По мере того как ваше приложение развивается и растёт, увеличивается и время его сборки — от нескольких минут при пересборке в development-режиме до десятков минут при «холодной» production-сборке. Это совершенно неприемлемо. Мы, разработчики, не любим переключать контекст в ожидании готовности бандла и хотим получать фидбек от приложения как можно раньше — в идеале за то время, пока переключаемся с IDE на браузер. Как этого достичь? Что мы можем сделать, чтобы оптимизировать время сборки? Эта статья — обзор существующих в экосистеме webpack инструментов для ускорения сборки, опыт их применения и советы. Оптимизации размера бандла и производительности самого приложения в этой статье не рассматриваются. Проект, отсылки к которому встречаются в тексте и относительно которого выполняются замеры скорости сборки, — это сравнительно небольшое приложение, написанное на стеке JS + Flow + React + Redux с использованием webpack, Babel, PostCSS, Sass и др. и состоящее из примерно 30 тысяч строк кода и 1500 модулей. Версии зависимостей актуальны на апрель 2019 года. Исследования проводились на компьютере с Windows 10, Node.js 8, 4-ядерным процессором, 8 ГБ памяти и SSD. Кеширование позволяет сохранять результаты вычислений для дальнейшего переиспользования. Первая сборка может быть немного медленнее обычной из-за накладных расходов на кеширование, однако последующие будут гораздо быстрее за счёт переиспользования результатов компиляции неизменившихся модулей. По умолчанию webpack в watch-режиме кеширует в памяти промежуточные результаты сборки, чтобы не пересобирать весь проект при каждом изменении. Для обычной сборки (не в watch-режиме) эта настройка не имеет смысла. Также можно попробовать включить кеш-резолвинг, чтобы упростить webpack работу по поиску модулей и посмотреть, оказывает ли эта настройка заметный эффект на ваш проект. Персистентного (сохраняемого на диск или в другое хранилище) кеша в webpack пока нет, хотя в 5-й версии его обещают добавить. А пока мы можем использовать следующие инструменты: — Кеширование в настройках TerserWebpackPlugin По умолчанию отключено. Даже в одиночку оказывает заметный положительный эффект: 60,7 с → 39 с (-36%), отлично сочетается с другими инструментами для кеширования. Включить и использовать очень просто: — cache-loader Cache-loader можно поместить в любую цепочку лоадеров и закешировать результаты работы предшествующих лоадеров. По умолчанию сохраняет кеш в папку .cache-loader в корне проекта. С помощью опции cacheDirectory в настройках лоадера путь можно переопределить. Пример использования: Безопасное и надёжное решение. Без проблем работает практически с любыми лоадерами: для скриптов (babel-loader, ts-loader), стилей (scss-, less-, postcss-, css-loader), изображений и шрифтов (image-webpack-loader, react-svg-loader, file-loader) и др. Обратите внимание: Результаты: — HardSourceWebpackPlugin Более массивное и «умное» решение для кеширования на уровне всего сборочного процесса, а не отдельных цепочек лоадеров. В базовом варианте использования достаточно добавить плагин в конфигурацию webpack, стандартных настроек должно быть достаточно для корректной работы. Подойдёт тем, кто хочет добиться максимальной производительности и не боится столкнуться с трудностями. В документации есть примеры использования с расширенными настройками и советы по решению возможных проблем. Перед вводом плагина в эксплуатацию на постоянной основе стоит тщательно протестировать его работу в различных ситуациях и режимах сборки. Результаты: Плюсы: Минусы: — Кеширование в настройках babel-loader. По умолчанию отключено. Эффект на несколько процентов хуже, чем от cache-loader. — Кеширование в настройках eslint-loader. По умолчанию отключено. Если вы используете этот лоадер, кеш поможет не тратить время на линтинг неизменившихся файлов при повторной сборке. При использовании cache-loader или HardSourceWebpackPlugin нужно отключить встроенные механизмы кеширования в других плагинах или лоадерах (кроме TerserWebpackPlugin), так как они перестанут приносить пользу при повторных и инкрементальных сборках, а «холодные» даже замедлят. То же относится к самому cache-loader, если уже используется HardSourceWebpackPlugin. При настройке кеширования могут возникнуть следующие вопросы: Куда следует сохранять результаты кеширования? Кеши обычно хранятся в каталоге node_modules/.cache/<название_кеша>/. Большинство инструментов по умолчанию используют этот путь и позволяют его переопределить, если вы желаете хранить кеш в другом месте. Когда и как инвалидировать кеш? Очень важно сбрасывать кеш при изменениях конфигурации сборки, которые повлияют на выходной результат. Использование старого кеша в таких случаях вредно и может привести к возникновению ошибок неизвестной природы. Факторы, которые нужно принимать во внимание: Если вы не используете cache-loader или HardSourceWebpackPlugin, которые позволяют переопределять список источников для формирования отпечатка сборки, немного облегчить жизнь вам помогут npm-скрипты, очищающие кеш при добавлении, обновлении или удалении зависимостей: Также помогут nodemon, настроенный на очистку кеша, и рестарт webpack-dev-server при обнаружении изменений в конфигурационных файлах: nodemon.json Нужно ли сохранять кеш в репозитории проекта? Так как кеш является, по сути, артефактом сборки, в репозиторий его коммитить не нужно. Как раз с этим поможет расположение кеша внутри папки node_modules, которая, как правило, внесена в .gitignore. Стоит заметить, что при наличии системы кеширования, умеющей надёжно определять валидность кеша при любых условиях, включая смену ОС и версии Node.js, кеш можно было бы переиспользовать между машинами разработчиков или в CI, что позволило бы радикально сократить время даже самой первой сборки после переключения между ветками. В каких режимах сборки стоит, а в каких не стоит использовать кеш? Однозначного ответа здесь нет: всё зависит от того, как интенсивно вы пользуетесь при разработке dev- и prod-режимами и переключаетесь между ними. В целом, ничто не мешает включить кеширование везде, однако помните, что оно обычно делает медленнее первую сборку. В CI вам, вероятно, всегда нужна «чистая» сборка, и в этом случае кеширование можно отключить с помощью соответствующей переменной окружения. Интересные материалы про кеширование в webpack: С помощью параллелизации можно получить прирост производительности, задействовав все доступные ядра процессора. Конечный эффект индивидуален для каждой машины. Кстати, вот простой Node.js-код для получения количества доступных процессорных ядер (может пригодиться при настройке перечисленных ниже инструментов): — Параллелизация в настройках TerserWebpackPlugin По умолчанию отключена. Так же, как и собственное кеширование, легко включается и заметно ускоряет сборку. — thread-loader Thread-loader можно поместить в цепочку лоадеров, производящих тяжёлые вычисления, после чего предшествующие лоадеры будут использовать пул подпроцессов Node.js («воркеров»). Имеет набор опций, которые позволяют достаточно тонко настроить работу пула воркеров, хотя и базовые значения выглядят вполне адекватно. Отдельного внимания заслуживают poolTimeout и workers — см. пример.  Может быть использован совместно с cache-loader следующим образом (порядок важен): . Если для thread-loader включён «прогрев» (warmup), стоит перепроверить стабильность повторных сборок, использующих кеш — webpack может зависать и не завершать процесс после успешного окончания сборки. В этом случае достаточно отключить warmup. Если вы столкнётесь с зависанием сборки после добавления thread-loader в цепочку компиляции Sass-стилей, вам может помочь этот совет. — HappyPack Плагин, который перехватывает вызовы лоадеров и распределяет их работу по нескольким потокам. На данный момент находится в режиме поддержки (то есть развитие не планируется), а его создатель рекомендует thread-loader в качестве замены. Таким образом, если ваш проект идёт в ногу со временем, от использования HappyPack лучше воздержаться, хотя попробовать и сравнить результаты с thread-loader, безусловно, стоит. HappyPack имеет понятную документацию по настройке, которая, кстати, довольно необычна сама по себе: конфигурации лоадеров предлагается переместить в вызов конструктора плагина, а сами цепочки лоадеров заменить на собственный лоадер happypack. Такой нестандартный подход может стать причиной неудобств при создании кастомной конфигурации webpack «из кусочков». HappyPack поддерживает ограниченный список лоадеров; основные и наиболее широко используемые в этом списке присутствуют, а вот работоспособность других не гарантируется по причине возможной несовместимости API. Больше информации можно найти в issues проекта. Любая работа занимает время. Чтобы тратить меньше времени, нужно избегать работы, которая приносит мало пользы, может быть отложена на потом или вообще не нужна в данной ситуации. — Применять лоадеры к минимально возможному числу модулей Свойства test, exclude и include задают условия для включения модуля в процесс обработки лоадером. Смысл — избегать трансформации модулей, которые не нуждаются в этой трансформации. Популярный пример — исключение node_modules из транспиляции через Babel: Другой пример — обычные CSS-файлы не требуется обрабатывать препроцессором: — Не включать оптимизации размера бандла в 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: — Пересмотреть использование 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 для замены импортов по названиям зависимостей на прямые импорты файлов, пути к которым были выписаны на предыдущем шаге. Например: Здесь кроется большой недостаток: если ваш код или код ваших зависимостей обращается не к стандартной точке входа (индексный файл, поле main в package.json), а к конкретному файлу внутри исходников зависимости, или если зависимость экспортируется как ES-модуль, или если в процесс резолвинга что-то вмешивается (например, babel-plugin-transform-imports), вся затея может провалиться. Бандл соберётся, однако приложение будет сломано. (4) В конфигурации webpack использовать опцию module.noParse, чтобы с помощью регулярных выражений пропускать парсинг предкомпилированных модулей, запрашиваемых по путям из шага 2. Например: Итого: на бумаге способ выглядит многообещающе, однако нетривиальная настройка с подводными камнями как минимум повышает затраты на внедрение, а как максимум — сводит пользу на нет. Альтернативный вариант с похожим принципом работы — использование опции externals. В этом случае придётся самостоятельно вставлять в HTML-файл ссылки на внешние скрипты, да ещё и с нужными версиями зависимостей, соответствующими package.json. — Выделять редко изменяющийся код в отдельный бандл и компилировать его только один раз Наверняка вы слышали про DllPlugin. С его помощью можно разнести по разным сборкам активно меняющийся код (ваше приложение) и редко меняющийся код (например, зависимости). Единожды собранный бандл с зависимостями (тот самый DLL) затем просто подключается к сборке приложения — получается экономия времени. Выглядит это в общих чертах так: Немного подробнее можно прочитать в статье по ссылке. Начав использовать этот подход, вы довольно быстро обнаружите ряд недостатков: Избавиться от бойлерплейта и решить первую проблему (а также вторую, если вы используете 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-режиме. Генерирование уникальных идентификаторов занимает какое-то время, которое можно сэкономить, если включения путей к файлам в имена классов достаточно, чтобы избежать коллизий. Пример: Выгода, тем не менее, невелика: на нашем проекте это менее полсекунды. Возможно, вы когда-нибудь встречали в документации webpack таинственный PrefetchPlugin, который вроде бы обещает ускорить сборку, но каким образом — неизвестно. Создатель webpack в одном из issues кратко рассказал о том, какую проблему решает этот плагин. Однако как же его использовать? Итого: слабо документированный способ без гарантии на заметный положительный результат. Если у вас есть дополнения, особенно с примерами на других технологиях (TypeScript, Angular и др.) — пишите в комментариях! Некоторые из них частично устарели, но, тем не менее, послужили основой для написания данной статьи. Фронтенд-разработчик