В процессе сборки современных React-приложений фаза синтаксической трансформации исходного кода занимает от 40% до 70% всего времени сборщика (Webpack, Turbopack, Vite, Next.js). Исторически стандартом де-факто являлся Babel — компилятор на языке JavaScript, реализующий манипуляции с синтаксическим деревом через интерпретируемый рантайм Node.js.
Однако с ростом монорепозиториев до миллионов строк кода архитектурные ограничения Babel стали непреодолимым барьером:
thread-loader) требует дорогой JSON-сериализации графа AST между процессами Node.js.Решением нового поколения стал SWC (Speedy Web Compiler) — компилятор на языке Rust, компилируемый в нативный машинный код или WebAssembly. SWC выполняет синтаксический анализ, трансформации и генерацию кода в 10–50 раз быстрее Babel за счет отсутствия оверхеда JavaScript рантайма, ручного управления памятью и использования библиотеки быстрой сериализации без копирования (rkyv).
Плагины для SWC пишутся на Rust с использованием крейта swc_core и компилируются в таргет wasm32-wasip1, что позволяет исполнять их в любом тулчейне (Next.js, Vite, Rollup) с гарантированной изоляцией памяти и нативной скоростью.
Трансформация кода в обоих компиляторах базируется на паттерне Посетитель (Visitor), однако механизмы работы с памятью кардинально отличаются:
@babel/traverse выполняет рекурсивный спуск, генерируя объект-обертку NodePath для каждого узла. Это упрощает доступ к контексту родителя, но создает миллионы мелких аллокаций.enum Expr, enum Stmt и структуры JSXElement. Плагин реализует трейт VisitMut, выполняющий мутацию узлов «на месте» (in-place) по мутабельной ссылке &mut JSXElement без создания объектов-оберток.[ Исходный TSX/JSX код ]
│
▼
[ Лексический и синтаксический парсинг (Rust / SWC) ]
│
▼
[ Типизированный AST в памяти: Module / Program ]
│
▼
[ SWC Wasm Plugin Sandbox ] ◄── zero-copy ABI (rkyv)
│
├─► VisitMut::visit_mut_jsx_element() (Инъекция атрибутов)
├─► VisitMut::visit_mut_import_decl() (Оптимизация barrel-файлов)
└─► VisitMut::visit_mut_fn_decl() (Инъекция телеметрии)
│
▼
[ Codegen: Эмиссия высокопроизводительного JS ]Для передачи синтаксического дерева между основным процессом компилятора и Wasm-песочницей плагина SWC использует плоский бинарный буфер. Данные не сериализуются в текст JSON: структуры данных маппятся напрямую через указатели памяти.
Компилятор загружает бинарный модуль .wasm в изолированное окружение Wasmer/Wasmtime.
Дерево программы сериализуется через rkyv и передается в линейную память WebAssembly-песочницы.
SWC инициирует проход по AST. Узлы JSXElement передаются по мутабельной ссылке &mut.
Плагин анализирует открывающий тег JSXOpeningElement и конструирует новые атрибуты JSXAttribute.
Спецификаторы именованных импортов разбиваются на прямые пути к изолированным файлам модулей.
Результирующий бинарный буфер возвращается в хост-компилятор для финальной эмиссии JavaScript.
За счет чего SWC Wasm-плагины компиляции обеспечивают многократное преимущество в скорости по сравнению с плагинами Babel на JavaScript?
data-testid и data-source-file#В корпоративных приложениях E2E-тестировщики и разработчики сталкиваются с отсутствием стабильных селекторов. Ручная расстановка data-testid="submit-btn" засоряет production-бандл. Плагин компилятора:
data-testid на основе имени компонента и структуры DOM;data-source-file="src/features/Cart.tsx:42", позволяя кликом мыши в браузере с зажатым Alt мгновенно открывать строку файла в редакторе (Click-to-Component);Библиотеки вроде @mui/material, lucide-react или lodash-es содержат центральные входные файлы (index.ts), реэкспортирующие тысячи модулей. Импорт import { Button } from 'my-ui-lib' заставляет бандлер парсить и анализировать все 5000 компонентов библиотеки. Кастомный плагин компилятора во время сборки на уровне AST переписывает этот импорт в import Button from 'my-ui-lib/Button', снижая время "холодного старта" dev-сервера с 30 секунд до 800 мс.
Для замера взаимодействия с пользователем и поиска медленных рендеров плагин компилятора может оборачивать рендер целевых компонентов в метки performance.mark() и performance.measure(), связывая их с таймингами React Fiber без изменения исходных исходников инженерами.
data-source-file в JSX#Для создания плагина используется крейт swc_core. Плагин реализует трейт VisitMut.
use swc_core::ecma::{
ast::*,
visit::{noop_visit_mut_type, VisitMut, VisitMutWith},
};
use swc_core::plugin::{plugin_transform, proxies::TransformPluginProgramMetadata};
use swc_core::common::DUMMY_SP;
struct JsxSourceInjector {
filename: String,
}
impl VisitMut for JsxSourceInjector {
noop_visit_mut_type!();
fn visit_mut_jsx_element(&mut self, el: &mut JSXElement) {
// Сначала рекурсивно обрабатываем всех дочерних потомков
el.visit_mut_children_with(self);
// Формируем имя атрибута: data-source-file
let attr_name = JSXAttrName::Ident(IdentName::new("data-source-file".into(), DUMMY_SP));
// Формируем строковое значение: "Filename.tsx"
let attr_value = JSXAttrValue::Lit(Lit::Str(Str {
span: DUMMY_SP,
value: self.filename.clone().into(),
raw: None,
}));
// Создаем новый узел атрибута
let new_attr = JSXAttrOrSpread::JSXAttr(JSXAttr {
span: DUMMY_SP,
name: attr_name,
value: Some(attr_value),
});
// Вставляем атрибут в открывающий тег элемента
el.opening.attrs.push(new_attr);
}
}
#[plugin_transform]
pub fn process_transform(mut program: Program, metadata: TransformPluginProgramMetadata) -> Program {
let filename = metadata
.get_context(&swc_core::plugin::metadata::TransformPluginMetadataContextKind::Filename)
.unwrap_or_else(|| "unknown.tsx".to_string());
let mut visitor = JsxSourceInjector { filename };
program.visit_mut_with(&mut visitor);
program
}Конфигурация плагина в next.config.ts или .swcrc:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
swcPlugins: [
[
'swc-plugin-jsx-source',
{
developmentOnly: true,
},
],
],
},
};
export default nextConfig;Для сравнения реализуем плагин разворачивания barrel-импортов на JavaScript в экосистеме Babel:
export default function transformBarrelImports({ types: t }) {
return {
name: 'transform-barrel-imports',
visitor: {
ImportDeclaration(path, state) {
const source = path.node.source.value;
const targetPackage = state.opts.packageName || 'enterprise-ui';
// Проверяем, что импорт направлен на оптимизируемый barrel-пакет
if (source !== targetPackage) {
return;
}
const newImports = [];
for (const specifier of path.node.specifiers) {
// Обрабатываем только именованные импорты: import { Button, Input }
if (t.isImportSpecifier(specifier)) {
const importedName = specifier.imported.name;
const localName = specifier.local.name;
// Генерируем прямой импорт: import Button from 'enterprise-ui/Button'
const directSource = `${targetPackage}/${importedName}`;
const newImport = t.importDeclaration(
[t.importDefaultSpecifier(t.identifier(localName))],
t.stringLiteral(directSource)
);
newImports.push(newImport);
}
}
// Заменяем исходную декларацию серией изолированных импортов
if (newImports.length > 0) {
path.replaceWithMultiple(newImports);
}
},
},
};
}Селектор находит импорт: import { Button, Modal } from '@corp/ui-kit'.
Проверяется совпадение пути библиотеки с реестром известных barrel-файлов.
Список specifiers итерируется: извлекаются локальные имена переменных и импортируемые символы.
Исключаются namespace импорты (* as UI) и default импорты.
Генерация двух независимых узлов: import Button from '@corp/ui-kit/Button' и import Modal from '@corp/ui-kit/Modal'.
Координаты Span (позиция в исходном коде) переносятся для корректной генерации SourceMap.
Исходный узел удаляется, на его место вставляется массив синтезированных инструкций.
Бандлер теперь считывает строго два файла вместо 5000 файлов всей библиотеки.
Почему замена import { A } from 'lib' на прямой импорт import A from 'lib/A' на этапе AST компилятора эффективнее, чем расчет на встроенный Tree-shaking бандлера (Webpack/Rollup)?
При генерации новых узлов в Rust часто используют константу DUMMY_SP (пустой диапазон координат). Если привязать DUMMY_SP к исполняемым инструкциям или вызовам функций, отладчик в Chrome DevTools потеряет правильное сопоставление строк SourceMap, и точки останова (breakpoints) будут съезжать на 10 строк вверх или вниз. Копируйте span исходного замещаемого узла (node.span).
В JSX порядок атрибутов имеет решающее значение: <Component className="custom" {...props} /> отличается от <Component {...props} className="custom" />. Если плагин автоматически добавляет className или data-testid в конец массива атрибутов attrs.push(), он может непреднамеренно перезаписать пользовательские пропсы, переданные через спред {...props}. Всегда вставляйте дефолтные атрибуты в начало массива или инспектируйте спреды.
Если плагин компилятора не настроен на исключение директории node_modules, он будет повторно трансформировать вендорные пакеты, которые уже скомпилированы. Это замедляет время сборки в 5–10 раз и может сломать скомпилированный код библиотек. Всегда проверяйте metadata.get_context(Filename) и выходите досрочно.