402 lines
40 KiB
JavaScript
402 lines
40 KiB
JavaScript
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const escapeHtml = (value) => String(value)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
|
||
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');
|
||
}
|
||
}
|