По мере того как ваше приложение развивается и растёт, увеличивается и время его сборки — от нескольких минут при пересборке в 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 можно поместить в любую цепочку лоадеров и закешировать результаты работы предшествующих лоадеров.


По умолчанию сохраняет кеш в папку .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%)

HardSourceWebpackPlugin


Более массивное и «умное» решение для кеширования на уровне всего сборочного процесса, а не отдельных цепочек лоадеров. В базовом варианте использования достаточно добавить плагин в конфигурацию 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:



Параллелизация


С помощью параллелизации можно получить прирост производительности, задействовав все доступные ядра процессора. Конечный эффект индивидуален для каждой машины.


Кстати, вот простой Node.js-код для получения количества доступных процессорных ядер (может пригодиться при настройке перечисленных ниже инструментов):


const os = require('os');
const cores = os.cpus().length;

— Параллелизация в настройках TerserWebpackPlugin


По умолчанию отключена. Так же, как и собственное кеширование, легко включается и заметно ускоряет сборку.


optimization: {
  minimizer: [
    new TerserJsPlugin({
      terserOptions: { ... },
      parallel: true
    })
  ]
}

thread-loader


Thread-loader можно поместить в цепочку лоадеров, производящих тяжёлые вычисления, после чего предшествующие лоадеры будут использовать пул подпроцессов Node.js («воркеров»).


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


Может быть использован совместно с cache-loader следующим образом (порядок важен): ['cache-loader', 'thread-loader', 'babel-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:


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) затем просто подключается к сборке приложения — получается экономия времени.


Выглядит это в общих чертах так:


  1. Для сборки DLL создаётся отдельная конфигурация webpack, необходимые модули подключаются как точки входа.
  2. Запускается сборка по этой конфигурации. DllPlugin генерирует DLL-бандл и файл-манифест с маппингами имён и путей к модулям.
  3. В конфигурацию основной сборки добавляется DllReferencePlugin, в который передаётся манифест.
  4. Импорты зависимостей, вынесенных в 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 кратко рассказал о том, какую проблему решает этот плагин. Однако как же его использовать?


  1. Выгрузить в файл статистику сборки. Это делается с помощью CLI-опции --json, подробнее см. в документации. Актуально, скорее всего, только для dev-режима сборки.
  2. Загрузить полученный файл в специальный онлайн-анализатор и перейти на вкладку Hints.
  3. Найти секцию, озаглавленную “Long module build chains”. Если её нет, на этом можно закончить — PrefetchPlugin не понадобится.
  4. Для найденных длинных цепочек использовать PrefetchPlugin. В качестве стартового примера см. топик на StackOverflow.

Итого: слабо документированный способ без гарантии на заметный положительный результат.


В качестве заключения


Если у вас есть дополнения, особенно с примерами на других технологиях (TypeScript, Angular и др.) — пишите в комментариях!


Источники


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