Оптимизация сборки веб-приложения

По мере того, как ваше приложение растёт и развивается, растут и затраты времени на его тестирование и сборку, достигая нескольких минут при пересборке в dev-режиме и, возможно, десятков минут при «холодной» production-сборке. Что, конечно, совершенно неприемлемо. И если поначалу увеличение временных затрат может казаться незначительным, то впоследствии это непременно ведёт к ухудшению процесса разработки и может негативно повлиять на скорость выкатки важных релизов или хотфиксов. Таким образом, в какой-то момент вопрос оптимизации и ускорения сборки приложения может стать критически важным для разработчика.

19d48f9d8937ab9e704fc599b66bc54a.jpeg

Полный цикл сборки я разделяю на четыре основных составляющих:

  1. Установка зависимостей. Время исполнения команды npm ci.

  2. Статический анализ кода. Время линтинга JS и CSS.

  3. Запуск и успешное прохождение unit-тестов. Речь пойдёт о тестировании с помощью инструмента Jest;

  4. Непосредственно этап сборки. Будем рассматривать webpack.

Текущие показатели

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

Первым делом следует оценить размер вашего production-бандла. Самое популярное решение для этого — плагин 'webpack-bundle-analyzer'. Он создаст для вас интерактивную страницу с визуализацией содержимого всех ваших пакетов.

Также можно воспользоваться менее популярным на данный момент, но более удобным на мой взгляд, инструментом — 'statoscope'. C его помощью вы сможете более детально проанализировать вашу сборку: узнать её общий размер, размер и состав каждого отдельного чанка, приблизительное время скачивание вашего бандла в зависимости от типа соединения и много других полезных метрик. В сформированном отчёте будет представлена подробная информация по каждому используемому пакету. При этом вы, возможно, обнаружите, что часть из них попала в вашу продовую сборку по ошибке — это будет сигналом для того, чтобы исключить их из списка зависимостей.

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

Второй показатель — это время успешного завершения цикла сборки. Как локально, так и в полном цикле CI.

Зависимости

Обновление

Обратите внимание на установленные зависимости. Пробегитесь по списку установленных пакетов и убедитесь, что в проекте используется действительно каждый из них, и нет ничего лишнего. А если лишнее всё-таки есть, то смело удаляйте. Также убедитесь, что вы не устанавливаете очень тяжёлый пакет ради мизерной части его функциональности, которую вполне можно реализовать в несколько строчек. Считаю, что в такой ситуации лучше самому написать пару функций.

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

Для того, чтобы узнать размер и состав интересующего вас пакета, проанализировать историю изменений этих параметров от версии к версии и оценить, как его добавление или обновление повлияет на вашу сборку, вы можете воспользоваться сервисом 'bundlephobia'.

Установка

Устанавливайте зависимости вашего проекта, используя команду npm ci. Она предназначена специально для того, чтобы использовать её в автоматизированных средах для сборки и деплоя приложений, и имеет два основных преимущества:

  1. Скорость. При использовании npm ci происходит установка зависимостей из package-lock.json файла, в котором уже перечислены все необходимые зависимости, подзависимости и их версии для установки. Таким образом пропускаются некоторые шаги полного алгоритма установки, который выполняется при использовании команды npm i. За счёт этого и уменьшается затрачиваемое время. Это особенно актуально для крупных проектов с большим количеством зависимостей.

  2. Надёжность. Установка зависимостей из package-lock.json гарантирует, что в релизной версии вашего приложения, которую получат конечные пользователи, будут использоваться ровно те же версии пакетов, их зависимостей, подзависимостей, которые были зафиксированы в лок-файле на этапе разработки. Без каких-либо дополнительных обновлений или модификаций.

Статический анализ кода

Настройка конфигурации

Немалую долю времени в полном цикле продовой сборки может занимать линтинг. Особенно если вы используете готовые конфигурации, например 'eslint-config-airbnb', что в целом является хорошим вариантом для начала, но впоследствии может оказать негативное влияние на производительность из-за возможного наличия кучи лишних тяжёлых правил, которые не требуются вашему проекту. Поэтому как минимум стоит покопаться в конфигурации и определить, какие правила вам действительно необходимы. А лучшим решением будет взять в рамках технической квоты время на исследование и составление собственной конфигурации набора правил.

Оценка производительности ESLint

Для получения показателей производительности правил и выявления самых «тяжёлых» из них, на проверку которых уходит больше всего времени, вы можете добавить TIMING=1 в команду запуска ESLint. После завершения линтинга вам будет выведен список из десяти правил с наибольшим индивидуальным временем выполнения и их относительным влиянием на производительность в процентах от общего времени линтинга.

После анализа этого списка вы, возможно, примите решение полностью отказаться от части этих правил или попробуете найти более лёгкие и более быстрые альтернативы.

При этом всё-таки не стоит сразу заносить в исключения .eslintrc всё подряд без предварительного исследования. Помните, что часть этих правил гарантирует, что ваш код соответствует принятому стандарту, и бездумное их отключение без должного разбора может нарушить общую целостность.

Кеширование

Вдобавок к вышеперечисленному вы можете добавить в команду запуска линтера опцию кеширования --сache. Это может значительно повысить производительность ESLint во время выполнения благодаря тому, что анализироваться будут только измененные файлы, а результаты проверок кешируются. По умолчанию кеш хранится в .eslintcache.

Тестирование

Для любого средне-большого проекта необходимость ждать, бывает, и по 5–10 минут для прохождения юнит-тестов, прямо скажем, разочаровывает. И если вы уделяете достаточно внимания написанию и покрытию вашего кода тестами, то, вероятно, могли столкнуться с подобной проблемой.

Самые медленные тестовые сценарии

Начать оптимизацию стоит с определения и исправления самых медленных сценариев. Временно подключите пакет 'jest-slow-test-reporter', который поможет определить, на какие именно тестовые сценарии уходит больше всего времени. Пройдитесь по каждому файлу из списка, который предоставит плагин, и постарайтесь облегчить и упростить особо тяжёлые кейсы. После рефакторинга можете повторно запустить процесс, оценить результат и удалить репортер до худших времён. Собирать такую статистику в каждом цикле запуска тестов необязательно.

collectCoverage

Если вы собираете и анализируете информацию о покрытии ваших файлов, то вынесите эту задачу в отдельную команду вроде этой:

"test": "jest -c=jest/config.js --silent",
"test:coverage": "jest -c=jest/config.js --silent --coverage"

Не стоит добавлять параметр collectCoverage в файл конфигурации. Такой подход применяется для того, чтобы Jest не собирал статистику и не проводил подробный анализ при каждом запуске тестов. Так вы заметите прирост производительности. И, вероятно, даже больший, чем могли ожидать. При этом чем крупнее ваш проект, тем выше будет прирост.

testEnvironment

Используйте соответствующую тестовую среду. Начиная с версии 27.0.0 Jest по умолчанию использует среду node. Этот вариант более производительный, но не подходит для тестирования JSX-компонентов. Поэтому если в вашей конфигурации вы переопределили параметр по умолчанию на testEnvironment: ‘jsdom’, то отдельно для тестов, которым не требуется DOM (тесты каких-либо дополнительных вспомогательных функций), добавьте соответствующий докблок, чтобы указать Jest использовать среду node вместо jsdom:

/**
 * @jest-environment node
 */

Подключение @swc/jest

Пришло время раскрыть козырь. Используйте @swc/jest для значительного повышения производительности. SWC — это платформа, написанная на Rust, которая позиционируется как инструмент разработки следующего поколения, способный обеспечить продвинутую оптимизацию сборки. В том числе это касается и увеличения производительности модульного тестирования.

Процесс подключения @swc/jest, разбор части ошибок, с которыми вы можете столкнуться, преимущества и недостатки перехода, итоговые результаты и выводы — всё это рассматривает @DenRedsky в своей статье »Как я Jest с помощью SWC ускорял». По своему опыту могу сказать, что подключение и переход почти безболезненные, и результат стоит того.

Сборка webpack

Преобразования с помощью загрузчиков

Webpack использует загрузчики для предварительной обработки файлов. Они существуют почти для любого типа файлов. Для транспайлинга JavaScript самое популярное решение сегодня — использование babel-loader. Я же предлагаю вам воспользоваться преимуществом esbuild в вашей webpack-сборке. Вы можете подключить 'esbuild-loader':

module: {
  rules: [
    {
      test: /\.(js|jsx)$/,
      loader: 'esbuild-loader',
      options: {
        loader: 'jsx',
        target: 'es2015'
      },
      include: /src/
    },
...

Это сразу даст значительный прирост производительности. В моём случае — около 20% от длительности всего процесса сборки.

include и exclude

В вашем проекте некоторым файлам и папкам вообще не нужно участвовать в отдельных этапах сборки. С помощью параметров include и exclude вы можете указать загрузчикам, плагинам и минимайзерам, какие файлы стоит включать в обработку, а какие следует исключить. С такой настройкой, как в примере выше, esbuild-loader будет обрабатывать только те /\\.(js|jsx)$/-файлы, которые расположены в директории /src.

Более правильным решением будет задавать правила таким образом, чтобы предпочтение отдавалось именно параметру include, а не exclude. Уделите внимание этой настройке.

TerserPlugin

По умолчанию webpack для минификации кода использует 'terser', чтобы облегчить модули и уменьшить вес JavaScript-файлов, исключив всё лишнее. Это относительно долгий процесс, и можно заметить, что на этом этапе при стандартных параметрах минификации сборка всегда значительно замедляется, «встаёт».

Проверьте, что у вас установлена последняя версия плагина, а если нет, то обновите его. В моём случает простое обновление уже дало прирост производительности приблизительно на 10%.

Минификация JS

Для ускорения минификации .js можно переопределить минификатор для TerserPlugin с помощью параметра minify. Оптимальным вариантом с точки зрения производительности и качества итогового результата будет использовать здесь уже знакомый нам SWC:

optimization: {
  minimizer: [
    new TerserPlugin({
      minify: TerserPlugin.swcMinify,
    })
  ],
...

Минификация CSS

По аналогии с JavaScript для минификации CSS применяется 'css-minimizer-webpack-plugin', по умолчанию использующий cssnano — не самый оптимальный вариант с точки зрения производительности. Ниже представлена сводная таблица результатов бенчмарка CSS-минификаторов, актуальная на сейчас:

434cd504707197d6cb2eff0b18275897.png

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

Параллельный запуск скриптов

Такой подход поможет сократить общее время ожидания выполнения команд. Для параллельного запуска npm скриптов вы можете либо перечислить необходимые команды, разделяя их & (если вы используете Windows, то имейте в виду, что cmd.exe, которую npm использует по умолчанию, не поддерживает &), либо, что я сам и рекомендую, использовать такие инструменты, как 'npm-run-all' или 'concurrently'.

Для начала установите выбранный пакет в качестве dev-зависимости:  npm i -D npm-run-all, и объедините команды всех этапов в одну:

	"scripts": {
      "prod": "npm-run-all -p lint test build",
      "build": "webpack --node-env=prod",
      "lint": "stylelint 'src/**/*.scss' & eslint --ext .ts,.tsx src/",
      "test": "jest -c=jest.config.json --silent"
    }

Таким образом, в дальнейшем при запуске npm run prod все этапы сборки, а именно линтинг, тестирование и сборка webpack, будут идти параллельно. Благодаря чему их общее время выполнения будет меньше.

Заключение

Вот основные техники, которые я выделил для себя при исследовании темы оптимизации сборки веб-приложений. Надеюсь, эти советы помогут и вам. Если у вас есть дополнения к материалу или вы готовы поделиться своим опытом, секретными методами оптимизации и результатами их применения, то давайте обсудим в комментариях!

© Habrahabr.ru