DarkRiDDeR10 мин

Webpack 4. Bundle вырос после нового entry: как найти причину по stats.json

JavaScriptWebpack

Симптом: после добавления admin-entry вырос site.[contenthash].js, а Network обычной страницы показывает запрос к admin.[contenthash].js. Пользователь получает код панели, которой не откроет; если править только сумму файлов в dist, легко оставить этот лишний запрос или сломать подключение нужного entry. Цена ошибки — лишний байт в критическом пути и неверная загрузка административного кода.

Главный вопрос статьи: как по данным Webpack 4 доказать, почему bundle вырос после добавления entry, прежде чем менять конфигурацию? Для ответа нужны три вещи: список emitted-ассетов, связь модуля с chunks и фактические script-теги в HTML. Одной цифры из файловой системы недостаточно.

Сначала фиксирую условия сравнения

Сравнивать development-результат с production-результатом бессмысленно: режим, минификация, source map и плагины меняют картину сильнее, чем новый entry. Я делаю два production-build на одном коммите: до изменения и после него. Для каждого сохраняю JSON статистики отдельно, например stats-before.json и stats-after.json.

Диагностика роста Webpack bundle: фиксируем одинаковый build, смотрим assets, связываем модули с chunks, проверяем HTML и только затем меняем splitChunks.
Статистика сборщика показывает состав компиляции. Network в браузере отвечает на отдельный вопрос: что реально скачала конкретная страница.

Создаю stats.json из той же команды сборки

Webpack умеет отдать статистику компиляции в JSON. В ней есть ассеты, chunks, модули и их связи. Команду лучше запускать локальным webpack-cli из проекта: тогда версия сборщика совпадает с той, для которой написан webpack.config.js.

# В 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

Параметр --profile добавляет сведения о времени по модулям. Для вопроса о размере он не обязателен, но снимок пригодится, если рост размера сопровождается долгой сборкой. Главное — не смешивать JSON со случайными console.log из конфигурации: файл должен остаться валидным JSON.

Читаю сначала ассеты, а не весь граф

Первый разрез простой: сортирую emitted-ассеты по size. Это показывает, какие выходные файлы появились и какие из них стали больше. Но размер в stats — размер ассета в сборке, а не обязательно число байтов, переданных по сети после gzip или кеширования. Поэтому это место для гипотезы, а не для вывода о скорости страницы.

// 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(','));
}

Запуск node tools/print-webpack-stats.js stats-after.json не должен автоматически объявлять все строки из второго списка проблемой. Общий модуль может быть правильно связан с несколькими chunks в описании компиляции, а часть chunks может быть асинхронной. Список нужен, чтобы назвать конкретный модуль, после чего его надо сопоставить с entrypoint и HTML.

НаблюдениеЧто это может означатьСледующее действие
Появился новый admin.[contenthash].js, старый site.[contenthash].js почти не изменилсяВ dist лежит ещё одна страница, но старая не стала тяжелееПроверить, что старый HTML не подключает admin
Один пакет из node_modules виден у двух initial chunksВнешняя зависимость достигнута из двух entry и не вынесенаПроверить splitChunks и условия cache group
Оба entry подключены в одном HTMLШаблон страницы получает чужой сценарийИсправить генерацию script-тегов до настройки оптимизации
Рост только в developmentСравнение сделано в разных режимах или с source mapПовторить замер одинаковой production-командой
Файл большой в stats, но не запрашивается на страницеАссет существует, но не входит в нужный entrypointСмотреть Network для конкретного URL, а не сумму каталога

Проверяю entrypoint и сетевой след

В stats есть сведения о chunks и entrypoints. Если новый admin должен жить только на /admin/, я открываю обычную страницу и смотрю список скриптов в HTML и вкладку Network. На ней должны быть только runtime, общие chunks, нужные именно этой странице, и её entry. Если там уже есть admin, проблема находится в шаблоне или плагине, а не в размере модуля.

Затем повторяю проверку для /admin/. Только когда один и тот же большой модуль действительно участвует в двух начальных путях, есть смысл добавлять cache group. В Webpack 4 оптимизация общих chunks по умолчанию ориентирована на динамические imports; для начальных chunks нужно явно выбрать подходящую конфигурацию. Это объясняет, почему «поставил второй entry» и «получил отдельный общий файл» не равны друг другу.

Небольшая правка после доказательства

Когда stats показал повторяющийся пакет, а обе страницы действительно его загружают, я добавляю минимальную группу, а не копирую чужой длинный конфиг. Сначала отделяю пакеты из node_modules. Общий код приложения стоит выносить отдельным правилом только после того, как видно повтор из двух entry и он достаточно велик для отдельного запроса.

optimization: {
  splitChunks: {
    chunks: 'all',
    cacheGroups: {
      vendors: {
        test: /[\\/]node_modules[\\/]/,
        name: 'vendors',
        chunks: 'all',
      },
    },
  },
  runtimeChunk: 'single',
}

После изменения я создаю третий stats-fixed.json и повторяю те же три проверки. Ожидаемый результат формулирую не как «стало мало килобайт», а как наблюдаемый контракт: обычная страница не загружает admin-entry; общий пакет появился в предназначенном для него chunk; обе страницы получают все необходимые файлы без ошибки выполнения.

Последовательность расследования

  1. Сохранить два stats-снимка из одинаковой production-команды и назвать версии webpack и webpack-cli.
  2. Сравнить ассеты: какой файл вырос, какой появился, связан ли он с новым entry.
  3. Найти крупные модули, отмеченные в нескольких chunks, и не путать этот сигнал с доказательством сетевой загрузки.
  4. Открыть каждый HTML-маршрут с пустым кешем и проверить реальные script-теги и Network.
  5. Только после подтверждения дублирования настроить одну cache group, пересобрать и повторить тот же снимок.

Ограничения метода

Stats JSON отражает конкретную версию Webpack 4 и состав компиляции. Названия полей и формат данных могут меняться после обновления сборщика, поэтому диагностический скрипт не стоит превращать в вечный CI-контракт без фиксации версии. Метод также не измеряет время первой отрисовки и не учитывает серверное сжатие; для этого нужен отдельный сетевой замер. Но он надёжно отделяет «в каталоге стало больше файлов» от конкретного вопроса «какой модуль попал в какой chunk и почему».

Итог

Новый entry сам по себе увеличивает число ассетов — это ожидаемо. Дублирование начинается не от количества файлов, а от повторно достижимого модуля и от того, какие chunks подключает HTML. stats.json даёт материал для первой части проверки, браузер — для второй. После такой пары доказательств настройка splitChunks становится короткой и объяснимой.

Проверяемые источники