Олег Марков
Ленивая загрузка маршрутов - подходы примеры и лучшие практики
Введение
Ленивая загрузка маршрутов (lazy-loading routes) — это подход, при котором код отдельных страниц или модулей загружается не сразу при старте приложения, а только в момент, когда пользователь действительно переходит по соответствующему маршруту.
Вы экономите трафик, ускоряете первоначальную загрузку и делаете интерфейс отзывчивее, особенно если приложение большое и состоит из десятков страниц.
Смотрите, я покажу вам, как это работает на практике, а также разберу типичные подходы в современных фреймворках:
- общие принципы ленивой загрузки маршрутов;
- как это устроено под капотом с точки зрения бандла и динамического импорта;
- примеры на чистом JavaScript с History API и SPA;
- примеры с React Router;
- примеры с Vue Router;
- примеры с Angular Router;
- типичные ошибки и подводные камни;
- рекомендации по структуре проекта и лучшим практикам.
Независимо от того, какой фреймворк вы используете, сама идея ленивой загрузки маршрутов везде примерно одна и та же, так что вы сможете адаптировать подход под свой стек.
Что такое ленивая загрузка маршрутов
Основная идея
Давайте начнем с сильного упрощения. Представьте у вас SPA с 10 страницами:
- главная;
- каталог;
- карточка товара;
- корзина;
- профиль;
- настройки;
- и так далее.
Без ленивой загрузки весь код всех страниц попадает в один большой бандл, который браузер скачивает сразу. Из-за этого:
- увеличивается время загрузки;
- страница дольше становится интерактивной;
- пользователю приходится ждать, даже если он никогда не зайдет, например, в «настройки администратора».
Ленивая загрузка маршрутов решает это так:
- стартовый бандл содержит только код, необходимый для запуска приложения и отображения первых экранов;
- остальные страницы упакованы в отдельные чанки (куски кода);
- при переходе по маршруту код этой страницы подгружается по сети через динамический импорт;
- после загрузки страница рендерится как обычно.
Отличие от обычного code splitting
Ленивая загрузка маршрутов — это частный случай разделения кода (code splitting). Разделять можно по-разному:
- по маршрутам (страницам);
- по фичам (feature-based);
- по виджетам (например, модальное окно, которое редко открывается);
- по внешним библиотекам.
Но ленивую загрузку маршрутов проще всего реализовать и понять, потому что маршруты уже естественно делят приложение на независимые части.
Когда имеет смысл использовать
Ленивая загрузка маршрутов почти всегда полезна, если:
- у вас SPA или MPA с JavaScript роутером;
- объем кода значительный;
- маршрутов больше нескольких штук.
Однако бывают нюансы:
- если приложение очень маленькое, выгода может быть минимальной;
- если у вас супер строгие требования к быстроте навигации между всеми разделами, возможно, часть модулей вы захотите предзагружать заранее.
Как это работает под капотом
Динамический импорт
Давайте разберемся на примере. В современном JavaScript есть динамический импорт модулей:
// Здесь мы динамически загружаем модуль только тогда, когда он нужен
import("./pages/ProfilePage.js")
.then(module => {
const ProfilePage = module.default
// Здесь мы, например, рендерим страницу профиля
render(ProfilePage)
})
.catch(error => {
// Здесь мы обрабатываем ошибку загрузки модуля
console.error("Не удалось загрузить страницу профиля", error)
})
Что происходит:
- при сборке бандлера (Webpack, Vite, esbuild) конструкция import с круглым скобками воспринимается как точка разделения кода;
- генерируется отдельный файл чанка, например
ProfilePage.[hash].js; - во время выполнения браузер загружает этот файл только при вызове dynamic import.
Ленивая загрузка маршрутов — это по сути обертка роутера вокруг динамического импорта.
Связь с роутером
Если упростить, роутер делает примерно следующее:
// Пример упрощенного роутера с ленивой загрузкой
const routes = {
"/profile": () => import("./pages/ProfilePage.js"),
"/settings": () => import("./pages/SettingsPage.js"),
}
// Здесь мы подписываемся на изменения адресной строки
window.addEventListener("popstate", handleRouteChange)
// Эта функция вызывается и при первом заходе и при клике по ссылкам
function handleRouteChange() {
const path = window.location.pathname
const loadComponent = routes[path]
if (!loadComponent) {
// Здесь мы показываем страницу 404
render(NotFoundPage)
return
}
// Здесь мы можем показать спиннер пока грузится модуль
showLoadingSpinner()
loadComponent()
.then(module => {
const PageComponent = module.default
// Здесь скрываем спиннер и рендерим загруженную страницу
hideLoadingSpinner()
render(PageComponent)
})
.catch(error => {
// Здесь показываем сообщение об ошибке, если модуль не загрузился
console.error("Ошибка загрузки маршрута", error)
render(ErrorPage)
})
}
Сейчас это псевдокод, но на нем хорошо видно общую механику:
- в таблице маршрутов вместо компонента сразу — функция загрузки;
- при навигации роутер вызывает функцию, которая возвращает промис динамического импорта;
- после загрузки, как только модуль доступен, он рендерится.
Базовая реализация ленивой загрузки маршрутов без фреймворка
Чтобы лучше прочувствовать идею, давайте реализуем очень простой клиентский роутер с ленивой загрузкой без использования больших фреймворков.
Структура проекта
Представим, что у вас есть такой набор файлов:
- index.html
- main.js
- router.js
- pages/
- HomePage.js
- AboutPage.js
- ProfilePage.js
Пример содержимого страниц:
// pages/HomePage.js
export default function HomePage(rootElement) {
// Здесь мы рендерим простую разметку в корневой элемент
rootElement.innerHTML = `
<h1>Главная страница</h1>
<p>Добро пожаловать на главную</p>
`
}
// pages/AboutPage.js
export default function AboutPage(rootElement) {
// Здесь мы рендерим содержимое страницы "О проекте"
rootElement.innerHTML = `
<h1>О проекте</h1>
<p>Здесь вы узнаете подробности о приложении</p>
`
}
// pages/ProfilePage.js
export default function ProfilePage(rootElement) {
// Здесь мы рендерим условный профиль пользователя
rootElement.innerHTML = `
<h1>Профиль</h1>
<p>Информация о пользователе</p>
`
}
Простой роутер с ленивой загрузкой
Теперь создадим файл router.js:
// router.js
// Здесь мы описываем таблицу маршрутов
// Вместо прямой ссылки на компонент у нас функция, которая загружает модуль
const routes = {
"/": () => import("./pages/HomePage.js"),
"/about": () => import("./pages/AboutPage.js"),
"/profile": () => import("./pages/ProfilePage.js"),
}
// Здесь мы указываем корневой элемент, в который будем рендерить страницы
const root = document.getElementById("app")
// Функция рендера загруженного компонента
function renderPage(PageModule) {
// Здесь получаем компонент по default экспорту
const PageComponent = PageModule.default
// Здесь очищаем содержимое корневого элемента перед рендером
root.innerHTML = ""
// Здесь вызываем компонент, передавая ему корневой элемент
PageComponent(root)
}
// Функция обработки маршрута
export function navigateTo(path) {
// Здесь мы обновляем адресную строку без перезагрузки страницы
window.history.pushState({}, "", path)
// Здесь вызываем обработчик маршрута
handleRoute()
}
// Основная функция, которая обрабатывает текущий путь
export function handleRoute() {
const path = window.location.pathname
const loadPage = routes[path]
if (!loadPage) {
// Здесь мы отображаем простую 404 страницу
root.innerHTML = "<h1>Страница не найдена</h1>"
return
}
// Здесь можно отобразить индикатор загрузки
root.innerHTML = "<p>Загрузка...</p>"
// Здесь мы вызываем функцию, которая вернет промис с модулем страницы
loadPage()
.then(renderPage)
.catch(error => {
// Здесь логируем ошибку и показываем сообщение пользователю
console.error("Ошибка загрузки страницы", error)
root.innerHTML = "<p>Ошибка загрузки страницы</p>"
})
}
// Здесь мы подписываемся на событие изменения истории браузера
window.addEventListener("popstate", handleRoute)
Инициализация роутера в main.js:
// main.js
import { navigateTo, handleRoute } from "./router.js"
// Здесь мы настраиваем обработчик кликов по ссылкам
document.addEventListener("click", event => {
const link = event.target.closest("a")
if (!link) return
const href = link.getAttribute("href")
// Здесь мы проверяем, что ссылка ведет на относительный маршрут приложения
const isInternalLink = href && href.startsWith("/")
if (!isInternalLink) return
// Здесь мы предотвращаем стандартную навигацию браузера
event.preventDefault()
// Здесь мы выполняем навигацию через наш роутер
navigateTo(href)
})
// Здесь мы вызываем обработчик маршрута при первой загрузке страницы
handleRoute()
Теперь вы увидите, как это выглядит в браузере:
- при первом открытии загрузится только основной бандл (main.js, router.js и та страница, которая нужна сразу, если вы не лениво грузите и ее);
- при переходе на
/profileв сети появится новый запрос к чанку с ProfilePage.js; - после загрузки компонент отрисуется.
Ленивая загрузка маршрутов в React Router
Давайте перейдем к более практичным вещам и посмотрим, как это делается в популярных фреймворках. Начнем с React.
React.lazy и Suspense
В React есть встроенный механизм ленивой загрузки компонентов:
- React.lazy — обертка над динамическим импортом;
- Suspense — компонент, который показывает fallback (например, "Загрузка…"), пока ленивый компонент загружается.
Пример:
// Здесь мы лениво загружаем компонент страницы профиля
const ProfilePage = React.lazy(() => import("./pages/ProfilePage"))
// Здесь мы описываем маршруты
function AppRouter() {
return (
// Здесь Suspense показывает запасной контент пока грузятся ленивые компоненты
<React.Suspense fallback={<div>Загрузка...</div>}>
<Routes>
{/* Здесь мы подключаем ленивый компонент к маршруту */}
<Route path="/profile" element={<ProfilePage />} />
</Routes>
</React.Suspense>
)
}
Основное, на что стоит обратить внимание:
- функция в React.lazy должна возвращать промис import;
- Suspense обязателен, без него React не знает, что показывать, пока компонент грузится;
- в fallback вы можете разместить любой JSX, обычно это спиннер или skeleton.
Ленивая загрузка по маршрутам с React Router v6
Давайте пример чуть расширим.
Структура:
- pages/HomePage.jsx
- pages/ProductsPage.jsx
- pages/ProductDetailsPage.jsx
- pages/ProfilePage.jsx
Конфигурация маршрутов:
// AppRouter.jsx
import { BrowserRouter, Routes, Route } from "react-router-dom"
import React from "react"
// Здесь мы лениво загружаем страницы
const HomePage = React.lazy(() => import("./pages/HomePage"))
const ProductsPage = React.lazy(() => import("./pages/ProductsPage"))
const ProductDetailsPage = React.lazy(() => import("./pages/ProductDetailsPage"))
const ProfilePage = React.lazy(() => import("./pages/ProfilePage"))
export function AppRouter() {
return (
<BrowserRouter>
{/* Здесь мы оборачиваем все маршруты в Suspense */}
<React.Suspense fallback={<div>Загрузка страницы...</div>}>
<Routes>
{/* Здесь каждый маршрут использует ленивый компонент */}
<Route path="/" element={<HomePage />} />
<Route path="/products" element={<ProductsPage />} />
{/* Здесь мы используем параметр маршрута id */}
<Route path="/products/:id" element={<ProductDetailsPage />} />
<Route path="/profile" element={<ProfilePage />} />
{/* Здесь мы добавляем маршрут 404 */}
<Route path="*" element={<div>Страница не найдена</div>} />
</Routes>
</React.Suspense>
</BrowserRouter>
)
}
Как видите, код довольно простой:
- все страницы лениво загружаются;
- первая навигация к каждой новой странице приведет к загрузке соответствующего чанка;
- последующие переходы на уже загруженную страницу не требуют повторной загрузки, компонент уже в памяти.
Группировка маршрутов в чанки
Иногда выгоднее не дробить страницу на слишком мелкие чанки, а загружать «пакет» страниц, связанных логически. Например, создать один чанк для всех админских страниц.
Давайте посмотрим, как это сделать.
// Здесь мы лениво загружаем общий модуль админской части
const AdminLayout = React.lazy(() => import("./admin/AdminLayout"))
const AdminDashboardPage = React.lazy(() => import("./admin/AdminDashboardPage"))
const AdminUsersPage = React.lazy(() => import("./admin/AdminUsersPage"))
Если вы используете бандлер с поддержкой magic comments, вы можете подсказать ему, что эти модули должны быть в одном чанке. Например, для Webpack:
// Здесь мы используем webpackChunkName чтобы сгруппировать модули
const AdminLayout = React.lazy(() =>
import(/* webpackChunkName: "admin" */ "./admin/AdminLayout")
)
const AdminDashboardPage = React.lazy(() =>
import(/* webpackChunkName: "admin" */ "./admin/AdminDashboardPage")
)
const AdminUsersPage = React.lazy(() =>
import(/* webpackChunkName: "admin" */ "./admin/AdminUsersPage")
)
Теперь при первом заходе в админку загрузится один чанк admin.js, содержащий все три модуля. Это уменьшит количество сетевых запросов и может быть выгодно, если эти страницы тесно связаны.
Ленивая загрузка маршрутов во Vue Router
Теперь давайте посмотрим на Vue Router (v3 или v4, подход очень похож).
Простая конфигурация ленивых маршрутов
В Vue Router ленивая загрузка маршрута выглядит так:
// router.js
import { createRouter, createWebHistory } from "vue-router"
// Здесь мы задаем ленивую загрузку компонентов страниц
const routes = [
{
path: "/",
name: "Home",
// Здесь component - функция которая возвращает dynamic import
component: () => import("./pages/HomePage.vue"),
},
{
path: "/about",
name: "About",
component: () => import("./pages/AboutPage.vue"),
},
{
path: "/profile",
name: "Profile",
component: () => import("./pages/ProfilePage.vue"),
},
]
export const router = createRouter({
history: createWebHistory(),
routes,
})
Обратите внимание:
- вы не используете import в начале файла для страниц;
- вместо этого в поле component прописывается функция, возвращающая динамический импорт;
- Vue Router сам вызовет эту функцию при первой навигации к маршруту.
Ленивая загрузка и nesting (вложенные маршруты)
Давайте посмотрим, как это выглядит с вложенными маршрутами, например, в админской панели:
// router.js
const routes = [
{
path: "/admin",
component: () => import("./layouts/AdminLayout.vue"),
children: [
{
path: "",
name: "AdminDashboard",
component: () => import("./pages/admin/AdminDashboardPage.vue"),
},
{
path: "users",
name: "AdminUsers",
component: () => import("./pages/admin/AdminUsersPage.vue"),
},
],
},
]
В таком случае:
- сначала при заходе на любой маршрут /admin загрузится AdminLayout.vue;
- затем подгрузится соответствующая дочерняя страница (Dashboard или Users).
Здесь я размещаю пример, чтобы вам было проще понять, как разделять код по уровням маршрутов.
Группировка чанков во Vue (Webpack)
Если вы используете Vue CLI с Webpack, вы можете использовать именование чанков:
{
path: "/admin",
component: () =>
import(/* webpackChunkName: "admin" */ "./layouts/AdminLayout.vue"),
children: [
{
path: "",
name: "AdminDashboard",
component: () =>
import(/* webpackChunkName: "admin" */ "./pages/admin/AdminDashboardPage.vue"),
},
{
path: "users",
name: "AdminUsers",
component: () =>
import(/* webpackChunkName: "admin" */ "./pages/admin/AdminUsersPage.vue"),
},
],
}
Так все админские компоненты попадут в один чанк admin.js.
Ленивая загрузка маршрутов в Angular Router
В Angular ленивую загрузку маршрутов обычно применяют не к одиночным компонентам, а к модулям (feature modules). Это несколько меняет картину.
Структура для ленивой загрузки
Частая структура:
- app.module.ts
- app-routing.module.ts
- features/
- admin/
- admin.module.ts
- admin-routing.module.ts
- profile/
- profile.module.ts
- profile-routing.module.ts
- admin/
Вы объявляете feature-модуль, который содержит свои компоненты и свой routing module.
Пример admin-routing.module.ts:
// admin-routing.module.ts
import { NgModule } from "@angular/core"
import { RouterModule, Routes } from "@angular/router"
import { AdminDashboardComponent } from "./admin-dashboard/admin-dashboard.component"
import { AdminUsersComponent } from "./admin-users/admin-users.component"
// Здесь мы описываем маршруты внутри админского модуля
const routes: Routes = [
{
path: "",
component: AdminDashboardComponent,
},
{
path: "users",
component: AdminUsersComponent,
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class AdminRoutingModule {}
И сам модуль admin.module.ts:
// admin.module.ts
import { NgModule } from "@angular/core"
import { CommonModule } from "@angular/common"
import { AdminRoutingModule } from "./admin-routing.module"
import { AdminDashboardComponent } from "./admin-dashboard/admin-dashboard.component"
import { AdminUsersComponent } from "./admin-users/admin-users.component"
// Здесь мы объявляем компоненты и подключаем роутинг для админского модуля
@NgModule({
declarations: [AdminDashboardComponent, AdminUsersComponent],
imports: [CommonModule, AdminRoutingModule],
})
export class AdminModule {}
Настройка ленивой загрузки в основном роутере
Теперь самое важное — как настроить ленивую загрузку маршрутов в корневом роутере.
В app-routing.module.ts:
// app-routing.module.ts
import { NgModule } from "@angular/core"
import { RouterModule, Routes } from "@angular/router"
// Здесь мы описываем ленивую загрузку модулей через loadChildren
const routes: Routes = [
{
path: "admin",
// Здесь мы используем dynamic import для модуля
loadChildren: () =>
import("./features/admin/admin.module").then(m => m.AdminModule),
},
{
path: "profile",
loadChildren: () =>
import("./features/profile/profile.module").then(m => m.ProfileModule),
},
{
path: "",
pathMatch: "full",
redirectTo: "home",
},
// Здесь может быть ленивый модуль для home или сразу компонент
]
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}
Теперь Angular:
- не включает код admin.module.ts и profile.module.ts в стартовый бандл;
- создает отдельные чанки для этих модулей;
- подгружает их в момент, когда пользователь впервые переходит по маршруту
/adminили/profile.
Важные нюансы в Angular
Несколько моментов, о которых часто спрашивают:
- путь в loadChildren должен быть относительным от файла роутера и без расширения файла;
- внутри then вы должны вернуть класс модуля, а не default export;
- для ленивых модулей рекомендуется использовать RouterModule.forChild, а не forRoot.
Предзагрузка и оптимизация навигации
Ленивая загрузка маршрутов ускоряет первый рендер, но может добавить небольшую задержку при первом входе на конкретную страницу. Иногда это критично, особенно на медленном интернете. Здесь на помощь приходят разные варианты предзагрузки.
Предзагрузка на idle
Один из подходов — загружать дополнительные маршруты, когда браузер «отдохнул», то есть нет активных задач. В браузерах есть API requestIdleCallback.
Пример с динамическим импортом:
// Здесь мы объявляем карту ленивых маршрутов
const lazyRoutes = {
profile: () => import("./pages/ProfilePage.js"),
settings: () => import("./pages/SettingsPage.js"),
}
// Здесь мы планируем предзагрузку при простое браузера
if ("requestIdleCallback" in window) {
window.requestIdleCallback(() => {
// Здесь мы проходимся по ленивым маршрутам и начинаем их подгружать
Object.values(lazyRoutes).forEach(loader => loader())
})
}
Что здесь происходит:
- при простое браузера запускается колбэк;
- внутри вы вызываете loaders для заранее выбранных маршрутов;
- браузер загрузит соответствующие чанки заблаговременно;
- при реальной навигации код уже будет в кэше.
Предзагрузка по user intent
Еще один интересный подход — предзагружать маршрут, когда пользователь почти наверняка собрался по нему перейти, например, навел мышь на ссылку.
Пример:
// Здесь мы навешиваем обработчик наведения на ссылки
document.addEventListener("mouseover", event => {
const link = event.target.closest("a[data-prefetch]")
if (!link) return
const href = link.getAttribute("href")
// Здесь по href мы находим нужный ленивый маршрут
if (href === "/profile") {
// Здесь мы начинаем загружать модуль страницы профиля
import("./pages/ProfilePage.js")
}
})
Подход особенно полезен на десктопе. На мобильных устройствах вместо hover иногда используют prefetch по visibility — например, при появлении ссылки в зоне видимости.
Prefetch линками
Некоторые сборщики (напрямую или через плагины) умеют автоматически вставлять теги <link rel="prefetch"> для ленивых чанков. Тогда браузер сам решает, когда их скачивать.
Вручную это тоже можно сделать:
<!-- Здесь браузер при свободных ресурсах заранее загрузит указанный файл -->
<link rel="prefetch" href="/assets/ProfilePage.[hash].js" as="script">
Но обычно лучше полагаться на автоматические механизмы сборщика, чтобы не привязываться к конкретным именам файлов.
Типичные проблемы и подводные камни
Теперь давайте посмотрим, с чем чаще всего сталкиваются разработчики, когда начинают использовать ленивую загрузку маршрутов.
1. Ошибки при загрузке чанков
Иногда сеть нестабильна, или пользователь обновляет страницу в момент деплоя, и чанк с новым хэшем становится недоступен. В результате dynamic import может завершиться с ошибкой.
Как минимум, стоит обработать ошибку и показать пользователю понятное сообщение или кнопку «Попробовать снова».
Пример на React с ErrorBoundary:
// ErrorBoundary.jsx
import React from "react"
// Здесь мы создаем классический Error Boundary для перехвата ошибок
export class RouteErrorBoundary extends React.Component {
constructor(props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError() {
// Здесь мы указываем что произошла ошибка
return { hasError: true }
}
componentDidCatch(error, errorInfo) {
// Здесь можно отправить ошибку в систему логирования
console.error("Ошибка в ленивом маршруте", error, errorInfo)
}
render() {
if (this.state.hasError) {
// Здесь показываем простой fallback интерфейс
return (
<div>
<p>Не удалось загрузить страницу</p>
<button onClick={() => window.location.reload()}>Перезагрузить</button>
</div>
)
}
return this.props.children
}
}
Использование:
// AppRouter.jsx
import { RouteErrorBoundary } from "./RouteErrorBoundary"
export function AppRouter() {
return (
<BrowserRouter>
<RouteErrorBoundary>
<React.Suspense fallback={<div>Загрузка...</div>}>
<Routes>
{/* Здесь ваши маршруты */}
</Routes>
</React.Suspense>
</RouteErrorBoundary>
</BrowserRouter>
)
}
2. SEO и серверный рендеринг
В SPA-подходе ленивая загрузка маршрутов не всегда хорошо сочетается с поисковой оптимизацией, особенно если вы рендерите все на клиенте. Если вы используете SSR (Next.js, Nuxt, Angular Universal), важно:
- убедиться, что серверная сборка тоже корректно обрабатывает ленивые компоненты;
- проверить, что критичный для SEO контент доступен без необходимости загружать дополнительные чанки после первого рендера.
В большинстве современных SSR-фреймворков поддержка ленивых маршрутов уже встроена, но иногда приходится дополнительно настраивать серверный бандл.
3. Слишком много маленьких чанков
Иногда разработчики чрезмерно увлекаются и создают десятки очень маленьких чанков по 1–2 килобайта. Каждый чанк — это отдельный HTTP-запрос, и их слишком большое количество может привести к дополнительным накладным расходам.
Рекомендации:
- анализируйте сборку (webpack-bundle-analyzer, Vite visualizer и аналоги);
- группируйте логически связанные страницы и компоненты в один чанк;
- не дробите код слишком агрессивно.
4. Сложность отладки
Когда приложение разбито на чанки, карта соответствия исходников и скомпилированного кода усложняется. Обычно sourcemaps решают проблему, но:
- убедитесь, что sourcemaps включены в дев-сборке;
- в продакшене можно оставить их с ограничением доступа, если нужны глубокие прод отладки.
Рекомендации по проектированию маршрутов с ленивой загрузкой
Теперь давайте соберем практические советы, которые помогут вам использовать ленивую загрузку маршрутов более осмысленно.
Делите по смыслу, а не механически
Лучший критерий для ленивого маршрута:
- пользователь явно переходит на эту страницу;
- страница достаточно тяжелая или редко используемая, чтобы выделять ее в отдельный чанк.
Типичные кандидаты:
- админские разделы;
- страницы отчетов и аналитики;
- сложные формы и мастера (wizard);
- справочные разделы и документация внутри приложения.
Не выносите в ленивые маршруты критически важные части UI
Например:
- шапка (header) и навигация;
- футер;
- базовая сетка страниц;
- элементы, без которых приложение не имеет смысла (например, домашняя страница, если на нее всегда попадает большинство пользователей).
Обычно их лучше оставить в стартовом бандле.
Планируйте стратегию предзагрузки
Особенно если:
- вы знаете типичные пути пользователя;
- у вас высокие требования к скорости отклика при навигации.
Подходы:
- предзагрузка при простое (idle);
- предзагрузка по hover или focus;
- предзагрузка ближайших логически следующих шагов (например, после корзины — страница оплаты).
Анализируйте результат
После включения ленивой загрузки маршрутов стоит:
- проверить размер стартового бандла до и после;
- измерить время до первого рендера (First Contentful Paint, Time to Interactive);
- убедиться, что пользовательская навигация не стала субъективно медленнее.
Инструменты:
- Lighthouse;
- DevTools вкладка Network и Performance;
- анализаторы бандла.
Заключение
Ленивая загрузка маршрутов — один из самых эффективных и при этом простых способов оптимизации производительности в SPA и крупных фронтенд-приложениях. Вместо того чтобы загружать весь код сразу, вы делите приложение на логические части и подгружаете их по мере необходимости.
В статье вы увидели:
- общие принципы ленивой загрузки маршрутов и то, как они завязаны на динамический импорт модулей;
- пример упрощенного роутера на чистом JavaScript;
- практические конфигурации для React Router с React.lazy и Suspense;
- конфигурации для Vue Router с динамическими компонентами в маршрутах;
- реализацию ленивой загрузки модулей в Angular Router;
- подходы к предзагрузке (idle, user intent, prefetch);
- типичные проблемы и способы их обхода.
Теперь, когда идея и базовые шаблоны понятны, вы можете адаптировать их под свою архитектуру, добавлять предзагрузку, объединять маршруты в чанки и постепенно улучшать скорость работы приложения.
Частозадаваемые технические вопросы по теме статьи и ответы на них
Как отложить загрузку только части компонента а не всей страницы
Иногда вам нужно лениво загрузить не страницу, а тяжелый виджет на ней. В этом случае используйте ленивые компоненты внутри страницы. В React это React.lazy для конкретного виджета, в Vue — defineAsyncComponent. Маршрут может быть обычным, а внутри компонента маршрута вы рендерите ленивый дочерний компонент. Так вы комбинируете ленивую загрузку маршрутов и ленивую загрузку отдельных частей.
Как кэшировать данные вместе с лениво загружаемым маршрутом
Код маршрута кэшируется браузером автоматически, но данные нет. Если вы хотите кэшировать ответы API для ленивых страниц, используйте слой состояния — например, React Query, Vue Query или Redux Toolkit Query. Настройте кэш с подходящим временем жизни, тогда при повторном входе на маршрут данные загрузятся из кэша, а не из сети.
Как обрабатывать обновление версии приложения когда хеши чанков поменялись
При деплое новые чанки получают другие имена, и старые ссылки на них могут ломаться. Для решения добавьте обработчик ошибки загрузки чанка. В React ErrorBoundary при ошибке dynamic import можно предложить пользователю перезагрузить страницу. На уровне сервис-воркера тоже можно реализовать стратегию «обновить и перезагрузить», когда появляется новая версия.
Можно ли лениво грузить маршруты в серверно рендеримых фреймворках типа Next.js
Да, но подход отличается. В Next.js маршруты формируются по файлам, а код сплиттинг по страницам есть по умолчанию. Дополнительно вы можете использовать dynamic с опцией ssr false для клиентовых виджетов. Важно проверять, чтобы критичный для SEO контент рендерился на сервере и не зависел от ленивых клиентских модулей.
Как организовать типизацию ленивых маршрутов в TypeScript
Основной момент — правильно типизировать функции dynamic import. В TS используйте синтаксис import для модулей с указанием типа, например type для типов и обычный import для компонентов. В Angular Router типы маршрутов и модулей уже описаны, в React и Vue вы можете типизировать лэйауты и пропсы компонентов, а также создавать отдельный тип RouteConfig с полями path и loader чтобы IDE подсказывала корректные маршруты и минимизировала опечатки.
Постройте личный план изучения Vue до уровня Middle — бесплатно!
Vue — часть карты развития Frontend
100+ шагов развития
30 бесплатных лекций
300 бонусных рублей на счет
Бесплатные лекции
Все гайды по Vue
Лучшие курсы по теме

Vue 3 и Pinia
Антон Ларичев
TypeScript с нуля
Антон Ларичев