СТАТЬЯ KOBEZZZA.LAB

Скорость Webpack

Очень часто webpack ругают за его скорость. Многие современные сборщики-аналоги webpack в первую очередь созданы с упором на скорость и по синтетическим тестам быстрее webpack в десятки раз.
Однако рано еще списывать webpack со счетов и сломя голову переходить на новый хайповый сборщик.

В этой статье разберем основные способы ускорения сборки, когда и где их использовать, а также разберемся, почему аналоги webpack быстрее.
Основные сценарии разработки
Некоторые оптимизации можно использовать для любой сборки,
а какие-то только для dev или prod сборки. Рассмотрим основные требования, которые мы ждем от сборки.
dev-сборка
Основной сценарий сборки, используется для локальной разработки.
При этом dev сборку можно поделить еще на холодную и watch/reload.

В первом случае мы запускаем сборку, а во втором при внесении изменений запускаем пересборку (--watch режим у webpack).

Как правило пересборка намного быстрее сборки (~1 секунда), потому что при пересборке нужно собрать лишь кусочек приложения, затронутый измененным кодом.

Также локальная dev сборка запускается на рабочей машине разработчика и обычно на самой свежей версии браузера.

Основные требования к локальной development сборке: - максимально быстрая сборка - собранный код близок или идентичен исходному - моментальная пересборка
prod-сборка
Сборка приложения для выкатки в production.
Меньший фокус на скорость, гораздо более важен размер собранного бандла, но скорость prod сборки полезна: повышается скорость выкатки релизов/тестирования/проверки в pre-prod окружении.

Основные требования к production сборке: - скорость не так важна, но быстрее - лучше - собранный код максимально оптимизирован - пересборка не нужна
Прежде чем оптимизировать
Прежде чем улучшать - научись измерять.
Для измерения скорости сборки можно воспользоваться самым простым и топорным способом - измерять время всей сборки. Для этого подойдет, например, webpack-progress-plugin. Можно написать самому посмотрев в api webpack, а можно взять один из множества готовых вариантов, например simple-progress-webpack-plugin. Бонусом, плагин очень полезен для dev сборки тем, что позволяет видеть процесс сборки при внесении изменений.
Суть измерения улучшений проста: вносим изменения в сборку и смотрим на общее время выполнения. Способ хорошо работает, но когда непонятно где сборка тормозит совершенно не подходит.

Чтобы подробнее понять сколько занимает каждый шаг сборки можно воспользоваться Speed Measure Plugin. Этот плагин также показывает общее время сборки, но при этом детализирует его, показывая сколько времени заняло выполнение того или иного шага сборки.
Если вы используете webpack вам открыта невероятная мощь, вы можете вмешаться в любой этап сборки.

Поменять алгоритм формирования модулей в чанки? Почему нет.
Вмешаться в парсинг js кода изменив поведение import? Тоже можно
Также вебпак один из пионеров во внедрении WebAssembly, он поддерживает самый современный стандарт webassembly и без труда может включить .wasm в ваш бандл.
Ни одна из альтернатив webpack не дает столько возможностей.
Тонкости измерения
Однако посмотрев на лог, выводимый speed measure webpack plugin (SMP), можно заметить, что сумма таймингов не дает общее время сборки.
Так происходит, потому что многие этапы сборки webpack выполняются параллельно и могут пересекаться друг с другом.

Чтобы еще более подробно взглянуть на таймлайн сборки можно воспользоваться profiling-plugin. Он генерирует chrome profile в виде json файла, который затем можно открыть devtools и увидеть тайминги вместе с потреблением памяти.
Обычно, такая информация будет излишней и очень шумной.

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

Thread-loader
Thread-loader выносит выполнение лоадера в отдельный процесс.
Таким образом цепочка лоадеров может выполняться параллельно и не загружать основной процесс выполнения.

Величина ускорения сильно зависит от конкретного конфига webpack. Эффект будет заметным, только если в конфиге есть тяжелые cpu-bound лоадеры. В остальных случаях overhead на создание воркера (~600ms) будет давать паритет в скорости или вообще замедлит сборку.

Для использования нужно добавить thread-loader первым в цепочку лоадеров для файла:

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        include: path.resolve('src'),
        use: [
          'thread-loader',
          // любой CPU-bound loader (например babel-loader)
        ],
      },
    ],
  },
};
TS-Loader
Если вы используете typescript у себя в проекте то вы с большой вероятностью собираете его с помощью ts-loader. Для его ускорения, можно отключить проверку типов с помощью опции transpileOnly.

module.exports = {
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: [
          {
            loader: 'ts-loader',
            options: {
              transpileOnly: true
            }
          }
        ]
      }
    ]
  }
}
SWC-loader
Также вместо ts-loaderа можно использовать альтернативный лоадер для ts - swc-loader. SWC это компилятор typescript написанный на rust. Он быстрее ts-loader примерно на 30%, но swc не умеет проверять типы и не поддерживает некоторые опции конфига.

module: {
  rules: [
    {
      test: /\.js$/,
      exclude: /(node_modules)/,
      use: {
        // для конфигурации можно использовать конфиг `.swcrc`
        loader: "swc-loader"
      }
    }
  ]
}
Выключение минификации ассетов
Обязательно удостоверьтесь, что в dev режиме у вас выключены различные инструменты для сжатия ассетов и любой минификации кода.

Также можно выключить оптимизации самого webpack:

module.exports = {
  // ...
  optimization: {
    removeAvailableModules: false,
    removeEmptyChunks: false,
    splitChunks: false,
  }
};
Exclude кода
Обязательно указывайте лоадерам, какие файлы они должны обрабатывать через опцию include:

module.exports = {
  //...
  module: {
    rules: [
      {
        test: /\.js$/,
        include: path.resolve(__dirname, 'src'),
        loader: 'babel-loader',
      },
    ],
  },
};
Также возможно убрать обработку файлов лоадером через противоположную опцию exclude или вообще выключить парсинг файла и подключать его без какой либо обработки с помощью опции noParse:

module.exports = {
  //...
  module: {
    noParse: /jquery/,
  },
};
При использовании noParse в таких файлах не должно быть никаких import, require, define. Хороший пример исключения из обработки, это предсобранная библиотека, например jquery.dist.js из node_modules.
Не связанные напрямую со сборкой плагины
Не используйте при сборке плагины по типу eslint-webpack-plugin или prettier-webpack-plugin. Такие плагины значительно замедляют скорость сборки и не так уж полезны.
Eslint и prettier свободно можно унести за пределы сборки, запуская его в CI отдельной джобой, а локально на precommit/prepush или вручную. Бонусом при запуске на precommit улучшится скорость проверки - проверятся будут только измененные файлы.

Точно так же можно поступить и с проверкой типов в CI - вынести в отдельную джобу.

Отказ от сложных препроцессоров стилей
Во многих проектах для описания стилей используется сложный препроцессор (sass/less/stylus). Компиляторы для сборки этих старых языков весьма медленные сами по себе и способов их ускорения нет.
Хорошей альтернативой является переезд на чистый CSS, а для реализации недостающих функции, к которым вы привыкли c препроцессором, можно использовать PostCSS.
С момента изобретения препроцессоров CSS прошел большой путь и теперь умеет намного больше, возможно современные возможности CSS вкупе с мощью PostCSS позволят вам отказаться от медленного препроцессора и существенно ускорить сборку.
Оптимизация dev сборки
Кэши
У webpack есть собственный встроенный модуль для кэширования.
Включается с помощью опции cache. В development моде по умолчанию включен в true и хранит кэш в памяти, что ускоряет время пересборки. Однако, мало кто знает, что у этой опции есть множество настроек и одна из них это type.

cache: {
    type: 'filesystem'
  }
type: filesystem позволяет сохранять кэш webpack при сборке на файловую систему (по умолчанию node_modules/.cache), что позволяет переиспользовать кэш после завершения сборки.

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

При этом webpack также кэширует данные в память и оперирует ими при пересборке.

Все что добавляет type: filesystem это периодический дамп этого кэша на файловую систему, чтобы в дальнейшем использовать его при следующем запуске.

Помимо самого webpack, у многих популярных лоадеров есть свои кэши. Например, у babel-loader есть собственный кэш который включается опцией cacheDirectory в конфиге самого лоадера.

Всегда проверяйте, есть ли опция кэширования у используемого вами лоадера/плагина.
Кэши не рекомендуется использовать для prod сборки и в CI, так как из-за риска неправильной инвалидации кэша можно получить нерабочий билд.

Про инвалидацию
Большая проблема кэширования - инвалидация кэша. В случае, если вы смержите к себе новый мастер, внесете изменения в кодовую базу или поднимите версию зависимости webpack инвалидирует кэш и будет собирать все с нуля.

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

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

Для улучшения инвалидации кэша можно воспользоваться более тонкой настройкой, например версионировать кэш, изменяя версию или имя (можно например давать имя кэшу по имени гитовой ветки или версию по хэшу коммита).

При использовании имени кэш сохраняется в .cache/{имя_кэша}/кэш и позволяет хранить сразу несколько версий кэша для разных веток.

Target сборки
Всегда используйте для dev сборки последнюю версию JS со всеми нововведениями (ESM модули, нативная поддержка динамических импортов, генераторы). Это позволит не подключать при сборке множество полифиллов и сложных конструкции транспайлинга, что существенно ускорит сборку. Если вы используйте typescript указывайте самую последнюю доступную версию js (поле target). На текущий момент это es2022.

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

Если для транспиляции вы используете babel то можно поступить еще проще - выключить его совсем или также указать самый последний target (preset-env сделает это сам, если вы используете .browserslist конфиг).
Тайпчекинг при сборке
Если вам нужен тайпчекинг при сборке можно воспользоваться плагином fork-ts-type-checker.
Этот плагин работает в отдельном процессе и будет проверять типы и выводить ошибки параллельно сборке, не блокируя основной процесс.

module.exports = {
  module: {
    rules: [
      {
        test: /\.ts$/,
        loader: 'ts-loader',
        // Начиная с версии ts-loader 9.3.0 указывать
        // transpileOnly при использовании ForkTsCheckerWebpackPlugin не нужно
      }
    ]
  },
  plugins: [new ForkTsCheckerWebpackPlugin()],
}
Dev-server
Обязательно используйте dev-server вместо watch режима webpack.
DevServer собирает ваш код и хранит собранный бандл в оперативной памяти, тогда как watch режим после сборки пишет файлы на файловую систему, это особенно важно, при горячей пересборке, так как перезаписывать измененные ассеты на файловую систему намного дороже.

Также, внутри dev сервера можно написать прокси для ваших API и мокать их, потому что по своей сути это обычный express сервер.
Используйте для dev сервера http/2 или http/3
Протоколы http/2 и 3 гораздо быстрее первой версий и могут дать небольшой прирост в скорости загрузки ассетов вашего приложения в браузере с локального сервера. Http/2+ может обрабатывать больше параллельных соединений с сервером, а также в среднем быстрее на 30%.

Платой за это будет дополнительная сложность настройки рабочего окружения: необходимость установки сертификатов в систему (http/2+ обязательно требует TLS).
Для включения http/2 нужно сгенерировать ключи шифрования, например вот так.
После чего положить эти ключи в ваш проект и указать их в конфиге:

module.exports = {
  devServer: {
    http2: true,
    https: {
      key: fs.readFileSync('/path/to/server.key'),
      cert: fs.readFileSync('/path/to/server.crt')
    },
  },
};

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

А вот в vite все иначе, начиная с того, что это на самом деле вообще не сборщик. Vite использует под капотом rollup и esbuild, конфигурируя их довольно хитрым образом. Vite с помощью esbuild при первом запуске предсобирает ваши зависимости из node_modules с помощью esbuild и кладет их в кэш, а на последующих запусках просто забирает их из кэша.
Затем с помощью rollup vite собирает уже ваш основной код из /src, но и тут не все так просто, он собирает его по требованию. То есть при старте vite вообще ничего не собирает, а ждет запросов от клиента за ассетом (vite использует ESM и нативные script type module, поэтому код состоит из множества маленьких ESM модулей) и собирает его только после получения запроса. Таким образом, vite собирает только реально используемый в настоящий момент при разработке код приложения.

При этом, при открытии новых страниц, рендер может немного подтормаживать, потому что vite собирает нужный код.
Экспериментальное: Lazy Compilation
Webpack тоже умеет работать в таком режиме, но эта функциональность еще помечена экспериментальной и ее включение возможно через поле experiments в webpack конфиге.

module.exports = {
  experiments: {
    lazyCompilation: true,
  },
};

Работает точно так же, как и в vite, но предоставляет возможность кастомизации того, какие модули и роуты можно собирать лениво, а какие нет.


module.exports = {
  experiments: {
    lazyCompilation: {
      // Выключает ленивую сборку для динамических импортов
      imports: false,

      // Выключает ленивую сборку для entry файлов
      entries: false,

      // Исключает из ленивой сборки moduleB
      test: (module) => !/moduleB/.test(module.nameForCondition()),
    },
  },
};
dll plugin
DLL Plugin позволяет предсобрать части вашего приложения, указав их в качестве external library у основной сборки.

То есть делает примерно то же, что и vite, но требует ручной настройки.
Настройка выглядит таким образом:
1. Создается отдельный vendor.webpack.config.js в котором указывается entry и подключается сам плагин:

module.exports = {
    entry: {
        vendor: ["react", /* любые ваши зависимости, например lodash, d3, jquery */]
    },
    mode: "development",
    output: {
        path: path.join(__dirname, "prebundle"),
        filename: "vendor.bundle.js",
        library: "vendor_lib"
    },
    plugins: [
        new webpack.DllPlugin({
            context: __dirname,
            name: "vendor_lib",
            path: path.join(__dirname, "prebundle", "vendor-manifest.json"),
        })
    ]
};
В результате сборки по этому конфигу вы получаете собранный чанк с вашими зависимостями vendor.bundle.js и файл vendor-manifest.json.

2. В основном конфиге Webpack теперь нужно указать ссылку на этот собранный vendor чанк:

module.exports = {
    plugins: [
        new webpack.DllReferencePlugin({
            context: __dirname,
            manifest: path.join(__dirname, "prebundle", "vendor-manifest.json")
        })
    ]
}
Такой подход позволяет экономить существенное количество времени, даже если вы сильно меняете исходный код вашего приложения.

В итоге для самой первой холодной сборки вам нужно запустить 2 сборки друг за другом, а затем так же запускать обычный build. Удобнее завести для этого отдельный команды:

{
  "scripts": {
    "build": "webpack",
    "prebuild": "webpack --config vendor.webpack.config.js",
    "build:all": "npm run prebuild && npm run build"
  }
}
DLL plugin нельзя использовать для prod сборки!
При использовании DLL plugin webpack собирает все указанные модули в отдельный чанк. Это хорошо работает для dev сборки, но для prod сборки это повлечет за собой невозможность tree shakingа модулей из этого чанка.

Например, если вы кладете в vendor чанк библиотеку lodash, то туда попадет целиком весь lodash вместе со всеми методами. А вот если вы подключаете lodash обычным способом, то tree shaking сможет убрать из сборки все неиспользуемые методы, сильно уменьшив конечный размер.
Sourcemaps
Sourcemaps позволяет при дебаге вашего приложения в браузере видеть вместо собранного кода исходный код как в IDE. Это бывает полезно, особенно для дебаге prod сборки, однако в dev режиме код и так довольно близок к исходному.

Использовать их или нет ваш выбор, есть множество различных режимов сборки sourcemaps, однако почти все они отрицательно влияют на скорость сборки, поэтому в dev режиме рекомендуется их отключать.

Разумно в случае необходимости вручную включать sourcemaps указанием флага webpack --devtool <value>.
Оптимизация prod сборки
Оптимизация ассетов вне сборки
Выносите оптимизацию ассетов из сборки.
Например, вместо использования svgo-loader для сжатия svg при каждой сборке, запустите svgo через cli и держите svg уже сжатыми в вашем репозитории.
Для того чтобы удостовериться, что все svg в репозитории сжаты, можно использовать линтер, который определяет, что svg сжат и запускать этот линтер на precommit или в CI.
Точно также можно поступить и с другими операциями сжатия ассетов.

Target
Выносите оптимизацию ассетов из сборки.
В случае с prod окружение обязательно используйте для сборки конфиг browserslist, который позволит точно определить необходимые полифиллы и target транспайлинга в автоматическом режиме. Для того чтобы точно понять, какой конфиг вам нужен, проанализируйте версии браузеров, которые используют пользователи вашего веб приложения. Многие лоадеры и плагины учитывают этот конфиг при сборке.

Также browserlist можно настроить отдельно для dev и prod сборки:

[production]
your supported browsers go here...

[development]
last 2 Chrome versions
last 2 Firefox versions
last 1 Safari version


[production]

your supported browsers go here...
[development]
last 2 Chrome versions
last 2 Firefox versions
last 1 Safari version
Итоги
В отличие от более современных сборщиков, Webpack в стандартной конфигурации достаточно медленный. Так происходит, потому что другие сборщики из коробки включают различные оптимизации, а в Webpack их нужно настраивать вручную. При этом нельзя выделить из всех способов “серебряную пулю”: одна оптимизация никак не повлияет на скорость, а другая вообще замедлит сборку. Понять эффект оптимизации заранее нельзя, все сильно зависит от конкретного конфига сборки, именно поэтому данные оптимизации в Webpack выключены по умолчанию.

Только зная все возможности Webpack можно правильно настроить оптимизацию скорости сборки.

Если интересуешься темой
инфраструктуры, то не пропусти
приглашаем на курс
Продвинутое использование Webpack
Cтарт 5 декабря