За последние пару лет не раз можно было услышать про новые инструменты сборки статики, такие как SWC, esbuild и Vite. Все они обещают нам next gen-оптимизацию времени сборки, а SWC ещë и грозится оптимизировать скорость выполнения тестов на Jest; более того, судя по документации, сделать это очень просто. Я решил проверить, так ли это на самом деле и каким будет результат. Если вам интересно, что из этого получилось и какие были проблемы, то прошу под кат.

Приветствую тебя, дорогой читатель. Меня зовут Денис, я лидер фронтенд-разработки в Домклик. И сегодня хочу рассказать вам про попытку сократить длительность CI-пайплайна с помощью минимальных кодо-движений. Причина выбора SWC проста, можно оставить транспилирование production-кода as is на Babel, так мы не рискуем сломать или частично повредить наш прод.

Зачем вообще оптимизировать скорость сборки, линтинга, тестов? Причин можно написать поистине много, но вот основные: улучшить процесс разработки, сэкономить время разработчика, а также ускорить CI, длительность которого может сыграть злую шутку во время экстренной выкатки важного исправления.

Что будем делать:

  • подключим SWС;

  • проведём сравнение локально;

  • проведём сравнение на сборочной машине;

  • сделаем выводы.

Подключаем @swc/jest

Для эксперимента выбрал небольшой и свежий (в плане зависимостей и кодовой базы) проект, где всего 74 тестовых набора с 231 тестом. Под капотом React, экспериментальный синтаксис и, конечно же, es-модули.

Подключение выглядит довольно просто, достаточно установить несколько библиотек.

# npm
npm i -D jest @swc/core @swc/jest

# yarn
yarn add -D jest @swc/core @swc/jest

Далее, в настройках jest заменим плагин, который отвечает за транспилирование, в моём случае это был babel-jest. С ним и будем сравнивать результат, но об этом позже.

Было:

/* jest.config.js */

transform: {
  '^.+\\.(js|jsx)$': 'babel-jest'
}

Стало:

/* jest.config.js */

transform: {
  '^.+\\.(js|jsx)$': '@swc/jest'
}

И, судя по документации, сейчас мне стоит пристегнуть ремень, ведь я точно должен нарушить скоростной режим. Но как бы не так, запускаю тесты и получаю печальный результат.

Test Suites: 68 failed, 6 passed

Наверное, наивно было полагать, что SWC сам разберëтся с jsx-трансформациями, ему нужно немного помочь. Сделать это можно через файл .swcrc или же через jest.config.js. Так как пока нет планов полностью заменить Babel на SWC, то создавать лишний файл не стал, решил разобраться на месте. Про все настройки можно прочитать тут, а сейчас посмотрим только на те, которые понадобились в моём случае.

Включить поддержку jsx-синтаксиса можно опцией jsc.parser.jsx = true, а jsc.transform.react.runtime = automatic понадобится для новой jsx-трансформации. Примеры кода по классике располагаются ниже.

Было:

/* jest.config.js */

transform: {
  '^.+\\.(js|jsx)$': '@swc/jest'
}

Стало:

/* jest.config.js */

transform: {
  '^.+\\.(js|jsx)$': ['@swc/jest', {
    jsc: {
      parser: {
        jsx: true
      },
      transform: {
        react: {
          runtime: 'automatic'
        }
      }
    }
  }]
}

Ремень уже пристëгнут, я готов. Запускаю тесты.

Test Suites: 19 failed, 55 passed

Определëнный прогресс, конечно же, есть, но часть тестов по-прежнему падает, а в логах плюс-минус одинаковые сообщения.

TypeError: Cannot redefine property: useIntersection at Function.defineProperty (<anonymous>)

Stack trace привëл меня к методу spyOn и импортируемому модулю. Логично было сравнить, что же получается на выходе после импорта.

/* via babel-jest */

{
  __esModule: true,
  pluralize: [Getter],
  getCookie: [Getter],
  hasCookie: [Getter],
  setCookie: [Getter],
  formatPhone: [Getter],
  priceFormatter: [Getter],
  scrollToAnchor: [Getter],
  getRatingColor: [Getter]
}

/* via @swc/jest */

{
  pluralize: [Getter],
  getCookie: [Getter],
  hasCookie: [Getter],
  setCookie: [Getter],
  formatPhone: [Getter],
  priceFormatter: [Getter],
  scrollToAnchor: [Getter],
  getRatingColor: [Getter]
}

Жонглирование настройками модульности в SWC, к сожалению, не помогло. Но решение проблемы всë же нашлось в документации Jest.

When using the factory parameter for an ES6 module with a default export, the __esModule: true property needs to be specified. This property is normally generated by Babel / TypeScript, but here it needs to be set manually. When importing a default export, it's an instruction to import the property named default from the export object

Ниже приведу примеры старого кода и кода после рефакторинга.

Было:

/* начало примера */

import * as utils from 'utils';

import Price from '.';

describe('price component', () => {
 const scrollToAnchor = jest.spyOn(utils, 'scrollToAnchor');

 it('клик на кнопку "В продаже"', () => {
   render(<Price />);

   userEvent.click(screen.getByText(/Смотреть: 1 объявление/));
   expect(scrollToAnchor).toHaveBeenCalledTimes(1);
   expect(scrollToAnchor).toHaveBeenCalledWith('offers');
 });

 /* конец примера, дальше не интересно */

Стало:

/* начало примера */

import * as utils from 'utils';

import Price from '.';

jest.mock('utils', () => ({
 __esModule: true,
 ...jest.requireActual('utils')
}));

describe('price component', () => {
 const scrollToAnchor = jest.spyOn(utils, 'scrollToAnchor');

 it('клик на кнопку "В продаже"', () => {
   render(<Price />);

   userEvent.click(screen.getByText(/Смотреть: 1 объявление/));
   expect(scrollToAnchor).toHaveBeenCalledTimes(1);
   expect(scrollToAnchor).toHaveBeenCalledWith('offers');
 });

 /* конец примера, дальше не интересно */

В случае, если бы задача сводилась к заглушке возвращаемого значения, без какого-либо интерактива в виде подсчёта количества вызовов или проверки входящих аргументов, то достаточно просто использовать jest.mock без __esModule.

/* начало примера */

import * as effects from 'effects';

import Panorama from '.';

jest.mock('effects');

describe('panorama component', () => {
 beforeEach(() => {
   effects.useIntersection.mockReturnValue(true);
 });

 /* конец примера, дальше не интересно */

Лёгким движением руки сломанные тесты починились.

Test Suites: 74 passed

Наконец можно перейти к самому интересному, а именно — замерить скорость.

babel-jest vs @swc/jest

Замеряю скорость выполнения тестов, получаю неожиданный результат, а ожидания были, естественно, что скорость вырастет.

babel-jest

@swc/jest

33.19 s

32.36 s

32.98 s

34.82

...

...

Все последующие десять замеров в таком же духе, визуально разницы нет никакой. «Выдумал сам себе оптимизацию?», – подумал я. Читаю документацию ещë раз, в ней отчётливо вижу: “Improving Jest performance”. Нашёл issue, прочитал. С одной стороны, кажется, что можно уже сворачивать эксперимент, но с другой стороны, этот топик заставил меня взять время на раздумье.

На следующий день, решил очистить кеш Jest перед прогоном тестов, сделать это можно через CLI.

jest --clearCache

Повторно провожу замер за замером, и, конечно же, каждый раз чищу кеш.

babel-jest

@swc/jest

50.907 s

35.163 s

49.462 s

34.878 s

49.316 s

39.499 s

….

….

Как видите, разница есть, и стоит отметить, что этот эксперимент проводился на относительно небольшом проекте. Глядя на эти числа, можно смело сказать, что это своего рода хороший cheap tuning, но «вау-эффекта» не возникает. Не изучал исходники, но догадываюсь, что весь профит — это, скорее всего, транспилирование, и на сам процесс выполнения тестов SWC никак не влияет, что, конечно же, логично, ведь речь идëт про "speedy web compiler".

Но подводить итоги ещë рано, нужно протестировать, каков будет результат на сборочной машине в целом по CI.

@swc/jest пытается уехать на dev-стенд

Как только код уехал из локального окружения, начались проблемы. Вы, наверное, уже догадались, что тесты упали.

На самом-то деле в данном случае это помогло обратить внимание на одну не совсем очевидную вещь. Но обо всëм по порядку, начнём с ошибки и причины её возникновения. 

Bindings not found

Ошибка была вызвана тем, что для работы SWC в разных окружениях нужны разные бинарники. Например, для macOS нужен был @swc/core-darwin-x64, и хорошая новость в том, что устанавливаются они всë так же через NPM, более того, SWC делает это сам, выбирая нужный бинарник в зависимости от текущего окружения. Так как мы используем прокси-хранилище для зависимостей, то нужно было добавить в него @swc/core-linux-x64-musl, этот бинарник необходим для работы SWC под Alpine Linux. А вот уже после этого тесты успешно прошли. Если вы устанавливаете зависимости напрямую из NPM, то с этой проблемой, скорее всего, не столкнётесь. На всякий случай оставлю тут актуальный список бинарей.

«Финишная прямая», — подумал я и начал делать десятки замеров. Все они показали похожий результат, давайте разберëм один из этих замеров подробнее:

транспайлер

установка зависимостей

тесты

полный цикл CI

babel-jest

49 s

20.82 s

2.65 m

@swc/jest

53 s

9.962 s

2.6 m

Как можно заметить, разницы в полном цикле CI почти нет, и на этот раз дело даже не в кеше. При детальном изучении логов я увидел, что количество npm-модулей увеличилось с 2018 до 2075, увеличилась и длительность установки зависимостей. Помните, в начале этой главы я писал про «не совсем очевидную вещь»? Так вот, один только бинарник под Alpine в распакованном состоянии весит почти 79 Mб. Из-за этого Docker-образ начал весить больше, чем раньше, а мы начали тратить больше времени на его выгрузку, нивелировав всю разницу в скорости транспилирования для выполнения тестов. Что в итоге? Ускорили транспилирование, но в то же время замедлили установку зависимостей и последующий пайплайн для Docker-образа.

Ну что же, пришло время подвести итоги!

Выводы

Подключить SWC оказалось довольно просто, но не обошлось без рефакторинга, который пропорционально количеству тестов может иметь разную сложность.

SWC без кеша оказался действительно быстрей. Это не поможет нам в разработке, но потенциально может улучшить скорость CI, если речь идёт про большие проекты. В ином же случае овчинка выделки не стоит.

Да, можно сделать базовый образ Docker с уже предустановленными зависимостями, и тогда профит будет уже и на маленьких проектах, но это затраты на разработку и поддержку этого же образа.

В целом, если рассмотреть полный перевод транспилирования, включая production-код, из Babel в SWC, то, скорее всего, выгода будет даже безо всяких базовых образов. Но это совсем другая история, мем не добавляю, думаю, вы уже поймали флешбэк с Леонидом.

Что касается меня, я, скорее всего, продолжу дальше копаться в SWC; не то чтобы Babel меня не устраивал, но если что-то может позволить ускорится, то почему бы это и не сделать. А ещë я постараюсь перестать забывать про наличие кеша.

Пробовали ли вы использовать SWC у себя на проектах, если да, то какие результаты получили?

Комментарии (6)


  1. Doman
    21.11.2022 16:01

    Тоже двойственные чувства. С одной стороны, буст составил 50% и это круто. С другой - при локальном прогоне кэш быстро съедает всю разницу, выравнивая цифры, а в CI - 90% времени занимают разворачивание докер образа, установка зависимостей и подготовка к тестам (генерация пачки файлов), и двукратное ускорение оставшихся 10% на картину не влияют. Но, возможно, кому-то пригодится, тем более миграция действительно не очень сложная.

    Кстати, у вас ведь не просто Jest - я вижу render(). Как вам React Testing Library? Всем устраивает? Не думали перейти на другой фреймворк? И раз уж упомянули в статье Vite, то не смотрели в сторону Vitest?


    1. DenRedsky Автор
      21.11.2022 16:56

      React Testing Library - устраивает более чем, мы на него переехали с enzyme. Изъянов в нем не нашли, даже стало немного приятней тесты писать, но это конечно субъективно.

      По поводу vite, до него к сожалению руки еще не дошли. Очень хочу протестировать до конца swc и понять чем придется пожертвовать, если babel отключим. А потом уже esbuild, vite и может еще что-то интересное подвернется.


  1. DmitryKazakov8
    21.11.2022 20:03
    +1

    Когда я перевел сборку проекта (не тестов) с Babel на SWC получилось вот что. Верхние результаты с минификацией, которая тоже переехала с Terser на Terser.SWC. Также здесь включено море плагинов, которые остались без изменений, поэтому результат довольно чистый - изменился только транспайлер

    (CI) Babel без прогретого кеша + Terser

    #16 22.96 [WEBPACK] finished building node within 20.21 seconds
    #16 40.69 [WEBPACK] finished building web within 36.324 seconds

    (CI) SWC + Terser.SWC

    #16 13.14 [WEBPACK] finished building node within 10.57 seconds
    #16 29.60 [WEBPACK] finished building web within 24.831 seconds

    (Локальная сборка без минификации) Babel без прогретого кеша

    [WEBPACK] finished building node within 5.443 seconds
    [WEBPACK] finished building web within 6.745 seconds

    (Локальная сборка без минификации) Babel с прогретым кешем

    [WEBPACK] finished building node within 2.348 seconds
    [WEBPACK] finished building web within 4.044 seconds

    (Локальная сборка без минификации) SWC

    [WEBPACK] finished building node within 1.609 seconds
    [WEBPACK] finished building web within 3.742 seconds

    Все на одной машине, запуски делал раз по 5, среднее значение скорости менялось не сильно. Выводы получились следующие:

    • SWC не требует прогревания кеша, что максимально отражается в CI

    • Terser.SWC - очень быстрый минификатор, без него результаты не такие поразительные

    • Переезд на SWC получился почти безболезненный (да, его нужно настроить и подобрать альтернативные плагины, как у babel, но мои кейсы с недавним добавлением swc-loadable-components закрылись полностью). Из негативных моментов - нет плагина для оптимизации lodash и сахарные декораторы SWC несовместимы с некоторыми библиотеками, поэтому пришлось переписать их на функциональные.

    • Размер node_modules действительно вырос, но цифры у меня не сохранились.

    Также делал замеры на более крупных проектах, там тоже локальная сборка примерно на 20% ускорилась относительно бабеля с кешем. На мой взгляд, отличный результат "на ровном месте" почти без изменения кода


    1. DenRedsky Автор
      21.11.2022 20:42

      Точно, есть же еще и terser.

      Вьюшки часом не на react? Интересуюсь с целью понять на сколько хорошо работает react-refresh из коробки, немного смущен пометкой "эксперимент".

      И за наводку на loadable тоже спасибо, понадобится.


      1. DmitryKazakov8
        21.11.2022 21:02

        На реакте, да, но рефрешем не пользуюсь, поэтому тут не подскажу - в основном в проектах SSR (кстати тоже прекрасно работает в SWC), поэтому использую полную перезагрузку страницы. Если нужно поработать над компонентом - то какой-нибудь стайлгайд.


      1. DmitryKazakov8
        21.11.2022 21:22

        Кстати по поводу установки зависимостей в CI - они как правило меняются не часто, и можно сделать кеш слоев именно в CI

        #13 [ 5/10] COPY pnpm-lock.yaml /app/
        #13 CACHED
        #14 [ 7/10] RUN pnpm install --prod
        #14 CACHED

        Так будет упираться только в скорость копирования в большинстве случаев, а это хорошо оптимизируется. Лишние 100мб дадут лишь несколько секунд сверху. Я не силен в девопсировании, но у нас в рецепте билда на восстановление из кеша и все сопутствующее уходит 1 минута + 30с сборка, так что увеличение размера node_modules влияет незначительно.