Пишем плагин к OXC на Rust

• build, rust

Введение

Продолжаем тему прошлых постов про Babel 8 и плагин к SWC, сегодня соберём плагин для трансформации кода через OXC. Будет много отсылок на второй пост.

OXC – ещё один инструмент на Rust, как и SWC. Точнее, набор инструментов. В состав входят парсер, генератор кода из AST, линтер, инструмент форматирования кода, минификации и некоторые другие.

Представим, что нам нужно сделать с исходным кодом что-то произвольное, и попробуем это преобразование сделать с помощью OXC.

Ситуация с плагинами

Если в случае SWC была документация, а также рабочий способ публикации и интеграции плагинов в готовый инструмент, то с OXC-трансформациями ничего этого нет. Можно писать плагины на JS к линтеру (другому инструменту), но конкретно к трансформации пока нельзя. Разработчики опасаются за скорость из-за передачи данных между JS <-> Rust, так как в случае трансформации и наслоения плагинов друг на друга ситуация станет сильно сложнее, чем в линтере.

Чем же мы тогда займёмся? Конечно же, пойдём в репозиторий OXC и будем собирать его из исходников :)

Без шуток, если нам нужен кастомный плагин к OXC, то в репозитории уже есть пример реализации плагинов. Остаётся написать свой плагин, скомпилировать и запустить.

Ну а в конце подведём итоги, измерим скорость и сравним инструменты по удобству.

Пилим плагин

Все правки в виде коммита я положил на ГитХаб, при желании можно обращаться туда. Правок на 60 строк, что намекает, что начать не очень сложно.

Как и в случае с SWC, ставим Rust по официальной инструкции. Таргет для сборки добавлять не требуется, ставить пакеты в cargo – тоже (сами поставятся необходимые, когда потребуется).

В репозитории есть один интересный файл: define.rs.

Клонируем репозиторий, создаём файл test.js в корне со следующим содержимым:

// test.js
console.log(process.env.NODE_ENV);

Запускаем:

cargo run -p oxc_transformer_plugins --example define -- test.js

Должно вывестись console.log(development) (без кавычек, да). Или будет какая-то ошибка. Если вывелось – значит, всё хорошо, сборка работает.

Идея плагина такая же, как и в случае с SWC: будем удалять второй аргумент в функции run. Создаём новый файл:

// crates/oxc_transformer_plugins/src/remove_second_arg.rs
 
// Импорты
use oxc_ast::ast::*;
use oxc_ast_visit::{VisitMut, walk_mut};
use oxc_semantic::Scoping;
 
/// Возвращаемое значение для [`RemoveSecondArg::build`].
#[must_use]
pub struct RemoveSecondArgReturn {
    pub scoping: Scoping,
    pub changed: bool,
}
 
// Структура для обхода AST
pub struct RemoveSecondArg {
    changed: bool,
    scoping: Option<Scoping>,
}
 
impl<'a> VisitMut<'a> for RemoveSecondArg {
    // Обработчик обхода вызова функции в AST
    fn visit_call_expression(&mut self, call_expr: &mut CallExpression<'a>) {
        if let Expression::Identifier(ident) = &call_expr.callee {
            if ident.name.as_str() == "run" && call_expr.arguments.len() > 1 {
                call_expr.arguments.remove(1);
                self.changed = true;
            }
        }
        walk_mut::walk_call_expression(self, call_expr);
    }
}
 
impl RemoveSecondArg {
    pub fn new() -> Self {
        Self { changed: false, scoping: None }
    }
 
    // Основная функция
    pub fn build(&mut self, scoping: Scoping, program: &mut Program<'_>) -> RemoveSecondArgReturn {
        self.scoping = Some(scoping);
        self.visit_program(program);
        let scoping = self.scoping.take().unwrap();
        RemoveSecondArgReturn { scoping, changed: self.changed }
    }
}

В файле crates/oxc_transformer_plugins/src/lib.rs дописываем:

// ...
mod remove_second_arg;
// ...
 
// ...
pub use remove_second_arg::*;
// ...

В уже знакомом нам define.rs правим импорт (или просто добавляем)

use oxc_transformer_plugins::RemoveSecondArg;

и правим вызов старого плагина с дефайнами на новый:

let _ = RemoveSecondArg::new().build(scoping, &mut program);

Правим test.js и добавляем туда run(a, b), запускаем также, как и раньше:

cargo run -p oxc_transformer_plugins --example define -- test.js

Если вывелось run(a), то всё получилось.

Собираем релизную версию

Для полного контекста: я прописал следующий конфиг для релизной сборки в crates/oxc_transformer_plugins/Cargo.toml:

[profile.release]
codegen-units = 1
lto = true
opt-level = 3
strip = "symbols"

Запускаем сборку:

cargo build --release -p oxc_transformer_plugins --example define

После этого получится бинарь, который можно запускать так:

target/release/examples/define test.js

Это будет значительно быстрее, чем первоначальный cargo run.

Замеряем скорость

Дальше будет очень много условностей, но какие-то представления получить можно. Методика не выдерживает никакой критики, я знаю. Я не ставил целью получить на 100% точные данные, я просто хочу посмотреть порядок чисел и представление о разных подходах (в том числе теоретически достижимые).

Ради примера я взял не минифицированный реакт с react-dom общим весом около 1.2 МБ.

Итого есть 3 реализации плагина: на Babel, SWC и OXC.

Для прогона SWC я взял 2 подхода: без плагинов и с плагином (чтобы оценить оверхед от плагина).

Для OXC я сделал аналогичное: взял пакет oxc-transform из npm (понятное дело, без плагина) и собранный бинарь из прошлого раздела с плагином.

В каждом случае я сделал 100 прогонов одного и того же файла. Все прогоны внутри, файл читался 1 раз.

Соответственно, все прогоны работают через Node.js, кроме героя данной статьи. Его я взял за 100%:

ОтносительноВремя
OXC+plugin1x1.552s
OXC Node1.28x1.98s
SWC4.35x6.753s
SWC+plugin8.4x13.034s
Babel 813.27x20.599s
Babel 720.57x31.923s

Несмотря на всю кривизну прогона, сравнение SWC vs OXC в общем-то сходится с официальными бенчмарками OXC, в которых он примерно в 4 раза быстрее SWC.

Ещё одно интересное наблюдение про запуск OXC через Node.js, который медленнее на 28%, чем запуск нативного бинаря. Понятно, что Node.js требует времени на запуск, но тут явно что-то ещё. Передача строк между бинарным кодом и Node.js? Вероятно.

SWC c плагином почти в 2 раза медленнее, чем без плагина. А вот это сюрприз. Видимо, передача данных между Rust <-> WASM имеет большой оверхед по итогу. OXC, в котором всё внутри Rust, такой проблемы не имеет.

Babel всего в 1.58 раз медленнее, чем SWC с плагином. Я ожидал куда большего разрыва. Без плагина получаем около 3х раз. Вспоминая всякие посты про ускорение в 20-70 раз от SWC как-то… Грустно.

С другой стороны, всё это из-за сильного ускорения Babel в новом мажорном релизе. Примерно в 1.5 раза в таком примитивном сравнении – очень здорово. Будет ли такое ускорение в реальных кейсах ещё большой вопрос, но повод обновиться так точно.

Ну и нельзя не забыть о том, что OXC с плагином тяжело запустить из JS кода. Какое-то время уйдёт на работу обёрток, поэтому в реальности будет чуть хуже.

Итого

Результаты для меня несколько неожиданные. Если вы используете только стандартные трансформации, и прямо сейчас у вас Babel, то Esbuild/SWC/OXC дадут вам ускорение в разы, а в случае OXC – примерно в 10 раз. а то и больше.

С другой стороны, по моей практике время чаще уходит на минификацию / tsc (в рамках ts-loader, например), и уже потом Babel становится основным потребителем. Оцените сначала время вашей сборки, разделите составляющие, и, возможно, найдётся что-то более важное, чем Babel.

Теперь к кастомным трансформациям. Мы рассмотрели 3 инструмента и плагины к ним:

  • Babel. Просто и надёжно. Плагины пишут давно, куча док. А с 8й версией даже не так медленно, как можно подумать. Код переносимый, не требует сборки, удобно таскать рядом с проектом без каких-либо проблем.
  • SWC. Дока есть, но не очень актуальная. Проблемы с поломками бинарного апи при выходе новой версии (теоретические). Бинарная сборка Rust в WASM. Тут, конечно, плюс, что сам SWC поставляется в собранном виде для разных платформ, а WASM заработает в разных архитектурах процессоров и в разных ОС. Сам подход выходит за пределы привычного npm install и фронтендерского стека. Обычно не будешь заставлять разработчиков интерфейсов ставить Rust, тут нужна какая-то автоматизация по сборке WASM. Но суммарно работает быстрее, чем Babel.
  • OXC. Заметно быстрее SWC. Очень ограниченная документация. Формат “собери себе сам”. И ты не можешь просто собрать пакет, тебе нужны бинари под разные архитектуры (особенно, если разработчики могут быть на разном железе, а в CI вообще ещё что-то третье). Публикация получается заметно сложнее, чем в случае с SWC, и, тем более, с Babel.

Итого имеем понятную кривую: хочется просто и дёшево - берём Babel. Заметно сложнее и сколько-то быстрее – SWC. Хотим сделать максимально быструю сборку и у нас есть много ресурсов для этого – идём копать OXC.

Какой итог по самому OXC? Впечатляюще. Очень. При этом разработчики пишут, что они старались в совместимость с тестами Babel, а без них было бы ещё лучше. Использовать сейчас для кастомного плагина тяжело, но результаты могут того стоить. Ждём публичное апи для плагинов? Возможно, оно тоже будет в WASM.

А пока может выглядеть чужеродно для “обычного” фронтенд-проекта. Или привыкнем?

Обсудить в Telegram

Почитать ещё посты

  • Пишем плагин к SWC на Rust
  • Вышел Babel 8. А нужен ли он?
  • Автоматические размеры полей с field-sizing
  • text-fit: свойство, которое дождались
  • Декораторы отступов, или свойства, которые у нас уже давно были