Files
progcode/web/scripts/upgrade-2018-06.mjs
T
huncode c27ec20965
Build and deploy / deploy (push) Successful in 12s
revise June 2018 webpack articles
2026-07-31 09:24:35 +03:00

402 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const escapeHtml = (value) => String(value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
const paragraph = (content) => '<p>' + content + '</p>';
const heading = (content) => '<h2>' + content + '</h2>';
const codeBlock = (source) => '<pre><code>' + escapeHtml(source.trim()) + '</code></pre>';
const figure = (src, alt, caption) => [
'<figure>',
'<img src="' + src + '" alt="' + alt + '" />',
'<figcaption>' + caption + '</figcaption>',
'</figure>',
].join('');
function dataTable(headers, rows) {
const head = headers.map((header) => '<th scope="col">' + header + '</th>').join('');
const body = rows.map((row) => (
'<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>'
)).join('');
return '<div class="table-scroll"><table><thead><tr>' + head
+ '</tr></thead><tbody>' + body + '</tbody></table></div>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function sourceList(items) {
return heading('Проверяемые источники') + '<ul>' + items.map(({ label, url }) => (
'<li><a href="' + url + '" target="_blank" rel="noopener noreferrer">' + label + '</a></li>'
)).join('') + '</ul>';
}
const sources = {
entry: {
label: 'Webpack 4 — Entry Points',
url: 'https://v4.webpack.js.org/concepts/entry-points/',
},
splitChunks: {
label: 'Webpack 4 — Code Splitting',
url: 'https://v4.webpack.js.org/guides/code-splitting/',
},
optimization: {
label: 'Webpack 4 — optimization.splitChunks и runtimeChunk',
url: 'https://v4.webpack.js.org/configuration/optimization/',
},
output: {
label: 'Webpack 4 — Output',
url: 'https://v4.webpack.js.org/concepts/output/',
},
stats: {
label: 'Webpack 4 — Stats Data',
url: 'https://v4.webpack.js.org/api/stats/',
},
cli: {
label: 'Webpack 4 — CLI',
url: 'https://v4.webpack.js.org/api/cli/',
},
};
const practiceArticle = {
slug: 'editorial-2018-06-practice-webpack-entry',
title: 'Webpack 4. Как добавить две страницы и не возить общий код дважды',
categories: ['JavaScript', 'Webpack'],
cover: '/assets/editorial/2018/webpack-entry-shared-chunks-2018.svg',
excerpt: 'Собираем две независимые страницы в Webpack 4: отделяем точки запуска от общего кода, подключаем нужные файлы в HTML и проверяем результат без догадок.',
readingMinutes: 10,
contentHtml: [
paragraph('В многостраничном сайте после добавления страницы оформления заказа появляются два entry-файла: <code>catalog.js</code> и <code>checkout.js</code>. Если обе страницы используют jQuery и один модуль с форматированием цены, production-build может дать два похожих файла. Это не ошибка Webpack: у него появились два старта выполнения, и каждый дошёл до общих импортов. Ошибка возникает, когда от сборщика ждут, что общий код исчезнет сам по себе.'),
paragraph('Главный вопрос этой заметки: <strong>как в Webpack 4 собрать две HTML-страницы так, чтобы общий модуль и зависимости не попадали в каждый стартовый bundle?</strong> Ниже — небольшая конфигурация для многостраничного сайта. Она намеренно не использует современный <code>dependOn</code>: это не API Webpack 4.'),
heading('Сначала отделяю две страницы от одной страницы с двумя файлами'),
paragraph('Entry — это не список библиотек и не место, куда складывают всё «общее». Это файл, с которого браузер начинает конкретный сценарий. Если сервер отдаёт отдельные документы <code>/catalog/</code> и <code>/checkout/</code>, у них могут быть два entry. Если же один документ подключает два entry только потому, что так проще в конфигурации, сначала стоит исправить это: пользователь будет загружать лишний сценарий ещё до оптимизации.'),
figure(
'/assets/editorial/2018/webpack-entry-shared-chunks-2018.svg',
'Две страницы Webpack 4: entry catalog и checkout используют runtime, vendors и common, затем каждая запускает только собственный код.',
'Entry остаётся точкой запуска страницы. Общие части создаёт оптимизация, а не третий фиктивный entry.',
),
dataTable(
['Часть сборки', 'Зачем она нужна', 'Что подключает страница каталога'],
[
['<code>catalog</code>', 'Запускает обработчики и код каталога', 'Да'],
['<code>checkout</code>', 'Запускает только сценарий заказа', 'Нет'],
['<code>vendors</code>', 'Внешние пакеты из <code>node_modules</code>', 'Да, если попали в группу'],
['<code>common</code>', 'Наши модули, достигнутые из двух entry', 'Да, если группа их выделила'],
['<code>runtime</code>', 'Код Webpack, который связывает модули и chunks', 'Да'],
],
),
heading('Минимальный пример с двумя сценариями'),
paragraph('В примере оба entry импортируют один модуль из <code>src/shared</code> и 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 для <code>vendor.js</code> уже не является хорошей отправной точкой. Официальная документация советует оставлять entry только у начала выполнения, а разделение внешних и общих модулей поручить <code>optimization.splitChunks</code>. В конфигурации ниже <code>minSize: 0</code> нужен для учебного примера: без него крошечный <code>money.js</code> может остаться в 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('У <code>runtimeChunk: "single"</code> здесь своя работа: Webpack 4 выносит runtime в один общий файл вместо того, чтобы встраивать его в каждый entry. Это не замена <code>splitChunks</code>. Первый вариант управляет runtime, второй — группами модулей. На этой границе легко запутаться, поэтому я проверяю оба результата в каталоге <code>dist</code>.'),
paragraph('У правил выделения тоже есть граница. <code>vendors</code> смотрит только на путь внутри <code>node_modules</code>; пакет, который нужен одному <code>checkout</code>, не обязан переезжать в общий файл. <code>common</code> смотрит на повторное достижение модуля из двух chunks. Поэтому я не называю папку <code>shared</code> гарантией оптимизации: имя папки помогает человеку, а решение принимает правило сборки по графу и его условиям.'),
paragraph('Перед тем как менять пороги, полезно сохранить список ассетов первого build. После изменения я сравниваю не общую сумму каталога, а роли файлов: появился ли <code>vendors</code>, попал ли <code>money.js</code> в <code>common</code>, остался ли код каталога в <code>catalog</code>. Так видно, какое именно правило сработало, и не приходится угадывать по одному числу в терминале.'),
heading('Проверка не заканчивается на появлении файлов'),
paragraph('После сборки должны появиться файлы с именами, зависящими от хеша: <code>runtime.…js</code>, <code>vendors.…js</code>, при нашем маленьком примере <code>common.…js</code>, а также <code>catalog.…js</code> и <code>checkout.…js</code>. Хеши нельзя вшивать в шаблон HTML вручную. Шаблонизатор, плагин или серверный код должен получить актуальный список ассетов из сборки.'),
codeBlock(String.raw`
<!-- 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>
`),
paragraph('Имена в примере условные. Важен набор: страница каталога не должна подключать <code>checkout</code>, а страница заказа — <code>catalog</code>. Если общий chunk выделен, он нужен обеим. После этого открываю обе страницы с пустым кешем, смотрю Network и проверяю, что на каждой нет ошибки <code>undefined is not a function</code> от неправильного порядка скриптов.'),
heading('Короткий порядок работы'),
orderedList([
'Назвать HTML-документы, которые действительно существуют, и создать по одному entry на документ.',
'Найти импорт, который повторяется в двух entry: сначала достаточно одного модуля из <code>src/shared</code>.',
'Включить <code>splitChunks</code> для начальных chunks и временно поставить <code>minSize: 0</code>, чтобы увидеть механизм на маленьком примере.',
'Вывести runtime в один файл, собрать production-вариант и передать актуальный список ассетов в HTML.',
'Открыть каждую страницу отдельно: проверить набор script-тегов, консоль и факт, что код другой страницы не загружается.',
]),
heading('Где этот рецепт не подходит'),
paragraph('Не всякий общий импорт стоит выносить. Маленький модуль может добавить ещё один запрос и не дать выигрыша; крупная библиотека, которая нужна только модальному окну, не должна попадать в стартовый общий chunk только потому, что так легче настроить. Для кода, который не нужен при первом открытии страницы, в Webpack 4 есть отдельный путь — динамический <code>import()</code>. Ещё одно ограничение: если один 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('После добавления <code>admin.js</code> в конфигурацию и <code>site.js</code>, и <code>admin.js</code> могут содержать <code>date-format.js</code>. Руки тянутся перенести модуль в отдельную папку или добавить третий entry с названием <code>vendor</code>. Это не объясняет причину. Файл уже общий на диске; проблема возникает позже, когда Webpack строит стартовые графы.'),
paragraph('Главный вопрос здесь один: <strong>почему один и тот же import попадает в два entry bundle до настройки общего chunk?</strong> Разобрав этот механизм, можно отличить две настоящие страницы от одного entry с подготовительными файлами и не превратить библиотеку в фальшивую точку запуска.'),
heading('Entry не равен bundle, но задаёт его начало'),
paragraph('Webpack начинает с entry и рекурсивно проходит <code>import</code> и <code>require</code>. Результатом становится граф зависимостей. При одном entry у графа один старт. При объекте из <code>site</code> и <code>admin</code> — два старта. Если оба пути доходят до одного модуля, сам модуль остаётся одним исходным файлом, но без дополнительного правила может оказаться в обоих начальных 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('В этом примере <code>site/search</code> и <code>admin/report</code> принадлежат разным страницам. <code>shared/date-format</code> достижим из обеих. Это полезная граница: переносить <code>search</code> в общий chunk ради симметрии не нужно; он не нужен админке. А <code>date-format</code> можно рассматривать как кандидата на общий chunk, если цена дополнительного файла оправдана.'),
dataTable(
['Запись в <code>entry</code>', 'Сколько стартов выполнения', 'Когда использовать'],
[
['<code>"./src/site.js"</code>', 'Один', 'Одна страница или библиотека с одним началом'],
['<code>["./src/polyfills.js", "./src/site.js"]</code>', 'Один', 'Нужно выполнить подготовительный файл перед главным кодом той же страницы'],
['<code>{ site: "./src/site.js", admin: "./src/admin.js" }</code>', 'Два', 'Сервер выдаёт два независимых HTML-документа'],
['<code>{ vendor: ["jquery"], site: "./src/site.js" }</code>', 'Два, один из них фиктивный', 'Для Webpack 4 это плохая модель; общий код выделяет <code>splitChunks</code>'],
],
),
heading('Почему массив не создаёт вторую страницу'),
paragraph('Массив в <code>entry</code> имеет другой смысл: 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 должен соответствовать старту выполнения, а внешний и общий код выделяет <code>optimization.splitChunks</code>. Правило не обещает, что любой общий модуль обязательно станет отдельным файлом: на результат влияют условия группы, размер и тип chunk.'),
codeBlock(String.raw`
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
common: {
name: 'common',
minChunks: 2,
minSize: 0,
chunks: 'all',
},
},
},
}
`),
paragraph('Здесь я снова ставлю <code>minSize: 0</code> только для наглядности. Условия говорят: найти модуль, использованный хотя бы в двух chunks, и вынести его в файл <code>common</code>. В production-конфигурации сначала стоит снять реальные размеры, а потом вернуть порог, который не дробит приложение на множество мелких файлов.'),
heading('Runtime — соседняя, но другая деталь'),
paragraph('После выделения общего модуля иногда кажется, что дублирование осталось: в каждом entry виден служебный код Webpack. У runtime отдельная роль — он знает, как загружать модули и chunks. В Webpack 4 по умолчанию runtime встроен в entry; <code>runtimeChunk: "single"</code> создаёт один общий runtime-файл. Это решение полезно для нескольких страниц, но не нужно путать его с переносом общего прикладного модуля.'),
paragraph('Если одна HTML-страница всё же включает несколько entry, у неё есть дополнительный риск: документация Webpack 4 предупреждает, что импортированные модули инициализируются для каждого runtime отдельно. Поэтому два script-тега не являются нейтральным приёмом. Сначала стоит проверить, нельзя ли оставить один старт и сделать вторую часть модулем внутри него.'),
heading('Порядок проверки графа'),
orderedList([
'Выписать HTML-документы и ответить, нужен ли каждому отдельный старт JavaScript.',
'Проверить, не является ли список файлов в массиве одним entry, где второй файл нужен только для подготовки.',
'Найти модуль, который достигается от двух независимых стартов, и не выносить в общий код модули, нужные одной странице.',
'Настроить <code>splitChunks</code> на маленьком примере, затем вернуть проектный порог размера.',
'Отдельно решить, нужен ли единый runtime, и проверить фактические script-теги каждой страницы.',
]),
heading('Граница объяснения'),
paragraph('Эта модель отвечает только на вопрос о начальных chunks. Она не говорит, что нужно вынести каждый импорт, и не заменяет анализ загрузки по действию пользователя. Модуль для редкого окна или отчёта может быть лучше загрузить через динамический <code>import()</code>. Ещё важно помнить о версии: конфигурация и названия опций в тексте относятся к Webpack 4; пример из свежей документации с новыми полями entry нельзя без проверки вставлять в старый проект.'),
heading('Итог'),
paragraph('Один import попадает в два entry bundle не потому, что файл лежит «не в той папке». Он достижим из двух стартов. Сначала нужно назвать эти старты, затем решить судьбу общего участка графа через <code>splitChunks</code> и только потом смотреть на размер файлов. Такой порядок оставляет в конфигурации причину, а не случайную заплатку.'),
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('Симптом: после добавления <code>admin</code>-entry вырос <code>site.…js</code>, а Network обычной страницы показывает запрос к <code>admin.…js</code>. Пользователь получает код панели, которой не откроет; если править только сумму файлов в <code>dist</code>, легко оставить этот лишний запрос или сломать подключение нужного entry.'),
paragraph('Главный вопрос статьи: <strong>как по данным Webpack 4 доказать, почему bundle вырос после добавления entry, прежде чем менять конфигурацию?</strong> Для ответа нужны три вещи: список emitted-ассетов, связь модуля с chunks и фактические script-теги в HTML. Одной цифры из файловой системы недостаточно.'),
heading('Сначала фиксирую условия сравнения'),
paragraph('Сравнивать development-результат с production-результатом бессмысленно: режим, минификация, source map и плагины меняют картину сильнее, чем новый entry. Я делаю два production-build на одном коммите: до изменения и после него. Для каждого сохраняю JSON статистики отдельно, например <code>stats-before.json</code> и <code>stats-after.json</code>.'),
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 из проекта: тогда версия сборщика совпадает с той, для которой написан <code>webpack.config.js</code>.'),
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('Параметр <code>--profile</code> добавляет сведения о времени по модулям. Для вопроса о размере он не обязателен, но снимок пригодится, если рост размера сопровождается долгой сборкой. Главное — не смешивать JSON со случайными <code>console.log</code> из конфигурации: файл должен остаться валидным JSON.'),
heading('Читаю сначала ассеты, а не весь граф'),
paragraph('Первый разрез простой: сортирую emitted-ассеты по <code>size</code>. Это показывает, какие выходные файлы появились и какие из них стали больше. Но размер в 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('Запуск <code>node tools/print-webpack-stats.js stats-after.json</code> не должен автоматически объявлять все строки из второго списка проблемой. Общий модуль может быть правильно связан с несколькими chunks в описании компиляции, а часть chunks может быть асинхронной. Список нужен, чтобы назвать конкретный модуль, после чего его надо сопоставить с entrypoint и HTML.'),
dataTable(
['Наблюдение', 'Что это может означать', 'Следующее действие'],
[
['Появился новый <code>admin.…js</code>, старый <code>site.…js</code> почти не изменился', 'В <code>dist</code> лежит ещё одна страница, но старая не стала тяжелее', 'Проверить, что старый HTML не подключает <code>admin</code>'],
['Один пакет из <code>node_modules</code> виден у двух initial chunks', 'Внешняя зависимость достигнута из двух entry и не вынесена', 'Проверить <code>splitChunks</code> и условия cache group'],
['Оба entry подключены в одном HTML', 'Шаблон страницы получает чужой сценарий', 'Исправить генерацию script-тегов до настройки оптимизации'],
['Рост только в development', 'Сравнение сделано в разных режимах или с source map', 'Повторить замер одинаковой production-командой'],
['Файл большой в stats, но не запрашивается на странице', 'Ассет существует, но не входит в нужный entrypoint', 'Смотреть Network для конкретного URL, а не сумму каталога'],
],
),
heading('Проверяю entrypoint и сетевой след'),
paragraph('В stats есть сведения о chunks и entrypoints. Если новый <code>admin</code> должен жить только на <code>/admin/</code>, я открываю обычную страницу и смотрю список скриптов в HTML и вкладку Network. На ней должны быть только runtime, общие chunks, нужные именно этой странице, и её entry. Если там уже есть <code>admin</code>, проблема находится в шаблоне или плагине, а не в размере модуля.'),
paragraph('Затем повторяю проверку для <code>/admin/</code>. Только когда один и тот же большой модуль действительно участвует в двух начальных путях, есть смысл добавлять cache group. В Webpack 4 оптимизация общих chunks по умолчанию ориентирована на динамические imports; для начальных chunks нужно явно выбрать подходящую конфигурацию. Это объясняет, почему «поставил второй entry» и «получил отдельный общий файл» не равны друг другу.'),
heading('Небольшая правка после доказательства'),
paragraph('Когда stats показал повторяющийся пакет, а обе страницы действительно его загружают, я добавляю минимальную группу, а не копирую чужой длинный конфиг. Сначала отделяю пакеты из <code>node_modules</code>. Общий код приложения стоит выносить отдельным правилом только после того, как видно повтор из двух entry и он достаточно велик для отдельного запроса.'),
codeBlock(String.raw`
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
},
},
runtimeChunk: 'single',
}
`),
paragraph('После изменения я создаю третий <code>stats-fixed.json</code> и повторяю те же три проверки. Ожидаемый результат формулирую не как «стало мало килобайт», а как наблюдаемый контракт: обычная страница не загружает 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. <code>stats.json</code> даёт материал для первой части проверки, браузер — для второй. После такой пары доказательств настройка <code>splitChunks</code> становится короткой и объяснимой.'),
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');
}
}