Реализация 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 (charts, tables)
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 дня.







