Компилятор файла: Babel, SWC, Oxc — и нужен ли вам Babel
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В прошлой статье свой сборщик искал импорты регуляркой, и та приняла import в комментарии и в строке за настоящие. Настоящий сборщик так не ошибается: файл разбирается в дерево, а переводит его компилятор файла. Это второй слой сборки: текст превращается в дерево, по дереву проходят посетители, дерево снова становится текстом. Сегодня соберём такой проход сами — подставим режим сборки и выбросим мёртвую ветку, — потом прогоним тот же файл через Babel, SWC, Oxc, esbuild и tsc и ответим, нужен ли вам сегодня Babel.
Сборщик из прошлой статьи работал с графом: какие файлы нужны, в каком порядке их выполнять, что увезти в отдельный кусок и как назвать файл. Внутрь файла он не заглядывал — для него это была строка. А внутри файла своя работа. JSX и аннотации типов браузер не разберёт. Константы режима сборки нужно подставить. И чтобы найти импорты, текст нужно разобрать, не путая код с комментарием и строкой. Эту работу делает компилятор файла: получает текст одного файла и возвращает JavaScript, понятный браузеру.
В настоящей сборке слои — разные инструменты. А третий слой, проверка типов, стоит отдельно от обоих:
| Слой | Что видит | Что делает | Кто это |
|---|---|---|---|
| сборщик | граф файлов целиком | обходит импорты, режет на куски, ставит хеш в имя | Rolldown в Vite, Turbopack в Next.js, webpack, Rspack, Metro в React Native, esbuild |
| компилятор файла | один файл | переводит TypeScript и JSX в JavaScript, подставляет константы | Oxc в Vite, SWC в Next.js, Babel в Expo, esbuild, tsc в режиме одного файла |
| проверка типов | всю программу | ищет ошибки типов, код не меняет | только tsc — обычно командой tsc --noEmit |
esbuild стоит в двух строках: он умеет и перевести файл, и собрать из файлов бандл — им в конце прошлой статьи резались куски. tsc тоже в двух: функция transpileModule из пакета шестой версии — той, что стоит в песочнице, — переводит ровно один файл, а команда tsc строит всю программу и проверяет её.
Проверка отделена не случайно. Чтобы сказать, что total(prices) вернёт строку, нужно открыть cart, из него — money, и так до конца цепочки: без всей программы тип не узнать. Компилятору файла соседи не нужны, ему хватает текста перед глазами. Поэтому файлы можно переводить независимо друг от друга, хоть параллельно, а проверку типов запускают отдельной командой. У этой независимости есть цена — правила вроде isolatedModules: файл обязан переводиться без соседей, потому что однофайловый компилятор в соседей не заглянет.
Такие компиляторы ещё называют транспайлерами: они переводят с языка на близкий язык, а не в машинный код. В документации инструментов встречаются оба слова.
Кроме перевода, компилятор файла подставляет константы сборки. Главная из них — process.env.NODE_ENV. В браузере объекта process нет, а библиотеки на эту строку опираются: пакет react по ней выбирает, какой файл отдать, отладочный или боевой. Поэтому сборка заменяет обращение строкой режима: "production" в выпускной сборке, "development" при разработке.
Проще всего заменить текст регуляркой — так в прошлой статье искали импорты. В файле ниже process.env.NODE_ENV встречается трижды: дважды в коде и один раз в тексте подсказки. new Function только разбирает получившийся код, не выполняя его. Прогноз: что станет со второй строкой и разберётся ли файл?
Вторая строка сломана: "задайте "production"". Кавычки подстановки закрыли строку раньше времени, за ними осталось голое слово production, и разобрать такой файл нельзя — отсюда SyntaxError. Регулярка не отличает обращение к свойству от тех же букв внутри строки или комментария. Будь подсказка в одинарных кавычках, вышло бы хуже: файл разобрался бы, а текст подсказки молча поменялся.
Компилятор не ищет в тексте буквы. Сначала он разбирает файл в дерево: каждая конструкция кода становится узлом, у узла есть вид и место в тексте. В статье про линтер это было одной фразой — парсер превращает TypeScript в дерево. Здесь дерево окажется у вас в руках.
ts.createSourceFile разбирает исходник из прошлого примера. visit спускается по дереву через ts.forEachChild и ищет узел вида PropertyAccessExpression — обращение к свойству через точку — с текстом process.env.NODE_ENV. Найдя такой, запоминает, где он начинается и кончается, и внутрь не идёт: там лежат process.env и process, их заменять не нужно. Сами замены делаются в конце, по запомненным позициям, с последней к первой. Прогноз: сколько замен найдёт обходчик?
ts здесь — настоящий компилятор TypeScript, который песочница загрузила ради ваших примеров. От него мы берём только разборщик и готовое дерево, а посетителя и правку пишем сами.
Замен две, подсказка цела. Для дерева текст в кавычках — узел StringLiteral: у него есть значение, но это не обращение к свойству, и условию посетителя он не подходит. Порядок «с конца» тоже не прихоть. "production" на восемь знаков короче process.env.NODE_ENV, и первая по тексту замена сдвинула бы позиции всех узлов за ней.
Разобрать, обойти, заменить узел — так устроен любой плагин компилятора. Вот тот же обходчик плагином Babel. Посетитель здесь — объект, где ключ — вид узла: Babel сам обходит дерево и зовёт метод MemberExpression на каждом обращении к свойству. matchesPattern сравнивает цепочку имён, replaceWith ставит на место узла новый строковый литерал. Позиции считать уже не нужно: Babel правит само дерево и печатает текст заново.
Узел здесь называется MemberExpression, а у TypeScript — PropertyAccessExpression: у каждого компилятора свои имена узлов, а идея одна. Вторая половина плагина, IfStatement, — тема следующего раздела.
После подстановки в файле осталось условие if ("production" !== "production"). Это сравнение двух литералов: ответ известен на сборке, и проверять его в браузере незачем. Второй проход, свёртка, вычисляет такие условия и оставляет от if только ту ветку, которая выполнится. В подложке — результат прошлого примера. Прогноз: что останется от файла?
Две строки. Условие ложно, ветки else нет — от if не осталось ничего, вместе с console.log("отладка"). Будь условие истинным, на месте if встал бы его блок. Так из выпускного бандла пропадает всё, что обёрнуто в сравнение с режимом: отладочные проверки, предупреждения, логи.
На том же держится выбор сборки React. Файл index.js пакета react — один if по режиму, а выпускная сборка Vite оставляет от него одну строку:
Условие стало "production" === "production", свёртка оставила первую ветку, и react.development.js в бандл не попал вовсе. Заметьте, что исчез require, стоявший внутри ветки. Импорт в начале файла свёртка не тронула бы: она убирает ветки, а не импорты.
Теперь один и тот же файл через пять настоящих компиляторов. В input.tsx — те же три строки с режимом, тип и компонент с JSX. Babel работает с нашим плагином, SWC — с оптимизатором simplify и подстановкой globals.envs, Oxc и esbuild — с настройкой define (esbuild ещё и с minifySyntax), а tsc получает тот же файл через transpileModule. Прогноз: в чьём выводе останется ветка «отладка»?
Ветка осталась только у tsc. Четыре других вывода совпали по сути: mode = "production", подсказка цела, if исчез. Форма у всех своя — esbuild, например, склеил объявления в одно и собрал экспорты в конец, — а результат один. tsc стёр тип и переписал JSX, но process.env оставил: такой настройки у него нет, и подстановка — не его работа. Подстановку делает не «компилятор вообще», а тот, кому её настроили. В Vite и Next это делает сама сборка, в своём скрипте на esbuild — ваш define.
Прогон нашёл четыре ловушки. У SWC значение в envs — код, а не строка: без двойных кавычек внутри одинарных подставилось бы имя переменной production. Мёртвую ветку SWC убирает, только когда включён simplify. esbuild без charset: "utf8" печатает кириллицу escape-последовательностями вида \u0437. И у Babel восьмой версии @babel/preset-typescript больше не принимает опции isTSX и allExtensions — с ними transformSync падает, а JSX в файле .tsx пресет включает сам, по расширению.
Та же подстановка работает и в самом учебнике. React для песочницы собирает esbuild-ом скрипт сайта, и режим там задан явно:
Поэтому в песочнице стоит отладочная сборка React: условие из index.js свернулось во вторую ветку. Минификация включена, но тексты предупреждений и ошибок React остаются полными — ради них сборка и сделана отладочной: в примерах части IV вы то и дело предсказываете, что именно скажет React.
В Expo те же два прохода делают разные инструменты: в выпускной сборке babel-preset-expo подставляет вместо Platform.OS строку платформы, а ветку сворачивает constantFoldingPlugin из Metro, который прогоняет transform-worker из @expo/metro-config. Это разобрано на настоящем выводе в части про React Native. А на доске задач тем же приёмом в бандл вшиваются переменные NEXT_PUBLIC_.
Кроме констант, компилятор убирает из файла всё, чего нет в JavaScript. Первое — JSX. Что разметка становится вызовом функции, вы видели в статье про JSX. Какой именно функции — решает настройка компилятора. transpileModule переводит один и тот же файл дважды: с JsxEmit.React и с JsxEmit.ReactJSX. Прогноз: вывод займёт три строки — какие?
JsxEmit.React — классический трансформ: разметка становится вызовом React.createElement, и React должен быть виден в файле. ReactJSX — автоматический: компилятор сам дописал импорт _jsx из react/jsx-runtime — это и есть третья строка, — а текст Hi переехал в пропсы как children. В песочнице учебника включён классический трансформ: разметка в примерах становится React.createElement, и эту функцию можно звать руками рядом с JSX. В проектах на Next.js, Vite и Expo включён автоматический. Его же настроили всем пяти компиляторам в сравнении выше, и импорт из react/jsx-runtime дописали все — только esbuild назвал функцию просто jsx.
При разработке функция ещё и другая. Вот начало main.tsx из шаблона react-ts в том виде, в каком Vite отдаёт его браузеру:
Вместо _jsx — _jsxDEV из react/jsx-dev-runtime, и у каждого вызова лишние аргументы: имя файла, строка и колонка разметки в исходнике. Остальное в этом ответе — работа самого Vite, а не компилятора: голые имена пакетов переписаны на адреса в /node_modules/.vite/deps/, а в конце файла встроена карта кода.
Второе, что компилятор убирает, — типы. Прогноз: что останется от аннотации и сколько ошибок насчитает transpileModule, если записать строку в число?
Аннотация стёрлась, "два" осталось, диагностик ноль. transpileModule видит один файл и сообщает только об ошибках разбора: чтобы заметить, что строка не число, нужна вся программа. Oxc, SWC, esbuild и Babel поступают с типами так же. Поэтому в проекте, где файлы переводит не tsc, проверка типов живёт отдельной командой tsc --noEmit. Песочница учебника разведена так же: диагностики под примером даёт программа TypeScript, построенная рядом, а выполняется результат transpileModule — поэтому ошибка типов запуск не отменяет.
Раз у компилятора есть дерево, импорты из него достаются без регулярки. Это первая задача статьи.
В таблице первого раздела Babel стоит рядом с SWC и Oxc. Какой из них работает в вашем проекте, решает платформа, и ответ меняется от версии к версии. Поэтому таблица — с датой, на 14.09.2026:
| Проект | Компилятор файла | Babel |
|---|---|---|
| Next.js 16.3.5 | SWC внутри Turbopack | включается сам, если в проекте найден babel-конфиг |
| Vite 8.3.0 | Oxc; @vitejs/plugin-react 6.1.1 Babel с собой не везёт | только если подключить: @rolldown/plugin-babel и @babel/core |
| Expo и React Native: Metro | Babel 7 с babel-preset-expo 57.0.11 | обязателен: metro-transform-worker 0.87.1 требует @babel/core ^7.25.2 |
| Jest 30.5.1 | babel-jest, если в проекте есть babel-конфиг | ставится вместе с Jest |
React Compiler: babel-plugin-react-compiler 1.0.0 | выпущен плагином Babel | в Vite — через Babel или экспериментальный порт на Rust oxc-transform-react (@vitejs/plugin-react с 6.1.0); в Next с 16.3 — экспериментальный turbopackRustReactCompiler без Babel |
| Nest, сервер части VI | tsc или SWC | не нужен |
сам Babel: @babel/core 8.0.5 | восьмая ветка с 16.06.2026: только ESM, Node 22.18+ или 24.11+ | седьмая получает исправления безопасности до июня 2027 |
Последняя строка — про сам Babel. Восьмая версия, по словам команды, новых функций не принесла. Заметнее всего для старого проекта, что @babel/preset-env по умолчанию больше не понижает код до ES5 — только если попросить явными targets. А седьмая ветка, на которой работает Metro, пока получает исправления безопасности.
Для веба из таблицы следует простое: настраивать Babel в Next и Vite не нужно, а завести его можно случайно. Хватит .babelrc, оставшегося от старого проекта. Ниже его положили в приложение из прошлой статьи. Прогноз: промолчит ли next build, найдя такой файл?
Не промолчит. Turbopack включил Babel для файлов проекта и написал об этом строкой Using external babel configuration, а раз в конфиге только стандартный пресет next/babel, предупредил, что файл, похоже, лишний. SWC при этом никуда не делся: внутренние преобразования Next и понижение синтаксиса делает он. В режиме --webpack жёстче: Disabled SWC as replacement for Babel — файлы проекта переводит Babel вместо SWC, а опции compiler из конфига Next больше не действуют. Если такие строки появились в вашем логе, а в babel-конфиге нет ничего, кроме next/babel, — конфиг можно удалять, о том и предупреждение.
Главное исключение в вебе — React Compiler. Он выпущен плагином Babel, и флаг reactCompiler в Next без пакета babel-plugin-react-compiler не соберётся. Но у обеих платформ уже есть экспериментальный порт компилятора на Rust, которому Babel не нужен. В Next это флаг experimental.turbopackRustReactCompiler — как его включить и что он выпускает, покажет статья про Turbopack. В Vite — react({ compiler: true }) вместе с пакетом oxc-transform-react.
Чтобы доказать, что Babel в сборке не участвует, его папки в node_modules на время сборки переименованы:
App собран с кешем компилятора — const $ = (0, import_compiler_runtime.c)(24) и проверками memo_cache_sentinel, — хотя ни @babel/core, ни Babel-плагина в node_modules не было. Без compiler: true та же функция — обычный useState и разметка. Порт помечен экспериментальным, а документация React описывает путь через Babel: @rolldown/plugin-babel с reactCompilerPreset.
Итог для веба: Babel нужно узнать в лицо и не завести случайно. Его след в проекте — babel-конфиг в корне и строка Using external babel configuration в логе сборки. В Expo и React Native всё наоборот: Babel — рабочая часть сборки. Через него не только подставляется платформа, но и работают плагины библиотек — например, плагин react-native-worklets, который находит в компоненте функции для UI-рантайма Reanimated. А какой сборщик приходит с какой платформой и когда выбирать его самому, разбирает последняя статья главы.
- Сборка — два слоя: сборщик работает с графом файлов, компилятор файла переводит каждый файл по отдельности. Типы проверяет только tsc, потому что для проверки нужна вся программа.
- Подстановка «найти и заменить» не отличает код от строки и комментария. Компилятор разбирает текст в дерево, посетитель находит узлы нужного вида, а правки идут по позициям узлов — с конца.
- После подстановки условие становится сравнением литералов, свёртка вычисляет его на сборке, и мёртвая ветка исчезает из бандла. Делает это тот, кому подстановку настроили:
transpileModuleеё не делает. - JSX — настройка компилятора: классический трансформ даёт
React.createElement, автоматический дописывает импорт_jsxизreact/jsx-runtime. - Компилятор файла стирает типы без проверки и сообщает только об ошибках разбора. Отсюда отдельная команда
tsc --noEmitи правилоisolatedModules. - В Next файлы переводит SWC, в Vite — Oxc, и Babel там нужно узнать в лицо, чтобы не завести случайно. В Expo и React Native Babel — рабочая часть сборки.
1/4Сборка заменила регуляркой все process.env.NODE_ENV в файле на "production". Что пострадает?
2/4Один и тот же файл прошёл через Babel с плагином, SWC, Oxc, esbuild и tsc. Почему ветка с отладкой осталась только после tsc?
3/4transpileModule переводит export let count: number = "два";. Что получится?
4/4В каком проекте Babel — обязательная часть сборки, а не случайный гость?
Импорты по дереву
В прошлой статье importsOf искала импорты регуляркой и нашла три зависимости там, где настоящая одна: две другие сидели в комментарии и в строке. Перепишите её на дереве. Верхний уровень файла лежит в tree.statements, объявление импорта узнаётся функцией ts.isImportDeclaration, путь — поле moduleSpecifier, а текст строкового узла — его поле text.
Объявление импорта бывает только на верхнем уровне модуля, поэтому обходить всё дерево не нужно — хватает списка statements.
Вывод:
Комментарий для дерева — вообще не узел: разборщик пропускает его, как пробелы. Строка-подсказка — объявление переменной со StringLiteral внутри, а не ImportDeclaration. Остаётся один настоящий импорт. Динамический import("./x.js") так не найдётся: это вызов внутри выражения, и искать его пришлось бы обходом в глубину, как process.env.NODE_ENV.
Свёртка тернарного оператора
Режим проверяют не только в if. В файле ниже адрес API выбирается тернарным оператором, а свёртка из раздела про мёртвую ветку его не видит и печатает строку с нетронутым ? :. Допишите visit так, чтобы тернарный оператор со сравнением двух строк заменялся выбранной веткой, а if по-прежнему исчезал. Узел узнаётся функцией ts.isConditionalExpression, у него поля condition, whenTrue и whenFalse. Проверяйте весь вывод, а не одну строку: вторая правка в том же файле ломает то, что с одной сходило с рук.
Если в свёртке из раздела проверять и тернарный оператор — брать у него condition, оставлять whenTrue или whenFalse — и по-прежнему менять out на месте, файл сломается. Тернарный оператор стоит раньше if и сворачивается первым, текст укорачивается, а позиции if дерево помнит по исходнику. Такая версия печатает:
Вырезан не тот кусок: от if остались обрывки. Лечится тем же приёмом, что в обходчике подстановки: правки собираются в список и применяются с конца. Проверка условия уходит в отдельную функцию, чтобы не писать её дважды. Начало файла с inlined — прежнее.
Вывод:
Условие истинно, поэтому на месте тернарного оператора встала ветка whenTrue, "/api", а адрес для разработки из выпускного файла исчез.
Что заметит компилятор одного файла
В обоих файлах ниже по одной ошибке. Предскажите обе строки вывода — сколько диагностик вернёт transpileModule и какой код он выпустит, — и только потом запускайте.
Ошибку типа компилятор одного файла не видит: аннотация стёрта, диагностик ноль. Синтаксическую видит — не разобрав файл, его не перевести, отсюда одна диагностика. Но код он выпустил и во втором случае, сломанный: export let count = ;. Поэтому инструмент, который зовёт компилятор, обязан сам смотреть на диагностики, а не отдавать дальше то, что вернулось.