Ускорение сборки JavaScript-кода с использованием webpack 2–3

в 6:02, , рубрики: javascript, SPA, webpack, Блог компании Контур, веб-приложения, Клиентская оптимизация, Разработка веб-сайтов, системы сборки

Появляется все больше SPA салонов. Даже лендинги люди пилят на React. А действительно сложное веб-приложение уже трудно представить с другим подходом. Одна из главных проблем современного фронтенда — это сборка таких проектов. С этим помогают справляться бандлеры.

Иван Соснин, фронтенд-разработчик Контура, рассказывает как настроить webpack 2 и 3, чтобы получить ощутимый прирост в скорости сборки статики. Статья будет полезна тем, кто уже работает с webpack или смотрит в его сторону.

Стоит начать с ремарки, что недавно вышел webpack 4. Там вообще все супербыстро и ничего делать не надо, а еще изменилось процесс разбиения кода на чанки.

Но тащить в продакшен библиотеки, которые обновились вчера — не мой путь.

Webpack

Webpack — это сборщик модулей (бандлер). Он собирает различные модули с зависимостями в один или несколько файлов (бандлов). У webpack модульная архитектура, а это значит, что его можно гибко настраивать. Сборка кода настраивается при помощи плагинов, а трансформации кода производятся с помощью загрузчиков (loaders).

Если хочется больше базовых подробностей, можно почитать статью Рахима Давлеткалиева про webpack 1. Она немного устаревшая, но идеи и примеры в ней разобраны подробно.

За всю эту гибкость приходится платить сложной конфигурацией.

Настройка webpack ранних версий — был процесс творческий и мог длиться бесконечно. Ситуация несколько изменилась с выходом второй версии и появлением внятной документации. Но остается много настроек, которые не лежат на поверхности. Это связано с тем, что существует много open-source решений, которые встраиваются в процесс сборки.

Другие сборщики:

  • browserifyrequire() для браузера. По возможностям сильно уступает webpack (умеет работать только с JS);
  • rollup — позиционируют себя как сборщик, который генерирует самые быстрые и маленькие бандлы;
  • parcel — супербыстрый, но пока зеленый бандлер, не требующий конфигурации. Любопытный зверек, но пока применим только на небольших проектах (до недавних пор вообще не умел в source map). Кстати, и webpack уже сделал шаг к zero configuration.

Немного цифр

У нас в проекте довольно много клиентского кода: ~2000 js/jsx файлов (~300000 строк) и ~800 файлов scss (~50000 строк). Всю эту красоту нужно как-то собирать и для этого мы используем webpack 3. Очевидно, что с ростом кодовой базы скорость сборки выше не станет. Это значит, что нужно искать пути оптимизации скорости сборки. Вообще, на эту тему уже есть довольно много статей и обсуждений, но они обычно затрагивают какую-то одну часть сборки (кеширование, пребилд вендорных библиотек и т.д.). Я собрал различные направления оптимизации с конкретными примерами.

Для разных проектов были разные результаты. Например, в одном соседнем проекте скорость сборки выросла с 3.5 минут до 30 секунд. Для моего проекта статистика ниже.

До всех изменений После изменений
"Холодный" билд для продакшена 14 минут 3 минуты
Ребилд для продакшена 12 минут 2 минуты
"Холодный" билд для разработки 17 минут 3 минуты
Ребилд для разработки 5 минут 30 секунд

Процесс билда во всех случаях одинаковый: сначала нужно установить зависимости, затем собрать 4 приложения, в которых по несколько entry points.

В данном случае "холодный билд" подразумевает, что очищены все кеши проекта (кроме локального кеша yarn, об этом далее), нет папки node_modules. А ребилд подразумевает повторный запуск сборки.

Я разделил билды для продакшена и для разработки, потому что у них отличаются конфиги, например, в билде для разработки совсем нет Uglify.

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

Что можно оптимизировать?

  • установка зависимостей;
  • процесс сборки проекта;
  • инкрементальный ребилд (для разработки);
  • уменьшение количества кода, который попадает в бандл (tree shaking);
  • кеширование на разных уровнях;
  • распараллеливание отдельных этапов сборки.

Используйте npm-клиент, который умеет в кеширование

Я использую yarn. Он довольно удачно зарелизился и решал многие проблемы нативного клиента npm на тот момент.

В октябре 2016, когда вышел yarn, npm был версии 3.10.9 и до релиза версии 5.0.0 было еще примерно полгода. Некоторые проблемы, которые решил yarn:

  • механизм фиксации всех зависимостей был костыльный: была лишь команда npm shrinkwrap, которая создавала lockfile;
  • npm был сильно зависим от стабильности сети. Эту проблему решала тулза shrinkpack, которая архивировала все текущие зависимости и подменяла пути в lockfile на локальные. Все бы ничего, но все эти тысячи архивов нужно было таскать в репозитории. И при любом мерже ловить конфликты в бинарях. Подробнее про эту тему можно узнать из интересного доклада с WSD 2016 в Екатеринбурге;
  • повторная установка пакетов, если ничего не менялось, все равно длилась какое-то время;
  • субъективно, но меня сильно порадовал визуальный режим обновления пакетов: yarn upgrade-interactive, хотя и для npm есть аналоги.

Сейчас уже есть альтернативы yarn: например, npm научился кешировать, и еще есть pnpm, который вообще в node_modules только хардлинки создает.

Сравнение скорости установки на ~1300 пакетах:

npm 5 yarn 0.24.6 pnpm
Установка с локальным кешем ~1 минута ~3 минуты ~1 минута
Повторная установка ~20 секунд ~1 секунда ~1 секунда
Lockfile ️ ✓ ️ ️ ️✓ ️ ️✓

Можно почитать еще занятное сравнение на hackernoon.

И если вы до сих пор не фиксируете зависимости в package.json — самое время начать.

Используйте кеширование у babel-loader

Конечно, в том случае, если вы трансформируете код и используете babel. Webpack-конфиг будет примерно такой:

test: /.jsx?$/,
use: [{
    loader: 'babel-loader',
    options: {
        cacheDirectory: true
    }
}]

По умолчанию кеш складывается в node_modules/.cache/babel-loader, но можно указать другой каталог.

Разница: 667 сек ⟶ 614 сек (8%)

Используйте HardSourcePlugin

Плагин для webpack, который кеширует собранные модули. Есть большой issue на тему кеширования в webpack, там и зародилась идея этого плагина. По ссылке как раз пост автора плагина.

Подключается в конфиге webpack:

plugins: [
    new HardSourceWebpackPlugin()
]

По умолчанию кеш складывается в node_modules/.cache/hard-source, но можно указать другой каталог.

В моем случае, просто подключение этого плагина без конфига, дало прирост с 200 секунд до 50 (при наличии кеша).

При использовании webpack-dev-server и postcss придется поработать напильником.

Замеченные проблемы:

  • иногда не видит изменений.

Разница: 275 сек ⟶ 53 сек (80%)

Используйте webpack-parallel-uglify-plugin

UglifyJS — это инструмент, который используется для минификации JS-кода. Webpack добавляет его в плагины автоматически, если собирать бандл с флагом -p.

Для использования webpack-parallel-uglify-plugin есть 2 причины:

  • умеет кешировать;
  • запускает Uglify в параллельных потоках.

Если использовать этот плагин, то придется вручную добавлять его в продакшен-сборку и уже не пользоваться флагом -p. Пример конфига:

plugins: [
    new ParallelUglifyPlugin({
        cacheDir: path.join(dir.root, "node_modules", ".cache", "parallel-uglify"),
        uglifyJS: {/* uglifyjs options */}
    })
]

Разница: 627 сек ⟶ 391 сек (38%)

Не очищайте каталог сборки проекта

Этот пункт следует из описанных выше. Не стоит удалять node_modules перед деплоем клиентского кода.

Используйте DllPlugin

У многих наверняка подключены в проектах библиотеки или фреймворки, которые используются по всему проекту. Такие библиотеки можно каждый раз не пересобирать. С этим нам поможет пара встроенных в webpack плагинов: DllPlugin и DllReferencePlugin.

Для начала нужно вынести в отдельный конфиг сборку DLL. Это ваш обычный webpack-конфиг, где обязательно должен быть подключен DllPlugin:

// webpack.vendor-dll.config.js
new webpack.DllPlugin({
    name: 'vendor',
    path: 'prebuild/' + environment + '/vendor-manifest.json',
})

Переменная environment здесь — это process.env.NODE_ENV. Потому что я хочу разделять девелоперскую и продакшн сборки DLL.

Для установки process.env.NODE_ENV можно посмотреть на пакет cross-env. Тогда npm-script может выглядеть как-то так: "deploy:app1": "cross-env NODE_ENV=production webpack --progress --config ./path/to/app1/webpack.config.js",

После сборки у вас получится 2 файла: vendor-manifest.json и какой-нибудь dll.vendor.js. Их нужно закоммитить в репозиторий. По крайней мере, версии для продакшена.

В вашем основном конфиге нужно добавить DllReferencePlugin:

// webpack.config.js
new webpack.DllReferencePlugin({
    manifest: require('./prebuild/' + NODE_ENV + '/vendor-manifest.json'),
})

Возможно, вы хотите, чтобы DLL, который вы коммитите в репозиторий, лежал рядом с вашими бандлами. Здесь вам поможет CopyWebpackPlugin:

new CopyWebpackPlugin([
    {
        context: path.join(__dirname, 'prebuild', NODE_ENV),
        from: '*',
    },
], {
    ignore: [
        'webpack-vendor-assets.json',
        'vendor-manifest.json',
    ],
})

Больше примеров

Разница: 233 сек ⟶ 213 сек (9%)

Используйте css-loader < v0.15

Начиная с версии 0.15 css-loader начал сильно замедлять сборку. Судя по комментариям, у некоторых сборка замедлилась больше, чем в 50 раз. В моем же случае разница была, но не такая большая.

Список фич, которые нельзя будет использовать при понижении версии:

Но CSS Modules и scope вполне можно использовать. Полная документация для версии 0.14.5.

Разница: 213 сек ⟶ 185 сек (13%)

Используйте CommonsChunkPlugin

Этот webpack-плагин умеет выносить общий код указанных модулей в отдельный чанк. То есть, если у вас есть 2 бандла 1.bundle.js и 2.bundle.js, и в обоих используются, скажем, React и Redux, они окажутся в отдельном чанке, а в бандлах их не будет.

Пример и результаты использования можно посмотреть в репозитории webpack. А более подробно работа плагина описана в теме на StackOverflow.

Пример конфига:

plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      names: ["common", "manifest"],
      minChunks: Infinity
    })
]

При этом у меня один entry point (не минифицированный) похудел с 4.4 Мб до 4.2 Мб (5%).

Разница: меньше 10 сек

Экспериментируйте! И изучайте ваши бандлы! И снова экспериментируйте!

Используйте настройку noParse

Эта настройка webpack позволяет избежать парсинга определенных библиотек или файлов. Webpack просто добавит такой модуль в бандл, без преобразования.

То есть, если какая-то библиотека доставляется в npm в минифицированном виде и в ней нет никаких require и, например, кода, который нужно компилировать, ее можно не прогонять через webpack, потому что это будет бесполезно и может быть долго, а сразу засунуть в бандл.

Разница: меньше 10 сек

Используйте настройку cache

Эта настройка webpack позволяет кешировать модули и чанки. Включена по умолчанию в режиме --watch.

Разница: меньше 10 сек

Используйте ContextReplacementPlugin

Этот плагин не столько про скорость сборки, сколько про размер бандла. Некоторые библиотеки при подключении тянут за собой тонну мусора (например, локализации для кучи языков). При помощи ContextReplacementPlugin можно это исправить:

plugins: [
    new webpack.ContextReplacementPlugin(/moment[/\]locale$/, /ru/)
]

При этом у меня один entry point (не минифицированный) похудел с 4.2 Мб до 3.8 Мб (10%). CommonsChunkPlugin был отключен.

Разница: меньше 10 сек

Используйте parallel-webpack

До этого момента мы различными способами ускоряли сборку отдельных entry-points. Но если у вас их много, или много разных webpack-конфигов, их можно собирать параллельно. Есть замечательная обертка, которой можно передать массив webpack-конфигов и они будут собраны параллельно.

У меня получился примерно такой конфиг:

const app1Config = require("./App1/webpack.config");
const app2Config = require("./App2/webpack.config");
const app3Config = require("./App3/webpack.config");
const app4Config = require("./App4/webpack.config");

module.exports = [
    app1Config,
    app2Config,
    app3Config,
    app4Config
];

В итоге сборка занимает столько времени, сколько занимает сборка самого жирного проекта.

Замеченные проблемы:

Разница: 178 сек ⟶ 119 сек (33%)

Используйте HappyPack

Это такой пакет, который запускает все трансформации кода параллельно.

Чтобы его настроить, нужно перетащить настройки для основного лоадера в настройки плагина HappyPack:

const HappyPack = require("happypack");

// ...

plugins: [
  new HappyPack({
      loaders: ["babel-loader"]
  })
]

А вместо них добавить happypack loader:

module: {
    rules: [
        {
            test: /.jsx?$/,
            use: "happypack/loader"
        }
    ]
}

У себя на Windows я его завел, но ощутимого прироста в скорости не получил, так что HappyPack я не использую. Судя по всему, я такой не один: issue, issue.

Разница на Windows: меньше 10 сек

Автор: ylian_demakova

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js