Реалізація Code Splitting по маршрутам для веб-додатків
Code splitting по маршрутам — найефективніший вид розбивки бандла. Замість одного монолітного JS-файлу браузер завантажує тільки код поточної сторінки. Перехід на інший маршрут підгружає відповідний чанк. Результат: менше трафіку, швидший перший рендер, кращі LCP і TTI в Core Web Vitals.
Проблема без code splitting
Типовий SPA без розбивки:
dist/
assets/
index-Bx7K9m2p.js # 1.2 MB — весь код додатку
vendor-Dq8R3nYk.js # 800 KB — усі залежності
Користувач, який відкрив главну сторінку, скачує код сторінки оформлення замовлення, панелі адміністратора та всіх інших розділів — хоча вони йому не потрібні прямо зараз.
React Router v6 + React.lazy
// router/index.tsx
import { lazy, Suspense } from 'react'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { AppLayout } from '@/layouts/AppLayout'
import { PageLoader } from '@/components/PageLoader'
// Кожен маршрут — окремий чанк
const Home = lazy(() => import('@/pages/Home'))
const Catalog = lazy(() => import('@/pages/Catalog'))
const ProductPage = lazy(() => import('@/pages/ProductPage'))
const Cart = lazy(() => import('@/pages/Cart'))
const Checkout = lazy(() => import('@/pages/Checkout'))
const Account = lazy(() => import('@/pages/Account'))
const AdminDashboard = lazy(() => import('@/pages/admin/Dashboard'))
const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
children: [
{ index: true, element: <Home /> },
{ path: 'catalog', element: <Catalog /> },
{ path: 'catalog/:id', element: <ProductPage /> },
{ path: 'cart', element: <Cart /> },
{ path: 'checkout', element: <Checkout /> },
{ path: 'account/*', element: <Account /> },
],
},
{
path: '/admin',
element: <AdminDashboard />,
},
])
// Suspense оборачує RouterProvider — один на весь додаток
export function App() {
return (
<Suspense fallback={<PageLoader />}>
<RouterProvider router={router} />
</Suspense>
)
}
Vite автоматично створює окремий чанк для кожного динамічного імпорту:
dist/assets/
Home-C2mK8pQr.js # 45 KB
Catalog-Fp7Xd3nY.js # 92 KB
ProductPage-Bv2Rs9mL.js # 67 KB
Checkout-Dq4Wt1kJ.js # 180 KB (Stripe, багато форм)
AdminDashboard-Xk9Pn3rT.js # 340 KB (графіки, таблиці)
vendor-Ym3Cx8wQ.js # 800 KB (загальні залежності)
Іменування чанків через магічні коментарі
const Checkout = lazy(() =>
import(/* webpackChunkName: "checkout" */ '@/pages/Checkout')
)
// У Vite — через rollupOptions
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// vendor чанки за категоріями
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-ui': ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],
'vendor-forms': ['react-hook-form', 'zod'],
'vendor-charts': ['recharts'],
},
},
},
},
})
Next.js App Router
У App Router code splitting працює за замовчуванням: кожен page.tsx — окремий сегмент. Додатково використовуємо dynamic() для важких компонентів всередині сторінок:
// app/catalog/page.tsx
import dynamic from 'next/dynamic'
import { ProductGrid } from '@/components/ProductGrid'
// Фільтри — важкі, рендеряться нижче fold
const FilterPanel = dynamic(() => import('@/components/FilterPanel'), {
loading: () => <FilterSkeleton />,
})
// Карта магазинів — тільки client-side
const StoreMap = dynamic(() => import('@/components/StoreMap'), {
ssr: false,
loading: () => <div style={{ height: 400 }} className="bg-muted animate-pulse" />,
})
export default function CatalogPage() {
return (
<div className="flex gap-8">
<FilterPanel />
<main>
<ProductGrid />
<StoreMap />
</main>
</div>
)
}
Prefetching маршрутів
Завантажуємо чанк заранее — до того, як користувач перейшов по ссилку:
// При hover на ссилку
import { useEffect } from 'react'
function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
const prefetch = () => {
// Динамічно імпортуємо відповідний чанк
if (to === '/catalog') import('@/pages/Catalog')
if (to === '/checkout') import('@/pages/Checkout')
}
return (
<Link to={to} onMouseEnter={prefetch} onFocus={prefetch}>
{children}
</Link>
)
}
У Next.js <Link> робить prefetch автоматично для видимих ссилок у production. Управління:
<Link href="/checkout" prefetch={false}> {/* відключити */}
<Link href="/admin" prefetch={true}> {/* примусово */}
Loading States та Error Boundaries
// components/PageLoader.tsx
export function PageLoader() {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex flex-col items-center gap-4">
<Spinner size="lg" />
<p className="text-muted-foreground text-sm">Завантаження сторінки…</p>
</div>
</div>
)
}
// ErrorBoundary для чанків, які не завантажилися (немає мережі, 404 чанка)
class ChunkErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean }
> {
state = { hasError: false }
static getDerivedStateFromError() {
return { hasError: true }
}
handleRetry = () => {
this.setState({ hasError: false })
window.location.reload()
}
render() {
if (this.state.hasError) {
return (
<div className="text-center py-16">
<p>Не удалось завантажити сторінку</p>
<button onClick={this.handleRetry}>Оновити</button>
</div>
)
}
return this.props.children
}
}
// Використання
<ChunkErrorBoundary>
<Suspense fallback={<PageLoader />}>
<RouterProvider router={router} />
</Suspense>
</ChunkErrorBoundary>
Аналіз результатів
# Вимірити розміри чанків до і після
npx vite build --reporter=verbose
# Source map explorer
npm install --save-dev source-map-explorer
npx source-map-explorer dist/assets/*.js
# Bundle analyzer
npm install --save-dev rollup-plugin-visualizer
Цільові показники: initial bundle < 200 KB gzipped, кожен route chunk < 100 KB gzipped.
Строки виконання
Настройка code splitting для існуючого маршрутизатору — 1 день. Разом з аналізом бандла, ручним розбитим vendor-чанків, настройкою prefetch та error boundary — 2–3 дні.







