Code splitting в Feature Sliced Design - практическое руководство

19 февраля 2026
Автор

Олег Марков

Введение

Code splitting и Feature-Sliced Design (FSD) почти всегда обсуждают вместе. В реальных SPA-приложениях именно размер бандла и скорость первой загрузки чаще всего становятся проблемой. А FSD, с его четкими слоями и модулями, будто специально создан, чтобы сделать разбиение кода предсказуемым и управляемым.

Задача в том, чтобы не просто включить динамический импорт, а встроить code splitting в архитектуру так, чтобы:

  • части приложения загружались по требованию;
  • не возникали циклические зависимости между слоями;
  • не было "магии" с роутингом и lazy-компонентами;
  • было понятно, где можно и где нельзя использовать import().

Сейчас вы увидите, как связать code splitting с уровнями FSD: app, processes, pages, widgets, features, entities, shared, и как выстроить понятные правила.


Что такое code splitting в контексте FSD

Базовая идея

Code splitting — это разбиение итогового бандла на части (chunks), которые загружаются не все сразу, а по мере необходимости. В типичном React-приложении вы используете:

  • динамические импорты import() для модулей;
  • React.lazy и Suspense для ленивых компонентов;
  • возможности бандлера (Webpack, Vite, Rollup) для генерации чанков.

В FSD важен не только сам факт "ленивая загрузка компонента", а то где именно это делать:

  • на уровне маршрутов (pages);
  • на уровне крупных виджетов (widgets);
  • при подключении тяжелых features;
  • при инициализации бизнес-процессов (processes).

Почему FSD помогает организовать code splitting

Здесь архитектура дает четкий каркас:

  • каждый срез (slice) — автономен;
  • слои строго упорядочены по направлению зависимостей;
  • публичный API каждого среза описан в index.ts.

Благодаря этому вы можете:

  • делать асинхронный импорт именно публичного API среза;
  • контролировать, какие части приложения реально нужны на старте;
  • избегать ситуаций, когда лениво загружаемый модуль неожиданно тянет за собой полпроекта.

Уровни FSD и где делать code splitting

Общие принципы

Сначала давайте сформулируем несколько практических правил:

  1. Основная точка входа app должна быть синхронной.
    Здесь лучше не использовать import() внутри index.tsx / main.tsx для базовой инициализации приложения и роутера.

  2. Основной кандидат на code splitting — слой pages.
    Страница — естественная граница, за которой можно скрыть ленивую загрузку.

  3. widgets и features можно грузить лениво, если они тяжелые или редко используются.
    Например, сложные фильтры, графики, редакторы.

  4. Слои shared и entities чаще всего загружаются синхронно.
    Это фундамент, на котором строится остальное. Слишком мелкое разбиение здесь может только навредить.

  5. processes как слой инициализации определенных сценариев тоже хороший кандидат на ленивую загрузку, особенно если процесс активируется не всегда.

Теперь давайте пройдемся по слоям подробнее.

Code splitting на уровне app

На уровне app (инициализация приложения) code splitting обычно используется для:

  • разделения "пользовательской" и "админской" части приложения по разным маршрутам;
  • вынесения аналитики, мониторинга или других второстепенных подсистем.

Пример структуры:

  • src/app/index.tsx — точка входа;
  • src/app/providers — провайдеры (router, store, theme и т.п.);
  • src/app/routes — конфигурация роутинга;
  • src/app/config — конфигурация приложения.

Здесь чаще всего лениво загружают именно страницы, а не само приложение.


Code splitting на уровне страниц (pages)

Маршруты как естественная граница чанков

Каждая страница в FSD — отдельный срез (slice) уровня pages. Пример структуры:

  • src/pages/home
  • src/pages/profile
  • src/pages/admin

Каждая страница экспортирует публичный компонент из index.ts:

// src/pages/home/index.ts
export { HomePage } from './ui/HomePage'

Теперь вы можете лениво подключить эту страницу в роутере.

Пример с React Router и React.lazy

Смотрите, я покажу вам, как это может выглядеть в app/routes:

// src/app/routes/config.tsx
import { Suspense, lazy } from 'react'
// Здесь можно импортировать базовые компоненты синхронно
import { Loader } from '@/shared/ui/loader'

// Ленивый импорт страниц через публичный API среза
const HomePageLazy = lazy(() =>
  import('@/pages/home').then((module) => ({
    // Явно указываем, какой экспорт использовать как default
    default: module.HomePage,
  }))
)

const ProfilePageLazy = lazy(() =>
  import('@/pages/profile').then((module) => ({
    default: module.ProfilePage,
  }))
)

export const routes = [
  {
    path: '/',
    element: (
      <Suspense fallback={<Loader />}>
        <HomePageLazy />
      </Suspense>
    ),
  },
  {
    path: '/profile',
    element: (
      <Suspense fallback={<Loader />}>
        <ProfilePageLazy />
      </Suspense>
    ),
  },
]

Комментарии к коду:

// lazy(() => import(...)) - создаст отдельный чанк для каждой страницы
// Suspense с fallback - показывает Loader пока соответствующий чанк загружается
// .then((module) => ({ default: module.HomePage })) - нужно, если страница не экспортируется как default

Где хранить логику lazy-загрузки страниц

Хорошая практика в FSD — не смешивать логику роутинга и ленивой загрузки с самими страницами. Для этого часто создают отдельный "роутинг-слайс" или модуль:

  • src/app/routes
    • ui/AppRouter.tsx
    • config/routes.tsx
    • lib/lazy.ts

Можно вынести повторяющийся код обертки для lazy:

// src/app/routes/lib/lazy.ts
import { ComponentType, LazyExoticComponent, lazy } from 'react'

type Loader<T> = () => Promise<{ default: ComponentType<T> }>

// Здесь мы создаем утилиту для ленивой загрузки компонентов
export function lazyImport<T extends ComponentType<any>>(
  loader: Loader<T>
): LazyExoticComponent<T> {
  return lazy(loader)
}

И использовать так:

// src/app/routes/config.tsx
import { lazyImport } from './lib/lazy'
import { Loader } from '@/shared/ui/loader'
import { Suspense } from 'react'

const HomePageLazy = lazyImport(() =>
  import('@/pages/home').then((m) => ({ default: m.HomePage }))
)

export const routes = [
  {
    path: '/',
    element: (
      <Suspense fallback={<Loader />}>
        <HomePageLazy />
      </Suspense>
    ),
  },
]

Так вы разделяете:

  • страницу (pages/home) — без знания о lazy;
  • роутинг (app/routes) — занимается code splitting.

Code splitting для widgets

Когда имеет смысл лениво грузить виджеты

В FSD widgets — это крупные композиции, которые могут стоять на нескольких страницах: хедер, сайдбар, дашборды. Ленивую загрузку здесь стоит использовать, когда:

  • виджет тяжелый (много логики, графики, сложные таблицы);
  • виджет не нужен сразу на первой отрисовке;
  • он появляется по действию пользователя (поисковые панели, модальные окна, сложные фильтры).

Например, у вас есть дашборд с большим количеством графиков:

  • src/widgets/analytics-dashboard

И на странице вы хотите показать его не сразу, а только после выбора периода или на отдельной вкладке.

Пример ленивого виджета

Сначала определим публичный API виджета:

// src/widgets/analytics-dashboard/index.ts
export { AnalyticsDashboard } from './ui/AnalyticsDashboard'

Теперь страница может лениво загрузить этот виджет:

// src/pages/home/ui/HomePage.tsx
import { Suspense, lazy, useState } from 'react'
import { Button } from '@/shared/ui/button'
import { Loader } from '@/shared/ui/loader'

// Ленивый импорт тяжелого виджета
const AnalyticsDashboardLazy = lazy(() =>
  import('@/widgets/analytics-dashboard').then((m) => ({
    default: m.AnalyticsDashboard,
  }))
)

export function HomePage() {
  const [showDashboard, setShowDashboard] = useState(false)

  return (
    <div>
      <h1>Главная страница</h1>

      <Button
        onClick={() => setShowDashboard(true)}
      >
        Показать аналитику
      </Button>

      {showDashboard && (
        <Suspense fallback={<Loader />}>
          <AnalyticsDashboardLazy />
        </Suspense>
      )}
    </div>
  )
}

Обратите внимание:

// Аналитический дашборд не попадает в начальный бандл
// Чанк с виджетом загрузится только когда пользователь нажмет на кнопку
// Это особенно полезно для графиков, дешбордов, редакторов

Code splitting для features

Сценарные возможности как отдельные чанки

features в FSD — это законченные пользовательские сценарии: "лайкнуть пост", "добавить в корзину", "сменить язык интерфейса".

Не все features нужны сразу. Например:

  • feature/admin-ban-user — нужен только админам;
  • feature/edit-profile — нужен только на странице профиля;
  • feature/advanced-search — используется реже, чем обычный поиск.

Пример: ленивое подключение feature в виджете

Представьте виджет профиля пользователя:

  • src/widgets/user-profile
    В нем вы хотите подключить feature edit-profile, но только по нажатию кнопки "Редактировать".

Публичный API feature:

// src/features/edit-profile/index.ts
export { EditProfileForm } from './ui/EditProfileForm'

Реализация виджета:

// src/widgets/user-profile/ui/UserProfileCard.tsx
import { Suspense, lazy, useState } from 'react'
import { Button } from '@/shared/ui/button'
import { Loader } from '@/shared/ui/loader'

// Ленивый импорт фичи
const EditProfileFormLazy = lazy(() =>
  import('@/features/edit-profile').then((m) => ({
    default: m.EditProfileForm,
  }))
)

type Props = {
  userId: string
}

export function UserProfileCard({ userId }: Props) {
  const [isEditing, setIsEditing] = useState(false)

  return (
    <div>
      {/* Здесь мы показываем данные пользователя */}
      {/* ... */}

      <Button onClick={() => setIsEditing(true)}>
        Редактировать профиль
      </Button>

      {isEditing && (
        <Suspense fallback={<Loader />}>
          <EditProfileFormLazy userId={userId} />
        </Suspense>
      )}
    </div>
  )
}

Комментарии:

// Виджет знает о фиче только через публичный API
// Форма редактирования не загружается, пока пользователь не включит режим редактирования
// Это позволяет разгрузить бандл, если большинство пользователей не редактируют профиль

Разграничение прав и code splitting

Частая задача — разделить "обычную" и "админскую" функциональность. В FSD можно сделать:

  • отдельные pages для админских экранов;
  • отдельные features admin-*, которые лениво загружаются только при наличии прав.

Пример:

// src/widgets/user-actions/ui/UserActions.tsx
import { Suspense, lazy } from 'react'
import { useUserRole } from '@/entities/user'
import { Loader } from '@/shared/ui/loader'

// Админская фича
const BanUserButtonLazy = lazy(() =>
  import('@/features/admin-ban-user').then((m) => ({
    default: m.BanUserButton,
  }))
)

export function UserActions() {
  const role = useUserRole()

  return (
    <div>
      {/* Обычные действия доступны всем */}
      {/* ... */}

      {role === 'admin' && (
        <Suspense fallback={<Loader />}>
          <BanUserButtonLazy />
        </Suspense>
      )}
    </div>
  )
}

Здесь важно:

// Код админской фичи не попадет к обычным пользователям
// Чанк будет подгружен только если роль - admin

Что с entities и shared слоями

Когда не стоит делать code splitting

shared и entities часто содержат:

  • типы и модели данных;
  • простые UI-компоненты (кнопки, инпуты, типовые лейауты);
  • базовые хелперы, API-клиенты и т.п.

Если начать дробить их агрессивно, вы получите:

  • множество мелких чанков;
  • лишние сетевые запросы;
  • и минимальный выигрыш по весу.

Поэтому общее практическое правило:

  • shared и entities почти всегда загружаются синхронно, без import().

Исключения

Иногда есть действительно тяжелые вещи в shared:

  • визуальные редакторы (draw, rich text);
  • большие сторонние библиотеки для графиков;
  • сложные валидаторы или конвертеры файлов.

В таком случае их можно выделить в отдельные "подсрезы" и грузить лениво. Но важно:

  • не смешивать такие модули с базовыми типами и утилитами;
  • явно помечать такие модули как "heavy" (например, в названии каталога).

Code splitting и processes в FSD

Что такое processes и зачем тут code splitting

Слой processes используется для описания сквозных процессов приложения:

  • "оформление заказа";
  • "регистрация и онбординг пользователя";
  • "прохождение курса" и т.п.

Каждый такой процесс объединяет несколько pages, widgets, features и entities. Это естественный кандидат для ленивой загрузки, если процесс:

  • запускается не всегда;
  • сильно нагружен логикой и внешними зависимостями.

Пример: ленивый процесс оформления заказа

Структура:

  • src/processes/checkout
    • model — состояние процесса (stepper, прогресс, валидации);
    • ui — общий контейнер для процесса;
    • config — конфигурации шагов.

Публичный API:

// src/processes/checkout/index.ts
export { CheckoutProcess } from './ui/CheckoutProcess'

Подключение на странице:

// src/pages/cart/ui/CartPage.tsx
import { Suspense, lazy, useState } from 'react'
import { Button } from '@/shared/ui/button'
import { Loader } from '@/shared/ui/loader'

// Ленивый импорт процесса
const CheckoutProcessLazy = lazy(() =>
  import('@/processes/checkout').then((m) => ({
    default: m.CheckoutProcess,
  }))
)

export function CartPage() {
  const [isCheckoutStarted, setIsCheckoutStarted] = useState(false)

  return (
    <div>
      {/* Здесь мы отображаем содержимое корзины */}
      {/* ... */}

      <Button onClick={() => setIsCheckoutStarted(true)}>
        Оформить заказ
      </Button>

      {isCheckoutStarted && (
        <Suspense fallback={<Loader />}>
          <CheckoutProcessLazy />
        </Suspense>
      )}
    </div>
  )
}

Здесь вы видите:

// Пользователь может просматривать корзину без загрузки всего процесса checkout
// Как только он начинает оформление - соответствующий чанк подгружается
// Это уменьшает initial bundle, особенно если checkout сложный (интеграции с платежами и т.д.)

Организация public API с учетом code splitting

Почему важно иметь чистый публичный API

В FSD все взаимодействия между срезами идут через их публичный API. Для code splitting это критично:

  • ленивый импорт должен указывать на минимальный и "чистый" модуль;
  • не стоит тянуть "все и сразу" только потому, что index.ts экспортирует слишком много.

Если index.ts у среза экспортирует десятки компонентов и хелперов, вы можете:

  • создать отдельный entrypoint, например index.lazy.ts, где публичный API будет легче;
  • или выделить тяжелые подмодули в отдельные срезы.

Пример разделения публичного API

Предположим, у вас есть feature edit-article, и вы хотите использовать:

  • маленькие утилиты в синхронном режиме;
  • саму форму редактирования — лениво.

Можно сделать так:

// src/features/edit-article/index.ts
// Синхронный API - легкие вещи
export { getArticleDiff } from './lib/getArticleDiff'
export { useEditPermissions } from './model/useEditPermissions'
// src/features/edit-article/index.lazy.ts
// Лениво используемый публичный API
export { EditArticleForm } from './ui/EditArticleForm'

Теперь страница может работать так:

// src/pages/article/ui/ArticlePage.tsx
import { Suspense, lazy } from 'react'
import { getArticleDiff } from '@/features/edit-article'
import { Loader } from '@/shared/ui/loader'

// Ленивый импорт формы
const EditArticleFormLazy = lazy(() =>
  import('@/features/edit-article/index.lazy').then((m) => ({
    default: m.EditArticleForm,
  }))
)

export function ArticlePage() {
  // Здесь мы можем использовать getArticleDiff синхронно
  // ...

  return (
    <Suspense fallback={<Loader />}>
      <EditArticleFormLazy />
    </Suspense>
  )
}

Такой подход помогает:

  • не раздувать ленивый чанк лишними утилитами;
  • не тащить форму туда, где нужны только функции из lib или model.

Ограничения и правила импортов при code splitting

Запреты и уровневые ограничения

FSD предполагает строгие правила импортов:

  • нижние слои (shared, entities) не могут импортировать верхние (features, widgets, pages, app);
  • горизонтальные импорты между срезами одного уровня тоже контролируются.

Code splitting не отменяет этих правил. Даже если вы используете import(), нужно:

  • соблюдать направленность зависимостей;
  • не делать динамические импорты "вверх".

Плохой пример:

// src/entities/user/model/useUser.ts
// ПЛОХО - сущность тянет фичу
const changePasswordFeature = () =>
  import('@/features/change-password')

Здесь вы нарушаете архитектуру:

  • сущность user зависит от feature change-password;
  • даже если это ленивый импорт, слои перепутаны.

Как проверять нарушения

Часто в проектах с FSD используют ESLint-правила, которые:

  • запрещают импорты "сверху вниз" по слоям;
  • проверяют импорты между срезами одного уровня.

Важно настроить правила так, чтобы:

  • они учитывали и статические, и динамические импорты;
  • import() тоже анализировался линтером.

На уровне конфигурации линтера обычно достаточно:

  • включить правило, которое смотрит на строки с import(;
  • или использовать кастомное правило, адаптированное под FSD.

Интеграция code splitting с бандлером

Webpack

В Webpack поддержка dynamic import встроена:

  • import('module') создает отдельный чанк;
  • можно настраивать имена чанков через комментарии.

Пример:

// Здесь мы подсказываем Webpack имя чанка через webpackChunkName
const ProfilePageLazy = lazy(() =>
  import(
    /* webpackChunkName: "page-profile" */
    '@/pages/profile'
  ).then((m) => ({
    default: m.ProfilePage,
  }))
)

Комментарии:

// webpackChunkName - имя файла чанка будет включать "page-profile"
// Это удобно для анализа бандла и дебага

Вы можете договориться в команде о паттернах имен:

  • page-* — для страниц;
  • widget-* — для виджетов;
  • feature-* — для фич;
  • process-* — для процессов.

Это хорошо сочетается с FSD, потому что:

  • названия чанков отражают архитектуру;
  • можно быстро понять, какой слой "тяжелый".

Vite и Rollup

В Vite и Rollup тоже есть поддержка dynamic import. Синтаксис такой же, но:

  • именование чанков может отличаться;
  • конфигурация разделения кода чаще основывается на "manualChunks".

Для FSD-проекта можно настроить "manualChunks" так, чтобы:

  • объединять или разделять чанки по уровням;
  • например, делать отдельный чанк vendor и отдельные чанки для страниц.

Просто ориентируйтесь на то, что архитектура FSD должна быть видна и в структуре чанков.


Антипаттерны и типичные ошибки

1. Лениво грузится "все подряд", без границ

Иногда разработчики начинают добавлять import() "везде, где получится". В итоге:

  • десятки мелких чанков;
  • каждый переход по странице порождает новые запросы;
  • профит по размеру бандла минимален.

Хорошая стратегия:

  • выбирать крупные, естественные границы — pages, тяжелые widgets, отдельные processes;
  • не дробить слишком мелко внутри shared и entities.

2. Логика ленивой загрузки внутри среза

Еще одна частая ошибка — писать React.lazy прямо внутри pages или features без выноса в app/routes или "композиционные" места. Например:

// src/pages/profile/ui/ProfilePage.tsx
// ПЛОХО - страница сама решает, как ее грузить
const ProfilePage = lazy(() => import('./ProfilePage'))

Лучше:

  • делать страницу обычным синхронным компонентом;
  • решать вопрос ленивой загрузки "снаружи" — в роутере или верхнем слое.

Так вы можете:

  • переиспользовать страницу в тестах, storybook без Suspense;
  • изменять политику загрузки, не трогая саму страницу.

3. Ленивые импорты в глубине базовых слоев

Проблемная ситуация:

  • в shared или entities появляются ленивые импорты верхних слоев;
  • линтер не настроен и это остается незамеченным.

В результате:

  • слои начинают "тянуть" друг друга;
  • возникает путаница, что за кем зависит.

Здесь важно:

  • жестко соблюдать правило "shared и entities ничего не знают о верхних слоях";
  • любые ленивые импорты использовать только "сверху вниз".

Практический чек-лист по code splitting в FSD

Давайте подытожим в виде списка, который вы можете использовать в своем проекте.

Стратегия по слоям

  • app
    • Не делать ленивую загрузку самой инициализации.
    • Настроить роутер так, чтобы страницы грузились лениво.
  • pages
    • Каждая страница — отдельный чанк.
    • Логика ленивой загрузки находится в слое app или в модуле роутинга.
  • widgets
    • Ленивая загрузка тяжелых или условно отображаемых виджетов.
    • Не злоупотреблять дроблением виджетов на мелкие чанки.
  • features
    • Ленивая загрузка фич, которые:
      • используются редко;
      • завязаны на права доступа;
      • сильно увеличивают бандл.
  • processes
    • Ленивая загрузка сложных бизнес-процессов.
    • Подключение процесса по явному действию (клик, переход на шаг).

Технические правила

  • Всегда использовать публичный API среза (index.ts или отдельный entrypoint) как цель import().
  • Соблюдать направленность зависимостей, даже при dynamic import.
  • Окружать ленивые компоненты Suspense с осмысленным fallback.
  • По возможности именовать чанки в соответствии со слоями и срезами.

Частозадаваемые технические вопросы

Как избежать дублирования кода при ленивой загрузке разных страниц, которые используют одни и те же widgets и features

Часто разработчики боятся, что ленивые страницы потянут дубликаты кода. Бандлеры обычно умеют выносить общие зависимости в отдельные чанки автоматически. Важно:

  1. Следить, чтобы общие компоненты жили в shared или entities, а не копировались в pages.
  2. Не делать отдельный node_modules внутри каждого среза.
  3. В Webpack можно настроить splitChunks так, чтобы общие модули попадали в общий чанк vendors или common.

Как тестировать лениво загружаемые компоненты в unit-тестах

В тестах вам не нужен реальный import() и Suspense. Можно:

  1. Импортировать компонент напрямую из его модуля, минуя React.lazy.
  2. Для тестов роутера замокать ленивый импорт:
    • использовать jest.mock или аналогичный механизм;
    • подменять его на синхронный компонент.
  3. В обертке для роутера можно сделать флаг "test mode", при котором вместо lazy используется синхронный импорт.

Как правильно типизировать lazy-компоненты в TypeScript

Иногда TS теряет типы пропсов у LazyExoticComponent. Чтобы этого избежать:

  1. Опишите тип компонента заранее: ts type EditProfileFormProps = { userId: string }
  2. Используйте этот тип в lazyImport: ts const EditProfileFormLazy = lazy( () => import('./EditProfileForm') as Promise<{ default: React.ComponentType<EditProfileFormProps> }> )
  3. Если используете свою утилиту lazyImport, передавайте туда дженерики с типами пропсов.

Как подружить code splitting с SSR и FSD

При SSR динамический импорт усложняется. Общий подход:

  1. На сервере использовать библиотеку, которая поддерживает асинхронные чанки (например, @loadable/component).
  2. Слои FSD при этом не меняются, меняется только "обертка" вокруг ленивых компонентов.
  3. Настроить генерацию "манефеста чанков", чтобы на сервере можно было определить, какие файлы подключить в HTML.

Что делать, если ленивый чанк загружается слишком долго и пользователь видит только спиннер

Если время загрузки чанка заметно, можно:

  1. Использовать "скелетон" вместо простого спиннера, чтобы интерфейс выглядел более стабильным.
  2. Подгружать часть кода заранее:
    • использовать import('./module') без ожидания результата как прелоад;
    • вызывать это при "наведении" на ссылку или при появлении в зоне видимости.
  3. Разделить особенно тяжелый срез на несколько подмодулей, чтобы первый чанк был меньше и грузился быстрее.
Стрелочка влевоЧастые ошибки при использовании функции append в Go

Все гайды по Feature-sliced_design

Открыть базу знаний

Отправить комментарий