По мере того как ваше приложение развивается и растёт, увеличивается и время его сборки — от нескольких минут при пересборке в development-режиме до десятков минут при «холодной» production-сборке. Это совершенно неприемлемо. Мы, разработчики, не любим переключать контекст в ожидании готовности бандла и хотим получать фидбек от приложения как можно раньше — в идеале за то время, пока переключаемся с IDE на браузер.
Как этого достичь? Что мы можем сделать, чтобы оптимизировать время сборки?
Эта статья — обзор существующих в экосистеме webpack инструментов для ускорения сборки, опыт их применения и советы.
Оптимизации размера бандла и производительности самого приложения в этой статье не рассматриваются.
Проект, отсылки к которому встречаются в тексте и относительно которого выполняются замеры скорости сборки, — это сравнительно небольшое приложение, написанное на стеке JS + Flow + React + Redux с использованием webpack, Babel, PostCSS, Sass и др. и состоящее из примерно 30 тысяч строк кода и 1500 модулей. Версии зависимостей актуальны на апрель 2019 года.
Исследования проводились на компьютере с Windows 10, Node.js 8, 4-ядерным процессором, 8 ГБ памяти и SSD.
Терминология
- Сборка — процесс преобразования исходных файлов проекта в набор связанных ассетов, в совокупности составляющих веб-приложение.
- dev-режим — сборка с опцией
mode: 'development', обычно с использованием webpack-dev-server и watch-режима. - prod-режим — сборка с опцией
mode: 'production', обычно с полным набором оптимизаций бандла. - Инкрементальная сборка — в dev-режиме: пересборка только файлов с изменениями.
- «Холодная» сборка — сборка с нуля, без каких-либо кешей, но с установленными зависимостями.
Кеширование
Кеширование позволяет сохранять результаты вычислений для дальнейшего переиспользования. Первая сборка может быть немного медленнее обычной из-за накладных расходов на кеширование, однако последующие будут гораздо быстрее за счёт переиспользования результатов компиляции неизменившихся модулей.
По умолчанию webpack в watch-режиме кеширует в памяти промежуточные результаты сборки, чтобы не пересобирать весь проект при каждом изменении. Для обычной сборки (не в watch-режиме) эта настройка не имеет смысла. Также можно попробовать включить кеш-резолвинг, чтобы упростить webpack работу по поиску модулей и посмотреть, оказывает ли эта настройка заметный эффект на ваш проект.
Персистентного (сохраняемого на диск или в другое хранилище) кеша в webpack пока нет, хотя в 5-й версии его обещают добавить. А пока мы можем использовать следующие инструменты:
— Кеширование в настройках TerserWebpackPlugin
По умолчанию отключено. Даже в одиночку оказывает заметный положительный эффект: 60,7 с → 39 с (-36%), отлично сочетается с другими инструментами для кеширования.
Включить и использовать очень просто:
optimization: { minimizer: [ new TerserJsPlugin({ terserOptions: { ... }, cache: true }) ] }
Cache-loader можно поместить в любую цепочку лоадеров и закешировать результаты работы предшествующих лоадеров.
По умолчанию сохраняет кеш в папку .cache-loader в корне проекта. С помощью опции cacheDirectory в настройках лоадера путь можно переопределить.
Пример использования:
{ test: /\.js$/, use: [ { loader: 'cache-loader', options: { cacheDirectory: path.resolve( __dirname, 'node_modules/.cache/cache-loader' ), }, }, 'babel-loader' ] }
Безопасное и надёжное решение. Без проблем работает практически с любыми лоадерами: для скриптов (babel-loader, ts-loader), стилей (scss-, less-, postcss-, css-loader), изображений и шрифтов (image-webpack-loader, react-svg-loader, file-loader) и др.
Обратите внимание:
- При использовании cache-loader совместно со style-loader или MiniCssExtractPlugin.loader он должен быть помещён после них:
['style-loader', 'cache-loader', 'css-loader', ...]. - Вопреки рекомендациям документации использовать этот лоадер для кеширования результатов только трудоёмких вычислений, он вполне может дать хоть и небольшой, но измеримый прирост производительности и для более «лёгких» лоадеров — нужно пробовать и замерять.
Результаты:
- dev: 35,5 с → (включаем cache-loader) → 36,2 с (+2%) → (повторная сборка) → 7,9 с (-78%)
- prod: 60,6 с → (включаем cache-loader) → 61,5 с (+1,5%) → (повторная сборка) → 30,6 с (-49%) → (включаем кеш у Terser) → 15,4 с (-75%)
Более массивное и «умное» решение для кеширования на уровне всего сборочного процесса, а не отдельных цепочек лоадеров. В базовом варианте использования достаточно добавить плагин в конфигурацию webpack, стандартных настроек должно быть достаточно для корректной работы. Подойдёт тем, кто хочет добиться максимальной производительности и не боится столкнуться с трудностями.
plugins: [ ..., new HardSourceWebpackPlugin() ]
В документации есть примеры использования с расширенными настройками и советы по решению возможных проблем. Перед вводом плагина в эксплуатацию на постоянной основе стоит тщател��но протестировать его работу в различных ситуациях и режимах сборки.
Результаты:
- dev: 35,5 с → (включаем плагин) → 36,5 с (+3%) → (повторная сборка) → 3,7 с (-90%)
- prod: 60,6 с → (включаем плагин) → 69,5 с (+15%) → (повторная сборка) → 25 с (-59%) → (включаем кеш у Terser) → 10 с (-83%)
Плюсы:
- по сравнению с cache-loader ещё больше ускоряет повторные сборки;
- не требует дублирования объявлений в разных местах конфигурации, как у cache-loader.
Минусы:
- по сравнению с cache-loader сильнее замедляет первую сборку (когда дисковый кеш отсутствует);
- может немного увеличивать время инкрементальной пересборки;
- может вызывать проблемы при использовании webpack-dev-server и требовать детальной настройки разделения и инвалидации кешей (см. документацию);
- достаточно много issues с багами на GitHub.
— Кеширование в настройках babel-loader. По умолчанию отключено. Эффект на несколько процентов хуже, чем от cache-loader.
— Кеширование в настройках eslint-loader. По умолчанию отключено. Если вы используете этот лоадер, кеш поможет не тратить время на линтинг неизменившихся файлов при повторной сборке.
При использовании cache-loader или HardSourceWebpackPlugin нужно отключить встроенные механизмы кеширования в других плагинах или лоадерах (кроме TerserWebpackPlugin), так как они перестанут приносить пользу при повторных и инкрементальных сборках, а «холодные» даже замедлят. То же относится к самому cache-loader, если уже используется HardSourceWebpackPlugin.
При настройке кеширования могут возникнуть следующие вопросы:
Куда следует сохранять результаты кеширования?
Кеши обычно хранятся в каталоге node_modules/.cache/<название_кеша>/. Большинство инструментов по умолчанию используют этот путь и позволяют его переопределить, если вы желаете хранить кеш в другом месте.
Когда и как инвалидировать кеш?
Очень важно сбрасывать кеш при изменениях конфигурации сборки, которые повлияют на выходной результат. Использование старого кеша в таких случаях вредно и может привести к возникновению ошибок неизвестной природы.
Факторы, которые нужно принимать во внимание:
- список зависимостей и их версии: package.json, package-lock.json, yarn.lock, .yarn-integrity;
- содержимое конфигурационных файлов webpack, Babel, PostCSS, browserslist и других, которые явно или неявно используются лоадерами и плагинами.
Если вы не используете cache-loader или HardSourceWebpackPlugin, которые позволяют переопределять список источников для формирования отпечатка сборки, немного облегчить жизнь вам помогут npm-скрипты, очищающие кеш при добавлении, обновлении или удалении зависимостей:
"prunecaches": "rimraf ./node_modules/.cache/", "postinstall": "npm run prunecaches", "postuninstall": "npm run prunecaches"
Также помогут nodemon, настроенный на очистку кеша, и рестарт webpack-dev-server при обнаружении изменений в конфигурационных файлах:
"start": "cross-env NODE_ENV=development nodemon --exec \"webpack-dev-server --config webpack.config.dev.js\""
nodemon.json
{ "watch": [ "webpack.config.dev.js", "babel.config.js", "more configs...", ], "events": { "restart": "yarn prunecaches" } }
Нужно ли сохранять кеш в репозитории проекта?
Так как кеш является, по сути, артефактом сборки, в репозиторий его коммитить не нужно. Как раз с этим поможет расположение кеша внутри папки node_modules, которая, как правило, внесена в .gitignore.
Стоит заметить, что при наличии системы кеширования, умеющей надёжно определять валидность кеша при любых условиях, включая смену ОС и версии Node.js, кеш можно было бы переиспользовать между машинами разработчиков или в CI, что позволило бы радикально сократить время даже самой первой сборки после переключения между ветками.
В каких режимах сборки стоит, а в каких не стоит использовать кеш?
Однозначного ответа здесь нет: всё зависит от того, как интенсивно вы пользуетесь при разработке dev- и prod-режимами и переключаетесь между ними. В целом, ничто не мешает включить кеширование везде, однако помните, что оно обычно делает медленнее первую сборку. В CI вам, вероятно, всегда нужна «чистая» сборка, и в этом случае кеширование можно отключить с помощью соответствующей переменной окружения.
Интересные материалы про кеширование в webpack:
- https://github.com/webpack/webpack/issues/6527 — черновик и обсуждение спецификации по добавлению в webpack 5 системы кеширования от автора HardSourcePlugin
- https://gist.github.com/mzgoddard/0b42ad50048f407c1f3ac434a874f8e1 — рассуждения автора HardSourcePlugin о том, как должна быть реализована система кеширования в webpack
- https://github.com/webpack-contrib/cache-loader/issues/11 — мнения об использовании cache-loader совместно с HardSourcePlugin
- https://github.com/webpack/webpack/issues/250 — обсуждение возможностей webpack по кешированию
- https://github.com/mzgoddard/hard-source-webpack-plugin/issues/251 — мнения об использовании HardSourcePlugin совместно с AutoDllPlugin
Параллелизация
С помощью параллелизации можно получить прирост производительности, задействовав все доступные ядра процессора. Конечный эффект индивидуален для каждой машины.
Кстати, вот простой Node.js-код для получения количества доступных процессорных ядер (может пригодиться при настройке перечисленных ниже инструментов):
const os = require('os'); const cores = os.cpus().length;
— Параллелизация в настройках TerserWebpackPlugin
По умолчанию отключена. Так же, как и собственное кеширование, легко включается и заметно ускоряет сборку.
optimization: { minimizer: [ new TerserJsPlugin({ terserOptions: { ... }, parallel: true }) ] }
Thread-loader можно поместить в цепочку лоадеров, производящих тяжёлые вычисления, после чего предшествующие лоадеры будут использовать пул подпроцессов Node.js («воркеров»).
Имеет набор опций, которые позволяют достаточно тонко настроить работу пула воркеров, хотя и базовые значения выглядят вполне адекватно. Отдельного внимания заслуживают poolTimeout и workers — см. пример.
Может быть использован совместно с cache-loader следующим образом (порядок важен): ['cache-loader', 'thread-loader', 'babel-loader']. Если для thread-loader включён «прогрев» (warmup), стоит перепроверить стабильность повторных сборок, использующих кеш — webpack может зависать и не завершать процесс после успешного окончания сборки. В этом случае достаточно отключить warmup.
Если вы столкнётесь с зависанием сборки после добавления thread-loader в цепочку компиляции Sass-стилей, вам может помочь этот совет.
Плагин, который перехватывает вызовы лоадеров и распределяет их работу по нескольким потокам. На данный момент находится в режиме поддержки (то есть развитие не планируется), а его создатель рекомендует thread-loader в качестве замены. Таким образом, если ваш проект идёт в ногу со временем, от использования HappyPack лучше воздержаться, хотя попробовать и сравнить результаты с thread-loader, безусловно, стоит.
HappyPack имеет понятную документацию по настройке, которая, кстати, довольно необычна сама по себе: конфигурации лоадеров предлагается переместить в вызов конструктора плагина, а сами цепочки лоадеров заменить на собственный лоадер happypack. Такой нестандартный подход может стать причиной неудобств при создании кастомной конфигурации webpack «из кусочков».
HappyPack поддерживает ограниченный список лоадеров; основные и наиболее широко используемые в этом списке присутствуют, а вот работоспособность других не гарантируется по причине возможной несовместимости API. Больше информации можно найти в issues проекта.
Отказ от вычислений
Любая работа занимает время. Чтобы тратить меньше времени, нужно избегать работы, которая приносит мало пользы, может быть отложена на потом или вообще не нужна в данной ситуации.
— Применять лоадеры к минимально возможному числу модулей
Свойства test, exclude и include задают условия для включения модуля в процесс обработки лоадером. Смысл — избегать трансформации модулей, которые не нуждаются в этой трансформации.
Популярный пример — исключение node_modules из транспиляции через Babel:
rules: [ { test: /\.jsx?$/, exclude: /node_modules/, loader: 'babel-loader' } ]
Другой пример — обычные CSS-файлы не требуется обрабатывать препроцессором:
rules: [ { test: /\.scss$/, use: ['style-loader', 'css-loader', 'sass-loader'] }, { test: /\.css$/, use: ['style-loader', 'css-loader'] } ]
— Не включать оптимизации размера бандла в dev-режиме
На мощной машине разработчика со стабильным интернетом развёрнутое локально приложение обычно запускается быстро, даже если весит несколько мегабайт. Оптимизация бандла при сборке может отнять гораздо больше др��гоценного времени, чем сэкономить при загрузке.
Совет касается JS (Terser, Uglify и др.), CSS (cssnano, optimize-css-assets-webpack-plugin), SVG и изображений (SVGO, Imagemin, image-webpack-loader), HTML (html-minifier, опция в html-webpack-plugin) и др.
— Не включать полифиллы и трансформации в dev-режиме
Если вы используете babel-preset-env, postcss-preset-env или Autoprefixer — добавьте отдельную конфигурацию Browserslist для dev-режима, включающую только те браузеры, которые используются вами при разработке. Скорее всего, это последние версии Chrome или Firefox, отлично поддерживающие современные стандарты без полифиллов и трансформаций. Это позволит избегать ненужной работы.
Пример .browserslistrc:
[production] your supported browsers go here... [development] last 2 Chrome versions last 2 Firefox versions last 1 Safari version
— Пересмотреть использование source maps
Генерирование наиболее точных и полных source maps занимает значительное время (на нашем проекте — около 30% времени prod-сборки с опцией devtool: 'source-map'). Подумайте, нужны ли вам source maps в prod-сборке (локально и в CI). Возможно, стоит генерировать их только при необходимости — например, на основе переменной окружения или тега у коммита.
В dev-режиме в большинстве случаев будет достаточно облегчённого варианта — 'cheap-eval-source-map' или 'cheap-module-eval-source-map'. Подробнее см. в документации webpack.
— Настроить сжатие в Terser
Согласно документации Terser (то же самое относится и к Uglify), при минификации кода подавляющую часть времени «съедают» опции mangle и compress. Тонкой их настройкой можно добиться ускорения сборки ценой незначительного увеличения размера бандла. Есть пример в исходниках vue-cli и другой пример от инженера из Slack. В нашем проекте тюнинг Terser по первому варианту сокращает время сборки примерно на 7% в обмен на 2,5-процентное увеличение размера бандла. Стоит ли игра свеч — решать вам.
— Исключать внешние зависимости из парсинга
С помощью опций module.noParse и resolve.alias можно перенаправить импортирование библиотечных модулей на уже скомпилированные версии и просто вставлять их в бандл, не тратя время на парсинг. В dev-режиме это должно значительно повысить скорость сборки, в том числе инкрементальной.
Алгоритм примерно следующий:
(1) Составить список модулей, которые нужно пропускать при парсинге.
В идеале это все runtime-зависимости, попадающие в бандл (или хотя бы самые массивные из них, такие как react-dom или lodash), причём не только собственные (первого уровня), но и транзитивные (зависимости зависимостей). Поддерживать этот список в дальнейшем придётся самостоятельно.
(2) Для выбранных модулей выписать пути к их скомпилированным версиям.
Вместо пропускаемых зависимостей нужно предоставить сборщику альтернативу, причём эта альтернатива не должна зависеть от окружения — иметь обращений к module.exports, require, process, import и т.д. На эту роль подходят заранее скомпилированные (не обязательно минифицированные) single-file модули, которые обычно лежат в папке dist внутри исходников зависимости. Чтобы найти их, придётся отправиться в node_modules. Например, для axios путь к скомпилированному модулю выглядит так: node_modules/axios/dist/axios.js.
(3) В конфигурации webpack использовать опцию resolve.alias для замены импортов по названиям зависимостей на прямые импорты файлов, пути к которым были выписаны на предыдущем шаге.
Например:
{ resolve: { alias: { axios: path.resolve( __dirname, 'node_modules/dist/axios.min.js' ), ... } } }
Здесь кроется большой недостаток: если ваш код или код ваших зависимостей обращается не к стандартной точке входа (индексный файл, поле main в package.json), а к конкретному файлу внутри исходников зависимости, или если зависимость экспортируется как ES-модуль, или если в процесс резолвинга что-то вмешивается (например, babel-plugin-transform-imports), вся затея может провалиться. Бандл соберётся, однако приложение будет сломано.
(4) В конфигурации webpack использовать опцию module.noParse, чтобы с помощью регулярных выражений пропускать парсинг предкомпилированных модулей, запрашиваемых по путям из шага 2.
Например:
{ module: { noParse: [ new RegExp('node_modules/dist/axios.min.js'), ... ] } }
Итого: на бумаге способ выглядит многообещающе, однако нетривиальная настройка с подводными камнями как минимум повышает затраты на внедрение, а как максимум — сводит пользу на нет.
Альтернативный вариант с похожим принципом работы — использование опции externals. В этом случае придётся самостоятельно вставлять в HTML-файл ссылки на внешние скрипты, да ещё и с нужными версиями зависимостей, соответствующими package.json.
— Выделять редко изменяющийся код в отдельный бандл и компилировать его только один раз
Наверняка вы слышали про DllPlugin. С его помощью можно разнести по разным сборкам активно меняющийся код (ваше приложение) и редко меняющийся код (например, зависимости). Единожды собранный бандл с зависимостями (тот самый DLL) затем просто подключается к сборке приложения — получается экономия времени.
Выглядит это в общих чертах так:
- Для сборки DLL создаётся отдельная конфигурация webpack, необходимые модули подключаются как точки входа.
- Запускается сборка по этой конфигурации. DllPlugin генерирует DLL-бандл и файл-манифест с маппингами имён и путей к модулям.
- В конфигурацию основной сборки добавляется DllReferencePlugin, в который передаётся манифест.
- Импорты зависимостей, вынесенных в DLL, при сборке отображаются на уже скомпилированные модули с помощью манифеста.
Немного подробнее можно прочитать в статье по ссылке.
Начав использовать этот подход, вы довольно быстро обнаружите ряд недостатков:
- Сборка DLL обособлена от основной сборки, и ей нужно управлять отдельно: подготовить специальную конфигурацию, запуск��ть заново каждый раз при переключении ветки или изменении в зависимостях.
- Так как DLL-библиотека не относится к артефактам основной сборки, её нужно будет вручную скопировать в папку с остальными ассетами и подключить в HTML-файле с помощью одного из этих плагинов: 1, 2.
- Нужно вручную поддерживать в актуальном состоянии список зависимостей, предназначенных для включения в DLL-бандл.
- Самое грустное: к DLL-бандлу не применяется tree-shaking. По идее, для этого предназначена опция
entryOnly, однако её забыли задокументировать.
Избавиться от бойлерплейта и решить первую проблему (а также вторую, если вы используете html-webpack-plugin v3 — с 4-й версией не работает) можно с помощью AutoDllPlugin. Однако в нём до сих пор не поддержана опция entryOnly для используемого «под капотом» DllPlugin, а сам автор плагина сомневается в целесообразности использования своего детища в свете скорого прихода webpack 5.
Разное
Регулярно обновляйте ваше ПО и зависимости. Более свежие версии Node.js, npm / yarn и инструментов для сборки (webpack, Babel и др.) часто содержат улучшения производительности. Разумеется, перед началом эксплуатации новой версии стоит внимательно ознакомиться с changelog, issues, отчётами по безопасности, убедиться в стабильности и провести тестирование.
При использовании PostCSS и postcss-preset-env обратите внимание на настройку stage, которая отвечает за набор поддерживаемых фич. Например, в нашем проекте был установлен stage-3, из которого использовались только Custom Properties, и переключение на stage-4 сократило время сборки на 13%.
Если вы используете Sass (node-sass, sass-loader), попробуйте Dart Sass (реализация Sass на Dart, скомпилированная в JS) и fast-sass-loader. Возможно, на вашем проекте они дадут прирост производительности сборки. Но даже если не дадут — dart-sass хотя бы устанавливается быстрее, чем node-sass, потому что является чистым JS, а не биндингом для libsass.
Пример использования Dart Sass можно найти в документации sass-loader. Обратите внимание на явное указание конкретной имплементации препроцессора Sass и использование модуля fibers.
Если вы используете CSS-модули, попробуйте отключить добавление хешей в сгенерированные имена классов в dev-режиме. Генерирование уникальных идентификаторов занимает какое-то время, которое можно сэкономить, если включения путей к файлам в имена классов достаточно, чтобы избежать коллизий.
Пример:
{ loader: 'css-loader', options: { modules: true, localIdentName: isDev ? '[path][name][local]' : '[hash:base64:5]' } }
Выгода, тем не менее, невелика: на нашем проекте это менее полсекунды.
Возможно, вы когда-нибудь встречали в документации webpack таинственный PrefetchPlugin, который вроде бы обещает ускорить сборку, но каким образом — неизвестно. Создатель webpack в одном из issues кратко рассказал о том, какую проблему решает этот плагин. Однако как же его использовать?
- Выгрузить в файл статистику сборки. Это делается с помощью CLI-опции
--json, подробнее см. в документации. Актуально, скорее всего, только для dev-режима сборки. - Загрузить полученный файл в специальный онлайн-анализатор и перейти на вкладку Hints.
- Найти секцию, озаглавленную “Long module build chains”. Если её нет, на этом можно закончить — PrefetchPlugin не понадобится.
- Для найденных длинных цепочек использовать PrefetchPlugin. В качестве стартового примера см. топик на StackOverflow.
Итого: слабо документированный способ без гарантии на заметный положительный результат.
В качестве заключения
Если у вас есть дополнения, особенно с примерами на других технологиях (TypeScript, Angular и др.) — пишите в комментариях!
Источники
Некоторые из них частично устарели, но, тем не менее, послужили основой для написания данной статьи.
- https://webpack.js.org/guides/build-performance/
- https://survivejs.com/webpack/optimizing/performance/
- https://github.com/webpack/docs/wiki/build-performance
- https://slack.engineering/keep-webpack-fast-a-field-guide-for-better-build-performance-f56a5995e8f1
- https://medium.com/webpack/typescript-webpack-super-pursuit-mode-83cc568dea79
- https://medium.com/ottofellercom/0-100-in-two-seconds-speed-up-webpack-465de691ed4a
- https://medium.com/onfido-tech/speed-up-webpack-ff53c494b89c
- https://blog.box.com/blog/how-we-improved-webpack-build-performance-95
- https://engineering.bitnami.com/articles/optimizing-your-webpack-builds.html
- https://habr.com/ru/company/skbkontur/blog/351080/
- https://habr.com/ru/company/oleg-bunin/blog/433324/
- https://github.com/lcxfs1991/blog/issues/15
- https://github.com/FrendEr/webpack-optimize-example
