Pull to refresh

Ускоряем сборку веб-приложения с webpack

Reading time 14 min
Views 41K

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


Источники


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


Tags:
Hubs:
+27
Comments 10
Comments Comments 10

Articles