В многостраничном сайте после добавления страницы оформления заказа появляются два entry-файла: catalog.js и checkout.js. Если обе страницы используют jQuery и один модуль с форматированием цены, production-build может дать два похожих файла. Это не ошибка Webpack: у него появились два старта выполнения, и каждый дошёл до общих импортов. Ошибка возникает, когда от сборщика ждут, что общий код исчезнет сам по себе.
Главный вопрос этой заметки: как в Webpack 4 собрать две HTML-страницы так, чтобы общий модуль и зависимости не попадали в каждый стартовый bundle? Ниже — небольшая конфигурация для многостраничного сайта. Она намеренно не использует современный dependOn: это не API Webpack 4.
Сначала отделяю две страницы от одной страницы с двумя файлами
Entry — это не список библиотек и не место, куда складывают всё «общее». Это файл, с которого браузер начинает конкретный сценарий. Если сервер отдаёт отдельные документы /catalog/ и /checkout/, у них могут быть два 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 от неправильного порядка скриптов.
Короткий порядок работы
- Назвать HTML-документы, которые действительно существуют, и создать по одному entry на документ.
- Найти импорт, который повторяется в двух entry: сначала достаточно одного модуля из
src/shared. - Включить
splitChunksдля начальных chunks и временно поставитьminSize: 0, чтобы увидеть механизм на маленьком примере. - Вывести runtime в один файл, собрать production-вариант и передать актуальный список ассетов в HTML.
- Открыть каждую страницу отдельно: проверить набор script-тегов, консоль и факт, что код другой страницы не загружается.
Где этот рецепт не подходит
Не всякий общий импорт стоит выносить. Маленький модуль может добавить ещё один запрос и не дать выигрыша; крупная библиотека, которая нужна только модальному окну, не должна попадать в стартовый общий chunk только потому, что так легче настроить. Для кода, который не нужен при первом открытии страницы, в Webpack 4 есть отдельный путь — динамический import(). Ещё одно ограничение: если один HTML-документ намеренно запускает несколько entry, нужно особенно внимательно проверить число runtime-экземпляров и порядок загрузки.
Что считаю готовым
Я не считаю задачу закрытой по размеру одного файла. Готовый результат отвечает на три простых вопроса: какой entry запускает страницу, какие общие chunks она реально получает и не подключён ли соседний entry. Если эти ответы видны в конфигурации, в HTML и в Network, оптимизацию потом можно менять без лотереи.