Що таке code splitting?
Code splitting - це поділ одного великого JavaScript-бандла на менші чанки, які браузер завантажує лише тоді, коли вони справді потрібні.
Теорія
TL;DR
- Без code splitting браузер завантажує весь додаток перед тим, як показати будь-що
- Динамічний
import()- сигнал для бандлера: цей модуль іде в окремий файл - Поділ по роутах - найпоширеніша стратегія: один чанк на сторінку або роут
webpackPrefetchдозволяє браузеру завантажити чанк у фоні, до того як юзер туди перейде- Надмірний поділ теж шкодить: 50 маленьких запитів можуть бути повільнішими за один середній бандл
Як браузер отримує код
Коли Webpack або Vite збирає додаток, всі статичні import потрапляють в один вихідний файл. Щойно зустрічається динамічний import() - бандлер виносить той модуль в окремий chunk-файл. Браузер завантажить його тільки тоді, коли виконання дійде до цього місця в коді.
// Статичний імпорт - потрапляє в основний бандл
import { format } from 'date-fns';
// Динамічний імпорт - створює окремий чанк
const { Chart } = await import('./Chart.js');Другий рядок змушує бандлер згенерувати окремий файл, наприклад chunk-abc123.js. Браузер завантажить його тільки коли виконання дійде до await import(...).
Поділ по роутах
React Router разом з React.lazy - стандартний підхід у React-додатках:
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<div>Завантаження...</div>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}Кожна сторінка - окремий чанк. Юзер, який відкриває тільки /dashboard, ніколи не завантажить бандл сторінки налаштувань.
Поділ на рівні компонентів
Ділити можна не тільки на межах роутів. Важкі компоненти - редактори тексту, мапи, PDF-переглядачі - теж гарні кандидати:
const RichEditor = lazy(() => import('./RichEditor')); // бібліотека на 200kb
function PostForm({ showEditor }) {
return (
<div>
{showEditor && (
<Suspense fallback={<p>Завантаження редактора...</p>}>
<RichEditor />
</Suspense>
)}
</div>
);
}Редактор завантажується тільки коли showEditor стає true. Якщо більшість юзерів ніколи не натискає «написати пост», вони ніколи не платять за цей код.
Що відбувається всередині бандлера
Webpack знаходить виклики import() під час збирання і генерує два артефакти: файл чанку і невеликий runtime-сніпет, який знає як його завантажити. У production-збірці чанки отримують хеш у назві: 312.abc1d2.js. Назву можна задати через magic comment:
const Chart = await import(
/* webpackChunkName: "analytics-chart" */ './Chart'
);Vite працює схоже, але використовує алгоритм чанкінгу від Rollup. Next.js ділить код автоматично на рівні файлів: кожна сторінка в app/ або pages/ стає окремим чанком без жодного налаштування.
Prefetch і Preload
Якщо завантажувати чанк тільки після кліку, юзер чекає на мережевий запит. Webpack підтримує два хінти:
webpackPrefetch: true- браузер завантажить чанк у фоні під час простою, до переходуwebpackPreload: true- браузер завантажить чанк паралельно з батьківським
// Завантажується у фоні коли браузер не зайнятий
const Analytics = await import(
/* webpackPrefetch: true */ './Analytics'
);Prefetch - правильний вибір для більшості переходів між роутами. Preload - для чанків, які точно знадобляться разом з поточною сторінкою.
Типові помилки
Занадто дрібний поділ. 50 окремих чанків по 5kb - це 50 HTTP-запитів. Навіть з HTTP/2 multiplexing накладні витрати накопичуються. Практичне правило: все що менше 30-40kb, скоріше за все, не варте окремого чанку.
Suspense без error boundary. Якщо чанк не завантажився (мережева помилка або деплой під час переходу між сторінками), React кидає помилку. Без error boundary весь додаток падає.
// Додаток впаде якщо чанк не завантажиться
<Suspense fallback={<Loading />}>
<LazyRoute />
</Suspense>
// Так помилка обробляється нормально
<ErrorBoundary fallback={<p>Не вдалося завантажити. Спробуй оновити сторінку.</p>}>
<Suspense fallback={<Loading />}>
<LazyRoute />
</Suspense>
</ErrorBoundary>Дублювання спільних залежностей. Якщо ChartA і ChartB обидва імпортують lodash, і обидва розбиті на окремі чанки - lodash потрапить у кожен з них. SplitChunksPlugin у Webpack вирішує це автоматично в більшості конфігурацій, але варто перевірити через аналіз бандла.
Поділ без аналізу бандла. Спочатку подивись що в бандлі, потім вирішуй що ділити. webpack-bundle-analyzer або rollup-plugin-visualizer для Vite покажуть точну картину: де вага, де дублювання.
Де зустрічається на практиці
- Next.js - автоматичний поділ на рівні сторінок, нічого налаштовувати не потрібно
- Vite - автоматичний поділ на динамічних імпортах;
manualChunksдля виділення vendor-коду - React Router v6 - разом з
React.lazyдля поділу на рівні роутів - Vue -
defineAsyncComponent(() => import('./Component.vue'))працює за тим самим принципом - Angular - lazy-loaded модулі через
loadChildrenу роутері
Питання на співбесіді
Q: Яка різниця між React.lazy і звичайним динамічним import()?
A: React.lazy обгортає динамічний import() і інтегрується з Suspense - React сам керує станом завантаження. Звичайний import() повертає просто Promise, стан завантаження треба обробляти вручну. Ще один нюанс: React.lazy працює тільки з default exports.
Q: Як Next.js обробляє code splitting порівняно зі звичайним React-додатком?
A: Next.js ділить код автоматично на рівні файлів: кожна сторінка в app/ або pages/ - окремий чанк. У звичайному React-додатку треба додавати React.lazy вручну. Плюс Next.js рендерить HTML на сервері, тому юзер бачить контент ще до завантаження JS-чанку.
Q: Що відбувається якщо юзер переходить на іншу сторінку поки чанк ще завантажується?
A: React скасовує pending-стан попереднього Suspense і починає завантажувати новий чанк. Стара мережева загрузка продовжується у фоні, але її результат ігнорується після завершення.
Q: Чи можна розділити код по ролях, щоб адміни і звичайні юзери отримували різні чанки?
A: Так. Якщо юзер не адмін, виклик import() для адмін-роутів просто не виконується, і чанк ніколи не запрошується. Це поширений патерн для обмеження доступу до функцій по ролі.
Q: Яку метрику code splitting покращує найбільше?
A: Time to Interactive (TTI) і Largest Contentful Paint (LCP), якщо видалений код блокував рендеринг. Зменшення бандла відображається в Lighthouse-аудиті «Remove unused JavaScript». Помітний ефект на першому завантаженні з'являється коли основний бандл зменшується нижче приблизно 150-200kb після gzip.
Приклади
Базовий динамічний імпорт
// Без splitting: heavyLib завантажується при кожному відкритті сторінки
// import { processData } from './heavyLib';
// З splitting: heavyLib завантажується тільки коли юзер натискає Експорт
async function handleExport() {
const { processData } = await import('./heavyLib');
const result = processData(userData);
downloadFile(result);
}
document.getElementById('export-btn')
.addEventListener('click', handleExport);Бібліотека processData може важити 150kb. Без поділу кожен юзер платить цю ціну при завантаженні сторінки, навіть якщо ніколи не натисне «Експорт».
Поділ роутів з обробкою помилок
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import ErrorBoundary from './ErrorBoundary';
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
const UserProfile = lazy(() => import('./pages/UserProfile'));
function App() {
return (
<ErrorBoundary fallback={<p>Сторінку не вдалося завантажити. Оновіть сторінку.</p>}>
<Suspense fallback={<div className="spinner" />}>
<Routes>
<Route path="/admin" element={<AdminPanel />} />
<Route path="/profile" element={<UserProfile />} />
</Routes>
</Suspense>
</ErrorBoundary>
);
}ErrorBoundary перехоплює помилки завантаження чанків. Без нього деплой під час переходу юзера між сторінками призведе до краша всього додатку без жодного шляху до відновлення.
Prefetch при наведенні
import { lazy } from 'react';
const HeavyDashboard = lazy(() => import('./HeavyDashboard'));
function NavLink({ to, children }) {
// Запускає завантаження чанку до кліку
const prefetch = () => import('./HeavyDashboard');
return (
<a
href={to}
onMouseEnter={prefetch} // prefetch при наведенні миші
onFocus={prefetch} // prefetch при фокусі з клавіатури
>
{children}
</a>
);
}До того як юзер підніме палець з кнопки миші, чанк уже, скоріше за все, у кеші. Я бачив як цей підхід зменшував видимий час переходу з 800мс до менш ніж 100мс на повільному з'єднанні.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.