DarkRiDDeR10 мин

Webpack 4. Как добавить две страницы и не возить общий код дважды

JavaScriptWebpack

В многостраничном сайте после добавления страницы оформления заказа появляются два entry-файла: catalog.js и checkout.js. Если обе страницы используют jQuery и один модуль с форматированием цены, production-build может дать два похожих файла. Это не ошибка Webpack: у него появились два старта выполнения, и каждый дошёл до общих импортов. Ошибка возникает, когда от сборщика ждут, что общий код исчезнет сам по себе.

Главный вопрос этой заметки: как в Webpack 4 собрать две HTML-страницы так, чтобы общий модуль и зависимости не попадали в каждый стартовый bundle? Ниже — небольшая конфигурация для многостраничного сайта. Она намеренно не использует современный dependOn: это не API Webpack 4.

Сначала отделяю две страницы от одной страницы с двумя файлами

Entry — это не список библиотек и не место, куда складывают всё «общее». Это файл, с которого браузер начинает конкретный сценарий. Если сервер отдаёт отдельные документы /catalog/ и /checkout/, у них могут быть два entry. Если же один документ подключает два entry только потому, что так проще в конфигурации, сначала стоит исправить это: пользователь будет загружать лишний сценарий ещё до оптимизации.

Две страницы Webpack 4: entry catalog и checkout используют runtime, vendors и common, затем каждая запускает только собственный код.
Entry остаётся точкой запуска страницы. Общие части создаёт оптимизация, а не третий фиктивный entry.
Часть сборкиЗачем она нужнаЧто подключает страница каталога
catalogЗапускает обработчики и код каталогаДа
checkoutЗапускает только сценарий заказаНет
vendorsВнешние пакеты из node_modulesДа, если попали в группу
commonНаши модули, достигнутые из двух entryДа, если группа их выделила
runtimeКод Webpack, который связывает модули и chunksДа

Минимальный пример с двумя сценариями

В примере оба entry импортируют один модуль из src/shared и jQuery. Содержимое функции не важно; важен путь импорта. Пока сборщик видит два стартовых графа, он имеет право положить достижимые модули в оба начальных файла.

// src/catalog.js
import $ from 'jquery';
import { formatPrice } from './shared/money';

$('[data-price]').each(function () {
  this.textContent = formatPrice(this.dataset.price);
});

// src/checkout.js
import $ from 'jquery';
import { formatPrice } from './shared/money';

$('[data-total]').text(formatPrice(window.checkoutTotal));

// src/shared/money.js
export function formatPrice(value) {
  return Number(value).toFixed(2) + ' ₽';
}

Конфигурация для Webpack 4

В Webpack 4 отдельный entry для vendor.js уже не является хорошей отправной точкой. Официальная документация советует оставлять entry только у начала выполнения, а разделение внешних и общих модулей поручить optimization.splitChunks. В конфигурации ниже minSize: 0 нужен для учебного примера: без него крошечный money.js может остаться в entry. В реальном проекте этот ноль обычно слишком агрессивен — он может создать лишний запрос ради пары строк.

// webpack.config.js
const path = require('path');

module.exports = {
  mode: 'production',
  entry: {
    catalog: './src/catalog.js',
    checkout: './src/checkout.js',
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash].js',
  },
  optimization: {
    runtimeChunk: 'single',
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
          priority: -10,
        },
        common: {
          name: 'common',
          minChunks: 2,
          minSize: 0,
          chunks: 'all',
          priority: -20,
          reuseExistingChunk: true,
        },
      },
    },
  },
};

У runtimeChunk: "single" здесь своя работа: Webpack 4 выносит runtime в один общий файл вместо того, чтобы встраивать его в каждый entry. Это не замена splitChunks. Первый вариант управляет runtime, второй — группами модулей. На этой границе легко запутаться, поэтому я проверяю оба результата в каталоге dist.

У правил выделения тоже есть граница. vendors смотрит только на путь внутри node_modules; пакет, который нужен одному checkout, не обязан переезжать в общий файл. common смотрит на повторное достижение модуля из двух chunks. Поэтому я не называю папку shared гарантией оптимизации: имя папки помогает человеку, а решение принимает правило сборки по графу и его условиям.

Перед тем как менять пороги, полезно сохранить список ассетов первого build. После изменения я сравниваю не общую сумму каталога, а роли файлов: появился ли vendors, попал ли money.js в common, остался ли код каталога в catalog. Так видно, какое именно правило сработало, и не приходится угадывать по одному числу в терминале.

Проверка не заканчивается на появлении файлов

После сборки должны появиться файлы с именами, зависящими от хеша: runtime.[contenthash].js, vendors.[contenthash].js, при нашем маленьком примере common.[contenthash].js, а также catalog.[contenthash].js и checkout.[contenthash].js. Хеши нельзя вшивать в шаблон HTML вручную. Шаблонизатор, плагин или серверный код должен получить актуальный список ассетов из сборки.

<!-- catalog.html: порядок — часть договора страницы -->
<script src="/assets/runtime.8ab1.js"></script>
<script src="/assets/vendors.34cd.js"></script>
<script src="/assets/common.91ef.js"></script>
<script src="/assets/catalog.a2b3.js"></script>

Имена в примере условные. Важен набор: страница каталога не должна подключать checkout, а страница заказа — catalog. Если общий chunk выделен, он нужен обеим. После этого открываю обе страницы с пустым кешем, смотрю Network и проверяю, что на каждой нет ошибки undefined is not a function от неправильного порядка скриптов.

Короткий порядок работы

  1. Назвать HTML-документы, которые действительно существуют, и создать по одному entry на документ.
  2. Найти импорт, который повторяется в двух entry: сначала достаточно одного модуля из src/shared.
  3. Включить splitChunks для начальных chunks и временно поставить minSize: 0, чтобы увидеть механизм на маленьком примере.
  4. Вывести runtime в один файл, собрать production-вариант и передать актуальный список ассетов в HTML.
  5. Открыть каждую страницу отдельно: проверить набор script-тегов, консоль и факт, что код другой страницы не загружается.

Где этот рецепт не подходит

Не всякий общий импорт стоит выносить. Маленький модуль может добавить ещё один запрос и не дать выигрыша; крупная библиотека, которая нужна только модальному окну, не должна попадать в стартовый общий chunk только потому, что так легче настроить. Для кода, который не нужен при первом открытии страницы, в Webpack 4 есть отдельный путь — динамический import(). Ещё одно ограничение: если один HTML-документ намеренно запускает несколько entry, нужно особенно внимательно проверить число runtime-экземпляров и порядок загрузки.

Что считаю готовым

Я не считаю задачу закрытой по размеру одного файла. Готовый результат отвечает на три простых вопроса: какой entry запускает страницу, какие общие chunks она реально получает и не подключён ли соседний entry. Если эти ответы видны в конфигурации, в HTML и в Network, оптимизацию потом можно менять без лотереи.

Проверяемые источники