Анализ JS-бандла в Next.js: где искать лишний вес
Раз в полгода я сажусь и проверяю, что у меня в бандле. Без этого через год проект превращается в склад случайно протащенных библиотек, и страница, которая раньше весила 95 КБ gzipped, начинает весить 280. Не потому что добавилось много функционала, а потому что кто-то поставил moment вместо date-fns и не заметил.
Покажу, как я анализирую бандл в Next.js, какие инструменты использую и что обычно оказывается виновником лишнего веса. Без рекламы и без подборок «10 лучших». Только то, что реально работает в проде.
Что измерять и зачем
Размер бандла — это два разных числа: total и first load. Total — сколько всего JS отдаст приложение, если пользователь обойдёт все страницы. First Load — сколько уедет на первую страницу, чтобы она заработала.
Меня в первую очередь интересует first load JS на критических страницах: главная, страница каталога, страница продукта. Если на них больше 200 КБ gzipped — пора лезть в бандл.
В Next.js во время next build в консоли уже виден размер каждого роута:
Route (app) Size First Load JS
┌ ○ / 1.2 kB 102 kB
├ ○ /catalog 3.1 kB 145 kB
├ ƒ /product/[slug] 4.5 kB 156 kB
+ First Load JS shared by all 89.3 kB
«Shared by all» — это базовый JS, который грузится на любой странице (фреймворк, polyfills, общие чанки). Снизить его сложнее всего, но именно тут лежит самый большой потенциал.
Инструмент номер один: bundle analyzer
Стандартный @next/bundle-analyzer. Подключается в next.config.js:
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
});
module.exports = withBundleAnalyzer({
// твой конфиг
});
Запускаешь:
ANALYZE=true npm run build
Открываются три HTML-отчёта: client.html, nodejs.html, edge.html. Меня в 90% случаев интересует первый. Это treemap, где каждый прямоугольник — модуль, размер пропорционален весу.
На что смотрю первым делом:
- Большие квадраты, в которых я не узнаю свой код. Это всегда чужая библиотека.
- Дублирующиеся модули. Если одна и та же библиотека встретилась в двух разных чанках — настройка треешейкинга где-то не сработала.
- Полный
node_modules/lodashвместо одногоlodash/debounce. Классика.
Типичные виновники, которые я ловлю
Тяжёлые библиотеки, импортированные целиком
Самый частый случай. Кто-то написал import _ from "lodash", и в бандл уехал весь lodash. То же самое с moment, date-fns (если импортировать без сабпатей), antd, material-ui v4 (в v5 уже лучше).
// плохо
import { debounce } from "lodash";
// хорошо
import debounce from "lodash/debounce";
В TS-проекте это лечится одной строкой в .eslintrc: правило no-restricted-imports для lodash с подсказкой использовать сабпаты. Через неделю в проекте это уже не появляется.
Полифилы для фич, которые не нужны
Next.js по умолчанию подключает довольно умный набор полифилов через target browserslist. Но если в browserslist у тебя стоит not dead и древние Safari, ты получаешь полифилы для всего на свете. Я обычно ставлю что-то вроде:
> 0.5%
last 2 versions
Firefox ESR
not dead
not ie 11
И смотрю, как меняется размер. На одном проекте мы за счёт правки browserslist скинули 12 КБ из shared chunk.
Иконки
Если ты тащишь react-icons и импортируешь оттуда что попало — можешь нечаянно затащить тысячи иконок. Library с иконками обычно работают через сабпаты, и важно их использовать:
// плохо — может затащить лишнее
import { FaCheck } from "react-icons";
// хорошо
import { FaCheck } from "react-icons/fa";
Ещё лучше — взять lucide-react, который treeshakeable из коробки, или вырезать нужные иконки в собственный SVG-спрайт.
CSS-in-JS рантайм
Решения вроде styled-components или emotion приносят свой рантайм в бандл. На простых проектах это терпимо. На больших я стараюсь сидеть либо на zero-runtime CSS-in-JS (vanilla-extract, panda), либо на Tailwind/CSS modules. Разница в shared chunk бывает 15–20 КБ.
Дублирующиеся версии React или утилит
Если bundle analyzer показывает, что react или react-dom попали в чанк дважды — это проблема резолва. Обычно из-за того, что какая-то монорепная зависимость тянет свою копию. Лечится через resolve.alias в Next.js или через peerDependencies в внутренних пакетах.
Dynamic import: когда выносить в отдельный чанк
Не все компоненты обязаны быть на первом загрузке. График, который виден только после клика на кнопку «Показать статистику», можно загрузить лениво:
import dynamic from "next/dynamic";
const Chart = dynamic(() => import("./Chart"), {
loading: () => <div>Загрузка графика…</div>,
ssr: false,
});
Я обычно выношу в dynamic:
- Тяжёлые библиотеки графиков (Chart.js, ECharts, recharts).
- Редактор Markdown или WYSIWYG, который виден только в форме создания.
- Карта (yandex-maps, leaflet) на странице, где она занимает видимую часть, но грузится после первичного рендера.
- Подсветка синтаксиса (Prism, Shiki) на страницах с кодом.
Не выношу в dynamic просто ради того, чтобы вынести. Если кусок UI виден сразу при открытии страницы — он часть first load, и dynamic тут только добавит «мигание».
Анализ Server Components отдельно
В App Router серверные компоненты не уезжают в клиентский бандл. Это значит, что все импорты в серверных файлах не считаются — бандл-аналайзер их не покажет в client.html. Но они влияют на cold start функции и на размер edge-бандла.
Если у тебя серверный компонент тянет @aws-sdk/client-s3 на 2 МБ — это не повлияет на JS у пользователя, но повлияет на холодный старт SSR. Особенно болезненно на edge-runtime, где есть лимиты по размеру.
Я для этого смотрю nodejs.html и edge.html из того же отчёта analyzer. Если на edge-роуте видны тяжёлые SDK — переношу либо на Node-runtime, либо в API-роут, который вызываю из edge через fetch.
Source map explorer: альтернативный взгляд
Bundle analyzer показывает «что внутри». Source map explorer — «что от моего кода». Запускается отдельно:
npx source-map-explorer .next/static/chunks/*.js
Из этого инструмента я обычно выясняю, что конкретно из моего кода занимает место. Часто выясняется, что какой-то компонент с большой константой (например, мегатон фейк-данных, забытый после прототипа) утащил с собой 30 КБ.
Web Vitals в проде
Размер бандла — не самоцель. Цель — быстрый LCP и низкий INP. После каждой оптимизации я смотрю реальные метрики, а не только цифры в консоли билда. Web Vitals в Next.js есть из коробки через useReportWebVitals:
"use client";
import { useReportWebVitals } from "next/web-vitals";
export function WebVitals() {
useReportWebVitals((metric) => {
fetch("/api/vitals", {
method: "POST",
body: JSON.stringify(metric),
});
});
return null;
}
Складываю в свою аналитику и смотрю распределение по реальным пользователям. Один раз я ужалась бандл на 30%, а LCP не сдвинулся — оказалось, что узким местом был не JS, а ответ API. Без RUM я бы это не увидел.
Регрессионная защита
Чтобы оптимизация не была одноразовой, ставлю в CI проверку размера. Использую size-limit или встроенную фишку с next build и собственным скриптом, который парсит вывод:
{
"size-limit": [
{
"path": ".next/static/chunks/main-*.js",
"limit": "100 kB"
}
]
}
На пуше в master CI смотрит, не вырос ли чанк сверх лимита. Если вырос — PR отклоняется. Это спасает от ситуации «кто-то добавил moment, никто не посмотрел».
Что запомнить
Анализ бандла — это процедура раз в квартал, а не разовая героика. Bundle analyzer плюс source map explorer плюс лимиты в CI — три инструмента, которыми ты держишь размер под контролем. Большинство выигрышей лежит в правильных импортах и в выносе тяжёлых компонентов в dynamic, а не в экзотических оптимизациях.
И не оптимизируй ради 0.05 секунды на странице, которую открывают раз в неделю. Сначала измерь реальное влияние, потом тратьте время.