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

    Появляется все больше 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 сек

    • +17
    • 6,9k
    • 5
    Контур 142,19
    Делаем веб-сервисы для бизнеса
    Поделиться публикацией
    Комментарии 5
    • +1

      Полезная информация, однако напрягает обилие конструкций типа "умеет в ...".

      • +2

        Мы тоже в свое время ускоряли сборку большого проекта. У нас была сборка по 13-15 минут, и это даже без сорс мапов. Сейчас у нас сборка с сорс мапами холодная — 25 минут, горячая — 2 минуты.


        Единственное, что есть в статье и мы не применили это Dll. Но пока и не надо. Остальное все — реально очень полезно и работает!
        Дополню нашим опытом:


        • HappyPack нам значительно помог, поэтому не стоит сбрасывать его со счетов. Хотя он неудобный в настройке. Мне не нравятся плагины, генерирующие под себя лоадеры
        • Parallel-webpack наоборот, откатил в скорости. На самом деле webpack и сам умеет собирать массив конфигов. Для нашего случая webpack справляется лучше, чем parallel-webpack. Возможно дело в том, что parallel-webpack для каждого конфига запускает свой процесс, а сам webpack нет, и это позволяет шарить in-memory кеши (результаты обработки модулей) между сборками разных конфигов. Это всего лишь гипотеза, но anyway у нас у конфигов много общего кода и сам webpack оказался быстрее чем parallel-webpack
        • Как-то обошли стороной замечательный cache-loader, который также очень сильно нам помогает. Если протухают кеши HardSourcePlugin, нас спасает cache-loader. Его кеши протухают значительно реже. Сборка вместо 25 минут идет 12-13 минут
        • HardSourcePlugin нужно использовать осторожно, на свой страх и риск. Не со всеми лоадерами\плагинами он может быть совместим и хорошо работать. Мы его чутка форкнули под наши нужды. Из больного — был случай, когда HardSourcePlugin думал что можно отдать данные из кеша, а на самом деле надо было пересобрать. Было, честно говоря, фатально

        Также в дополнение, можно указывать не просто % ускорения, а также абсолютные величины. А то выходит что кеши babel-loader ускоряют всего на 8%. Выглядит не очень круто. На моей практике кеши бабеля самые стабильные, простые и одни из самых эффективных.

        • 0
          Спасибо за дополнение про cache-loader, действительно, не знал про него. Как он ведет себя при деплое на продакшен? Не вешает билд?

          HardSourcePlugin я в итоге оставил только для дев-сборки, т.к. страшно тащить в продакшен деплой. В деве то себя иногда ведет весьма странно.

          Про абсолютные величины ускорения: я писал изменение (до/после) в секундах, этого показалось не достаточно?
          • +1
            Спасибо за дополнение про cache-loader, действительно, не знал про него. Как он ведет себя при деплое на продакшен? Не вешает билд?

            У него очень простая логика: для каждого реквеста, которые выглядят следующим образомstyle-loader!css-loader!sass-loader!resource-path.scss, сохраняется ответ.
            Последующие обращения по этому реквесту будут считываться с файловой системы, вместо выполнения реальными лоадерами. Происходит это во время pitch фазы загрузки модуля.
            Алгоритм почти такой же как в babel-loader, но можно прикрутить к любому ресурсу. В ключ кеша входят — request, cacheKay из опций и modifyDate запрашиваемого файла. Мы для CI сделали форк, который использует hash контента запрашиваемого файла вместо modifyDate.
            У нас с cache-loader полет нормальный, единственный кто не подводит никогда (в особенности наш форк, проверяющий hash контента)


            HardSourcePlugin я в итоге оставил только для дев-сборки, т.к. страшно тащить в продакшен деплой. В деве то себя иногда ведет весьма странно.

            Мы в дев сборку наоборот не тащим, в watch режиме по памяти слишком часто начинает падать webpack с HardSourcePlugin. А про время сборки продакшн билдов без него я уже упоминал — 25 минут. На текущий момент, HardSourcePlugin нужен нам как воздух, что плохо, но нас устраивает.


            Про абсолютные величины ускорения: я писал изменение (до/после) в секундах, этого показалось не достаточно?

            Чисто мое субъективное мнение. Обратил внимание на 8% и подумал что-то не очень помогло, а ведь должно было, и действительно, в цифрах — минута

        • +1
          hard-source как и написано в статье «иногда не видит изменения». также он у меня не грузит картинки из-под webpack-dev-server

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое