import path from 'node:path'; import { fileURLToPath } from 'node:url'; const escapeHtml = (value) => String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const paragraph = (content) => '
' + content + '
'; const heading = (content) => '' + escapeHtml(source.trim()) + '';
const figure = (src, alt, caption) => [
'catalog.js и checkout.js. Если обе страницы используют jQuery и один модуль с форматированием цены, production-build может дать два похожих файла. Это не ошибка Webpack: у него появились два старта выполнения, и каждый дошёл до общих импортов. Ошибка возникает, когда от сборщика ждут, что общий код исчезнет сам по себе.'),
paragraph('Главный вопрос этой заметки: как в Webpack 4 собрать две HTML-страницы так, чтобы общий модуль и зависимости не попадали в каждый стартовый bundle? Ниже — небольшая конфигурация для многостраничного сайта. Она намеренно не использует современный dependOn: это не API Webpack 4.'),
heading('Сначала отделяю две страницы от одной страницы с двумя файлами'),
paragraph('Entry — это не список библиотек и не место, куда складывают всё «общее». Это файл, с которого браузер начинает конкретный сценарий. Если сервер отдаёт отдельные документы /catalog/ и /checkout/, у них могут быть два entry. Если же один документ подключает два entry только потому, что так проще в конфигурации, сначала стоит исправить это: пользователь будет загружать лишний сценарий ещё до оптимизации.'),
figure(
'/assets/editorial/2018/webpack-entry-shared-chunks-2018.svg',
'Две страницы Webpack 4: entry catalog и checkout используют runtime, vendors и common, затем каждая запускает только собственный код.',
'Entry остаётся точкой запуска страницы. Общие части создаёт оптимизация, а не третий фиктивный entry.',
),
dataTable(
['Часть сборки', 'Зачем она нужна', 'Что подключает страница каталога'],
[
['catalog', 'Запускает обработчики и код каталога', 'Да'],
['checkout', 'Запускает только сценарий заказа', 'Нет'],
['vendors', 'Внешние пакеты из node_modules', 'Да, если попали в группу'],
['common', 'Наши модули, достигнутые из двух entry', 'Да, если группа их выделила'],
['runtime', 'Код Webpack, который связывает модули и chunks', 'Да'],
],
),
heading('Минимальный пример с двумя сценариями'),
paragraph('В примере оба entry импортируют один модуль из src/shared и jQuery. Содержимое функции не важно; важен путь импорта. Пока сборщик видит два стартовых графа, он имеет право положить достижимые модули в оба начальных файла.'),
codeBlock(String.raw`
// 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) + ' ₽';
}
`),
heading('Конфигурация для Webpack 4'),
paragraph('В Webpack 4 отдельный entry для vendor.js уже не является хорошей отправной точкой. Официальная документация советует оставлять entry только у начала выполнения, а разделение внешних и общих модулей поручить optimization.splitChunks. В конфигурации ниже minSize: 0 нужен для учебного примера: без него крошечный money.js может остаться в entry. В реальном проекте этот ноль обычно слишком агрессивен — он может создать лишний запрос ради пары строк.'),
codeBlock(String.raw`
// 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,
},
},
},
},
};
`),
paragraph('У runtimeChunk: "single" здесь своя работа: Webpack 4 выносит runtime в один общий файл вместо того, чтобы встраивать его в каждый entry. Это не замена splitChunks. Первый вариант управляет runtime, второй — группами модулей. На этой границе легко запутаться, поэтому я проверяю оба результата в каталоге dist.'),
paragraph('У правил выделения тоже есть граница. vendors смотрит только на путь внутри node_modules; пакет, который нужен одному checkout, не обязан переезжать в общий файл. common смотрит на повторное достижение модуля из двух chunks. Поэтому я не называю папку shared гарантией оптимизации: имя папки помогает человеку, а решение принимает правило сборки по графу и его условиям.'),
paragraph('Перед тем как менять пороги, полезно сохранить список ассетов первого build. После изменения я сравниваю не общую сумму каталога, а роли файлов: появился ли vendors, попал ли money.js в common, остался ли код каталога в catalog. Так видно, какое именно правило сработало, и не приходится угадывать по одному числу в терминале.'),
heading('Проверка не заканчивается на появлении файлов'),
paragraph('После сборки должны появиться файлы с именами, зависящими от хеша: runtime.…js, vendors.…js, при нашем маленьком примере common.…js, а также catalog.…js и checkout.…js. Хеши нельзя вшивать в шаблон HTML вручную. Шаблонизатор, плагин или серверный код должен получить актуальный список ассетов из сборки.'),
codeBlock(String.raw`
`),
paragraph('Имена в примере условные. Важен набор: страница каталога не должна подключать checkout, а страница заказа — catalog. Если общий chunk выделен, он нужен обеим. После этого открываю обе страницы с пустым кешем, смотрю Network и проверяю, что на каждой нет ошибки undefined is not a function от неправильного порядка скриптов.'),
heading('Короткий порядок работы'),
orderedList([
'Назвать HTML-документы, которые действительно существуют, и создать по одному entry на документ.',
'Найти импорт, который повторяется в двух entry: сначала достаточно одного модуля из src/shared.',
'Включить splitChunks для начальных chunks и временно поставить minSize: 0, чтобы увидеть механизм на маленьком примере.',
'Вывести runtime в один файл, собрать production-вариант и передать актуальный список ассетов в HTML.',
'Открыть каждую страницу отдельно: проверить набор script-тегов, консоль и факт, что код другой страницы не загружается.',
]),
heading('Где этот рецепт не подходит'),
paragraph('Не всякий общий импорт стоит выносить. Маленький модуль может добавить ещё один запрос и не дать выигрыша; крупная библиотека, которая нужна только модальному окну, не должна попадать в стартовый общий chunk только потому, что так легче настроить. Для кода, который не нужен при первом открытии страницы, в Webpack 4 есть отдельный путь — динамический import(). Ещё одно ограничение: если один HTML-документ намеренно запускает несколько entry, нужно особенно внимательно проверить число runtime-экземпляров и порядок загрузки.'),
heading('Что считаю готовым'),
paragraph('Я не считаю задачу закрытой по размеру одного файла. Готовый результат отвечает на три простых вопроса: какой entry запускает страницу, какие общие chunks она реально получает и не подключён ли соседний entry. Если эти ответы видны в конфигурации, в HTML и в Network, оптимизацию потом можно менять без лотереи.'),
sourceList([sources.entry, sources.splitChunks, sources.optimization, sources.output]),
].join('\n'),
};
const mechanismArticle = {
slug: 'editorial-2018-06-mechanism-webpack-entry',
title: 'Webpack 4. Почему общий import оказывается в двух entry bundle',
categories: ['JavaScript', 'Webpack'],
cover: '/assets/editorial/2018/webpack-entry-graph-2018.svg',
excerpt: 'Разбираем один вопрос: что именно Webpack строит от entry, почему массив файлов — всё ещё один старт, и где появляется дублирование до настройки splitChunks.',
readingMinutes: 10,
contentHtml: [
paragraph('После добавления admin.js в конфигурацию и site.js, и admin.js могут содержать date-format.js. Руки тянутся перенести модуль в отдельную папку или добавить третий entry с названием vendor. Это не объясняет причину. Файл уже общий на диске; проблема возникает позже, когда Webpack строит стартовые графы.'),
paragraph('Главный вопрос здесь один: почему один и тот же import попадает в два entry bundle до настройки общего chunk? Разобрав этот механизм, можно отличить две настоящие страницы от одного entry с подготовительными файлами и не превратить библиотеку в фальшивую точку запуска.'),
heading('Entry не равен bundle, но задаёт его начало'),
paragraph('Webpack начинает с entry и рекурсивно проходит import и require. Результатом становится граф зависимостей. При одном entry у графа один старт. При объекте из site и admin — два старта. Если оба пути доходят до одного модуля, сам модуль остаётся одним исходным файлом, но без дополнительного правила может оказаться в обоих начальных chunks.'),
figure(
'/assets/editorial/2018/webpack-entry-graph-2018.svg',
'Граф Webpack 4: entry site и admin проходят к своим модулям и оба достигают shared/date-format и jquery; до splitChunks общие зависимости могут присутствовать в обоих стартовых chunks.',
'Две стрелки к одному исходнику не означают две копии файла в репозитории. Они объясняют, почему сборщик должен отдельно решить судьбу общего участка графа.',
),
heading('Минимальный граф, который показывает проблему'),
codeBlock(String.raw`
// src/site.js
import { formatDate } from './shared/date-format';
import { mountSearch } from './site/search';
mountSearch(formatDate);
// src/admin.js
import { formatDate } from './shared/date-format';
import { mountReport } from './admin/report';
mountReport(formatDate);
// src/shared/date-format.js
export function formatDate(date) {
return date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0');
}
`),
paragraph('В этом примере site/search и admin/report принадлежат разным страницам. shared/date-format достижим из обеих. Это полезная граница: переносить search в общий chunk ради симметрии не нужно; он не нужен админке. А date-format можно рассматривать как кандидата на общий chunk, если цена дополнительного файла оправдана.'),
dataTable(
['Запись в entry', 'Сколько стартов выполнения', 'Когда использовать'],
[
['"./src/site.js"', 'Один', 'Одна страница или библиотека с одним началом'],
['["./src/polyfills.js", "./src/site.js"]', 'Один', 'Нужно выполнить подготовительный файл перед главным кодом той же страницы'],
['{ site: "./src/site.js", admin: "./src/admin.js" }', 'Два', 'Сервер выдаёт два независимых HTML-документа'],
['{ vendor: ["jquery"], site: "./src/site.js" }', 'Два, один из них фиктивный', 'Для Webpack 4 это плохая модель; общий код выделяет splitChunks'],
],
),
heading('Почему массив не создаёт вторую страницу'),
paragraph('Массив в entry имеет другой смысл: Webpack 4 собирает указанные файлы как один multi-main entry и обходит их зависимости в одном chunk. Это подходит для полифиллов или кода подготовки, который всегда должен выполниться перед приложением. Массив не создаёт отдельную страницу и не заменяет объектную запись для многостраничного сайта.'),
codeBlock(String.raw`
// Один entry: polyfills и сайт попадают в один стартовый граф.
entry: ['./src/polyfills.js', './src/site.js']
// Два entry: сервер обязан отдать нужный набор файлов каждой странице.
entry: {
site: './src/site.js',
admin: './src/admin.js',
}
`),
heading('Где Webpack 4 разделяет общий участок'),
paragraph('До Webpack 4 встречалась привычка писать отдельный entry для библиотек и подключать CommonsChunkPlugin. В документации Webpack 4 этот путь уже помечен как нежелательный: entry должен соответствовать старту выполнения, а внешний и общий код выделяет optimization.splitChunks. Правило не обещает, что любой общий модуль обязательно станет отдельным файлом: на результат влияют условия группы, размер и тип chunk.'),
codeBlock(String.raw`
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
common: {
name: 'common',
minChunks: 2,
minSize: 0,
chunks: 'all',
},
},
},
}
`),
paragraph('Здесь я снова ставлю minSize: 0 только для наглядности. Условия говорят: найти модуль, использованный хотя бы в двух chunks, и вынести его в файл common. В production-конфигурации сначала стоит снять реальные размеры, а потом вернуть порог, который не дробит приложение на множество мелких файлов.'),
heading('Runtime — соседняя, но другая деталь'),
paragraph('После выделения общего модуля иногда кажется, что дублирование осталось: в каждом entry виден служебный код Webpack. У runtime отдельная роль — он знает, как загружать модули и chunks. В Webpack 4 по умолчанию runtime встроен в entry; runtimeChunk: "single" создаёт один общий runtime-файл. Это решение полезно для нескольких страниц, но не нужно путать его с переносом общего прикладного модуля.'),
paragraph('Если одна HTML-страница всё же включает несколько entry, у неё есть дополнительный риск: документация Webpack 4 предупреждает, что импортированные модули инициализируются для каждого runtime отдельно. Поэтому два script-тега не являются нейтральным приёмом. Сначала стоит проверить, нельзя ли оставить один старт и сделать вторую часть модулем внутри него.'),
heading('Порядок проверки графа'),
orderedList([
'Выписать HTML-документы и ответить, нужен ли каждому отдельный старт JavaScript.',
'Проверить, не является ли список файлов в массиве одним entry, где второй файл нужен только для подготовки.',
'Найти модуль, который достигается от двух независимых стартов, и не выносить в общий код модули, нужные одной странице.',
'Настроить splitChunks на маленьком примере, затем вернуть проектный порог размера.',
'Отдельно решить, нужен ли единый runtime, и проверить фактические script-теги каждой страницы.',
]),
heading('Граница объяснения'),
paragraph('Эта модель отвечает только на вопрос о начальных chunks. Она не говорит, что нужно вынести каждый импорт, и не заменяет анализ загрузки по действию пользователя. Модуль для редкого окна или отчёта может быть лучше загрузить через динамический import(). Ещё важно помнить о версии: конфигурация и названия опций в тексте относятся к Webpack 4; пример из свежей документации с новыми полями entry нельзя без проверки вставлять в старый проект.'),
heading('Итог'),
paragraph('Один import попадает в два entry bundle не потому, что файл лежит «не в той папке». Он достижим из двух стартов. Сначала нужно назвать эти старты, затем решить судьбу общего участка графа через splitChunks и только потом смотреть на размер файлов. Такой порядок оставляет в конфигурации причину, а не случайную заплатку.'),
sourceList([sources.entry, sources.splitChunks, sources.optimization]),
].join('\n'),
};
const fieldArticle = {
slug: 'editorial-2018-06-field-webpack-entry',
title: 'Webpack 4. Bundle вырос после нового entry: как найти причину по stats.json',
categories: ['JavaScript', 'Webpack'],
cover: '/assets/editorial/2018/webpack-entry-diagnosis-2018.svg',
excerpt: 'Пошаговая диагностика Webpack 4: отделяем новые ассеты от реального дублирования, читаем stats.json и проверяем, что браузер действительно скачивает.',
readingMinutes: 10,
contentHtml: [
paragraph('Симптом: после добавления admin-entry вырос site.…js, а Network обычной страницы показывает запрос к admin.…js. Пользователь получает код панели, которой не откроет; если править только сумму файлов в dist, легко оставить этот лишний запрос или сломать подключение нужного entry.'),
paragraph('Главный вопрос статьи: как по данным Webpack 4 доказать, почему bundle вырос после добавления entry, прежде чем менять конфигурацию? Для ответа нужны три вещи: список emitted-ассетов, связь модуля с chunks и фактические script-теги в HTML. Одной цифры из файловой системы недостаточно.'),
heading('Сначала фиксирую условия сравнения'),
paragraph('Сравнивать development-результат с production-результатом бессмысленно: режим, минификация, source map и плагины меняют картину сильнее, чем новый entry. Я делаю два production-build на одном коммите: до изменения и после него. Для каждого сохраняю JSON статистики отдельно, например stats-before.json и stats-after.json.'),
figure(
'/assets/editorial/2018/webpack-entry-diagnosis-2018.svg',
'Диагностика роста Webpack bundle: фиксируем одинаковый build, смотрим assets, связываем модули с chunks, проверяем HTML и только затем меняем splitChunks.',
'Статистика сборщика показывает состав компиляции. Network в браузере отвечает на отдельный вопрос: что реально скачала конкретная страница.',
),
heading('Создаю stats.json из той же команды сборки'),
paragraph('Webpack умеет отдать статистику компиляции в JSON. В ней есть ассеты, chunks, модули и их связи. Команду лучше запускать локальным webpack-cli из проекта: тогда версия сборщика совпадает с той, для которой написан webpack.config.js.'),
codeBlock(String.raw`
# В package.json уже есть webpack и webpack-cli.
./node_modules/.bin/webpack --mode production --profile --json > stats-after.json
# Для второго снимка возвращаем только конфигурацию entry
# и повторяем ту же команду:
./node_modules/.bin/webpack --mode production --profile --json > stats-before.json
`),
paragraph('Параметр --profile добавляет сведения о времени по модулям. Для вопроса о размере он не обязателен, но снимок пригодится, если рост размера сопровождается долгой сборкой. Главное — не смешивать JSON со случайными console.log из конфигурации: файл должен остаться валидным JSON.'),
heading('Читаю сначала ассеты, а не весь граф'),
paragraph('Первый разрез простой: сортирую emitted-ассеты по size. Это показывает, какие выходные файлы появились и какие из них стали больше. Но размер в stats — размер ассета в сборке, а не обязательно число байтов, переданных по сети после gzip или кеширования. Поэтому это место для гипотезы, а не для вывода о скорости страницы.'),
codeBlock(String.raw`
// tools/print-webpack-stats.js
const fs = require('fs');
const stats = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const assets = (stats.assets || [])
.map((asset) => ({
name: asset.name,
size: asset.size,
chunks: asset.chunks || [],
}))
.sort((left, right) => right.size - left.size);
for (const asset of assets) {
console.log(asset.size + '\\t' + asset.name + '\\tchunks=' + asset.chunks.join(','));
}
const repeated = (stats.modules || [])
.filter((module) => Array.isArray(module.chunks) && module.chunks.length > 1)
.map((module) => ({
name: module.name,
chunks: module.chunks,
size: module.size,
}))
.sort((left, right) => right.size - left.size);
console.log('\\nModules present in more than one chunk:');
for (const module of repeated.slice(0, 30)) {
console.log(module.size + '\\t' + module.name + '\\tchunks=' + module.chunks.join(','));
}
`),
paragraph('Запуск node tools/print-webpack-stats.js stats-after.json не должен автоматически объявлять все строки из второго списка проблемой. Общий модуль может быть правильно связан с несколькими chunks в описании компиляции, а часть chunks может быть асинхронной. Список нужен, чтобы назвать конкретный модуль, после чего его надо сопоставить с entrypoint и HTML.'),
dataTable(
['Наблюдение', 'Что это может означать', 'Следующее действие'],
[
['Появился новый admin.…js, старый site.…js почти не изменился', 'В dist лежит ещё одна страница, но старая не стала тяжелее', 'Проверить, что старый HTML не подключает admin'],
['Один пакет из node_modules виден у двух initial chunks', 'Внешняя зависимость достигнута из двух entry и не вынесена', 'Проверить splitChunks и условия cache group'],
['Оба entry подключены в одном HTML', 'Шаблон страницы получает чужой сценарий', 'Исправить генерацию script-тегов до настройки оптимизации'],
['Рост только в development', 'Сравнение сделано в разных режимах или с source map', 'Повторить замер одинаковой production-командой'],
['Файл большой в stats, но не запрашивается на странице', 'Ассет существует, но не входит в нужный entrypoint', 'Смотреть Network для конкретного URL, а не сумму каталога'],
],
),
heading('Проверяю entrypoint и сетевой след'),
paragraph('В stats есть сведения о chunks и entrypoints. Если новый admin должен жить только на /admin/, я открываю обычную страницу и смотрю список скриптов в HTML и вкладку Network. На ней должны быть только runtime, общие chunks, нужные именно этой странице, и её entry. Если там уже есть admin, проблема находится в шаблоне или плагине, а не в размере модуля.'),
paragraph('Затем повторяю проверку для /admin/. Только когда один и тот же большой модуль действительно участвует в двух начальных путях, есть смысл добавлять cache group. В Webpack 4 оптимизация общих chunks по умолчанию ориентирована на динамические imports; для начальных chunks нужно явно выбрать подходящую конфигурацию. Это объясняет, почему «поставил второй entry» и «получил отдельный общий файл» не равны друг другу.'),
heading('Небольшая правка после доказательства'),
paragraph('Когда stats показал повторяющийся пакет, а обе страницы действительно его загружают, я добавляю минимальную группу, а не копирую чужой длинный конфиг. Сначала отделяю пакеты из node_modules. Общий код приложения стоит выносить отдельным правилом только после того, как видно повтор из двух entry и он достаточно велик для отдельного запроса.'),
codeBlock(String.raw`
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
},
},
runtimeChunk: 'single',
}
`),
paragraph('После изменения я создаю третий stats-fixed.json и повторяю те же три проверки. Ожидаемый результат формулирую не как «стало мало килобайт», а как наблюдаемый контракт: обычная страница не загружает admin-entry; общий пакет появился в предназначенном для него chunk; обе страницы получают все необходимые файлы без ошибки выполнения.'),
heading('Последовательность расследования'),
orderedList([
'Сохранить два stats-снимка из одинаковой production-команды и назвать версии webpack и webpack-cli.',
'Сравнить ассеты: какой файл вырос, какой появился, связан ли он с новым entry.',
'Найти крупные модули, отмеченные в нескольких chunks, и не путать этот сигнал с доказательством сетевой загрузки.',
'Открыть каждый HTML-маршрут с пустым кешем и проверить реальные script-теги и Network.',
'Только после подтверждения дублирования настроить одну cache group, пересобрать и повторить тот же снимок.',
]),
heading('Ограничения метода'),
paragraph('Stats JSON отражает конкретную версию Webpack 4 и состав компиляции. Названия полей и формат данных могут меняться после обновления сборщика, поэтому диагностический скрипт не стоит превращать в вечный CI-контракт без фиксации версии. Метод также не измеряет время первой отрисовки и не учитывает серверное сжатие; для этого нужен отдельный сетевой замер. Но он надёжно отделяет «в каталоге стало больше файлов» от конкретного вопроса «какой модуль попал в какой chunk и почему».'),
heading('Итог'),
paragraph('Новый entry сам по себе увеличивает число ассетов — это ожидаемо. Дублирование начинается не от количества файлов, а от повторно достижимого модуля и от того, какие chunks подключает HTML. stats.json даёт материал для первой части проверки, браузер — для второй. После такой пары доказательств настройка splitChunks становится короткой и объяснимой.'),
sourceList([sources.stats, sources.cli, sources.entry, sources.optimization]),
].join('\n'),
};
export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
const isDirectExecution = Boolean(process.argv[1])
&& path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isDirectExecution) {
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
} else {
process.stderr.write('Usage: node web/scripts/upgrade-2018-06.mjs --print-revisions\n');
}
}