Урок 01

Оптимизация бандла: code splitting, tree shaking

Зачем оптимизировать

Проблема:

  • Bundle 5 MB — долго скачивается.
  • Парсинг JS — сотни мс.
  • LCP страдает.
  • Мобильные — 3G.

Что даёт:

  • Быстрее загрузка.
  • Меньше трафика.
  • Лучше CWV.
  • Дешевле CDN.

Правило: бандл — первый bottleneck фронта.

Что внутри бандла

Что типично:

  • React / Vue / Next — ~50 KB gzip.
  • Router — 10–20 KB.
  • UI-lib — 30–100 KB.
  • Твой код — 30–300 KB.
  • Полифиллы — 20 KB.

Что должно быть:

  • < 200 KB gzip для initial.
  • < 500 KB — предел.
  • > 1 MB — проблема.

Правило: initial bundle — < 200 KB gzip.

Bundle Analyzer

Vite:

npm install -D rollup-plugin-visualizer
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';

export default {
    plugins: [visualizer({ open: true, gzipSize: true })],
};

Next.js:

npm install -D @next/bundle-analyzer
const withBundleAnalyzer = require('@next/bundle-analyzer')({
    enabled: process.env.ANALYZE === 'true',
});

module.exports = withBundleAnalyzer({});
ANALYZE=true npm run build

Правило: без analyzer — не видно, что внутри.

Что искать в analyzer

  • Крупные пакеты.
  • Дубли (moment + date-fns).
  • Полные lodash.
  • Момент (тяжёлый).
  • Polyfills без нужды.
  • Всё в initial chunk.

Правило: топ-5 крупных — план оптимизации.

Tree shaking

Что это: удаление неиспользуемого кода.

Что требует:

  • ESM (import/export).
  • Side-effect free код.
  • sideEffects: false в package.json.

Что даёт: размер ↓ 20–50%.

CommonJS vs ESM

Плохо (CJS):

const _ = require('lodash');

Хорошо (ESM):

import debounce from 'lodash/debounce';
// или
import { debounce } from 'lodash-es';

Правило: ESM + named imports — tree-shakable.

sideEffects

package.json проекта:

{
    "sideEffects": false
}

⚠️ Только если нет side-effects (CSS-импортов, инициализаций).

Или явно:

{
    "sideEffects": ["*.css", "./src/setup.js"]
}

Правило: sideEffects — осторожно.

Импорты по частям

Плохо:

import _ from 'lodash';
_.debounce(...);

Хорошо:

import debounce from 'lodash/debounce';

Что даёт: в 10× меньше lodash.

Библиотеки

Замена тяжёлых:

  • moment → date-fns / dayjs.
  • lodash → es-toolkit / radash.
  • axios → fetch.
  • jQuery → vanilla.

Правило: каждая зависимость — осознанно.

Code splitting

Что это: разделение бандла на chunks.

Что даёт:

  • Меньше initial.
  • Загрузка по требованию.
  • Кэш между релизами.

Правило: code splitting — обязательно в SPA.

Dynamic import

Базовое:

button.addEventListener('click', async () => {
    const { default: Chart } = await import('chart.js');
    new Chart(...);
});

Что даёт: chart.js загрузится по клику.

React lazy

import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./Dashboard'));

<Suspense fallback={<Spinner />}>
    <Dashboard />
</Suspense>

Что даёт: отдельный chunk для Dashboard.

React Router lazy

const routes = [
    {
        path: '/dashboard',
        lazy: () => import('./pages/Dashboard'),
    },
];

Что даёт: chunk на каждый роут.

Правило: роуты — всегда lazy.

Next.js dynamic

import dynamic from 'next/dynamic';

const Chart = dynamic(() => import('./Chart'), {
    ssr: false,
    loading: () => <Spinner />,
});

Что даёт: chart только на клиенте.

Next.js App Router

Автоматически:

  • Каждая страница — свой chunk.
  • loading.tsx — стриминг.
  • Suspense — по требованию.

Правило: в App Router — ничего вручную.

Route-based

Плохо: всё в main.js.

Хорошо:

main.js          — shell
catalog.js       — каталог
product.js       — товар
account.js       — кабинет
admin.js         — админка

Что даёт: админка не грузится для гостя.

Component-based

Для тяжёлых:

  • Chart.js.
  • Editor.
  • Maps.
  • Rich text.
  • PDF viewer.

Правило: > 50 KB компонент — dynamic.

Prefetch

Что даёт: заранее загрузить.

React Router:

<Link to="/dashboard" prefetch="intent">Dashboard</Link>

Next.js Link:

<Link href="/dashboard" prefetch>Dashboard</Link>

Правило: prefetch — на hover.

Vendor chunk

Что даёт: кэш библиотек отдельно.

Vite:

build: {
    rollupOptions: {
        output: {
            manualChunks: {
                vendor: ['react', 'react-dom'],
                router: ['react-router-dom'],
            },
        },
    },
},

Правило: vendor — меняется редко → кэш.

Minify

Что делает: удаляет пробелы, сокращает.

Vite: автоматически (esbuild).

Webpack: terser.

Правило: prod — всегда minify.

Compression

gzip / brotli:

gzip on;
gzip_types application/javascript text/css application/json;
brotli on;
brotli_types application/javascript text/css application/json;

Что даёт: gzip ↓ 70%, brotli ↓ 75%.

Правило: brotli — если поддерживает Nginx.

Preload / Prefetch

Preload критичного:

<link rel="preload" as="script" href="/main.js" crossorigin>

Prefetch будущего:

<link rel="prefetch" href="/dashboard.js">

Правило: preload — только критичное.

Что важно в 2026

  • Vite — стандарт для SPA.
  • Turbopack — для Next.js.
  • esbuild — быстрая минификация.
  • Brotli — дефолт.
  • HTTP/3 — быстрее.
  • RSC — Server Components.

Практика

1. Прогоните analyzer.

2. Найдите топ-5 крупных.

3. Замените moment на dayjs.

4. lodash — на es-toolkit.

5. Добавьте lazy для роутов.

6. Разделите vendor.

7. Включите brotli.

8. Замерьте размер.

9. Сравните initial bundle.

10. Проверьте Lighthouse.

Частые ошибки

1. import _ from 'lodash':

Весь lodash

Решение: named / path.

2. Без analyzer:

Не видно что внутри

Решение: включить.

3. Всё в initial:

5 MB бандл

Решение: lazy.

4. Без minify:

+30%

Решение: prod build.

5. Без gzip/brotli:

Больше трафик

Решение: Nginx.

6. Polyfills без нужды:

+20 KB

Решение: по browserslist.

7. Preload всего:

Конкуренция

Решение: только критичное.

8. Один vendor:

Нет кэша

Решение: split.

Итог

  • < 200 KB gzip.
  • Analyzer — обязательно.
  • Tree shaking — ESM.
  • Named imports — правило.
  • Замена moment / lodash.
  • Lazy — для роутов.
  • Vendor — отдельно.
  • Brotli — дефолт.
  • Preload — критичное.
  • Замеры до/после.

Дальше — lazy-loading компонентов и изображений.