diff --git a/editorial/reviews/2018-06-draft.md b/editorial/reviews/2018-06-draft.md new file mode 100644 index 0000000..a09771a --- /dev/null +++ b/editorial/reviews/2018-06-draft.md @@ -0,0 +1,52 @@ +# Июнь 2018 — тройное ревью Webpack 4 + +Партия подготовлена для отдельной интеграции: + +- `editorial-2018-06-practice-webpack-entry` +- `editorial-2018-06-mechanism-webpack-entry` +- `editorial-2018-06-field-webpack-entry` + +Дата повторной проверки: 31 июля 2026 года. Пакет принят в публикационный слой: +скрипт печатает ревизии, а `web/data/editorial-revisions.mjs` сопоставляет их только +по стабильным slug, не меняя даты, автора и базовый архив. + +## 1. Факты и техника — пройдено + +### Практика: две страницы без двойной перевозки общего кода + +- Основой стали официальные страницы Webpack 4: [Entry Points](https://v4.webpack.js.org/concepts/entry-points/), [Code Splitting](https://v4.webpack.js.org/guides/code-splitting/), [Optimization](https://v4.webpack.js.org/configuration/optimization/) и [Output](https://v4.webpack.js.org/concepts/output/). +- Конфигурация использует синтаксис Webpack 4: object entry, `optimization.splitChunks` и `runtimeChunk: 'single'`. Отдельный `vendor` entry не выдан за рекомендуемый путь: документация v4 прямо направляет разделение vendor/app-кода в `splitChunks`. +- `minSize: 0` помечен учебным условием для маленького примера, а не универсальной production-настройкой. Текст называет риск лишнего запроса и требует проверить фактические script-теги. + +### Механизм: один import в двух entry bundle + +- Сверены [object/array-синтаксис entry](https://v4.webpack.js.org/concepts/entry-points/) и [ограничения entry-point splitting](https://v4.webpack.js.org/guides/code-splitting/). +- Объяснение разделяет исходный файл, граф достижимости и emitted chunk. В тексте нет ложного вывода, что общий путь на диске автоматически создаёт общий chunk. +- Массив файлов описан как один multi-main entry, а два самостоятельных HTML-документа — как два object entry. Новые entry-API не подставляются в пример Webpack 4. + +### Диагностика: рост bundle после нового entry + +- Поля `assets`, `chunks`, `modules` и entrypoint-данные сверены с [Stats Data](https://v4.webpack.js.org/api/stats/) Webpack 4; команда и формат CLI — с [документацией CLI](https://v4.webpack.js.org/api/cli/). +- Диагностический фрагмент не трактует присутствие модуля в нескольких chunks как готовое доказательство скачивания. Статья требует проверить HTML и Network отдельно. +- Размер ассета в stats не выдан за сетевой transfer size: явно названы влияние сжатия, кеша, режима сборки и source map. В текст не добавлены вымышленные замеры, пользователи или результаты. +- Третий редакторский проход после независимого quality-gate: первое предложение теперь начинается с явного маркера «Симптом» и называет наблюдаемые `site.…js` и `admin.…js` в Network; цена ошибки сохранена — пользователь скачивает код панели, которой не откроет. + +## 2. Редактура и голос — пройдено + +- Каждый текст отвечает на отдельный вопрос: настройка двух страниц, причина дублирования в графе, расследование роста ассетов. +- Проблема и цена ошибки стоят в первом абзаце; далее идут конкретные куски конфигурации, проверка и ограничение. +- Проза без кода, источников и разметки: 5 502, 5 177 и 6 024 знака соответственно. Все значения входят в интервал 5 000–15 000. +- Тон оставлен на уровне 2018 года: короткая техническая речь, локальный пример, ручная проверка. Нет позднего инфраструктурного жаргона, неподкреплённых метрик и шаблонов массового архива. +- Общие слова заменены условиями: что является entry, когда общий модуль считается кандидатом на chunk, что нужно увидеть в stats и Network. +- Во всех трёх ревизиях список источников теперь начинается точным отдельным заголовком `
' + 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');
+ }
+}