Главная / Статьи

Дизайн с разделением на компоненты для React: слои, правила импорта и когда остановиться

Узнайте, как слои концепции Feature-Sliced Design, правило однонаправленного импорта, упрощённые публичные API и механизмы кросс-импорта @x помогают разобраться в кодовых базах React, а также когда стоит применять только часть этих подходов.

Издатель: REACTAPP.TOP7 мин чтения1715 слов

Источники: Feature-Sliced Design in React: How to Structure a Scalable Codebase (and When It’s Overkill)

Почти в каждом проекте на React, который просуществовал более двух лет, есть папка utils/, которую никто не хочет открывать. Внутри находятся форматтер дат, обертка для повторных попыток, половина машины состояний и файл helpers.ts, который импортирует элементы из трех функциональных папок, в то время как две из этих папки импортируют его обратно, поэтому никто не может сказать, что зависит от чего. Методология Filament-Sliced Design (FSD) направлена именно на решение этой проблемы. Прочитав это, вы узнаете о её шести слоях, едином правиле импорта, которое выполняет основную работу, о том, как определять границы функциональных блоков для безопасной рефакторизации, и, что самое важное, о том, как решить, стоит ли для вашего приложения использовать полную версию FSD или лучше выбрать более простую её версию.

Почему папки, основанные на типах, мешают масштабированию

Классическая структура группирует файлы по их типу (компоненты, хуки, утилиты), а не по их назначению. Это подходит для небольшого приложения, но как только появляются реальные бизнес-домены, структура начинает выглядеть так:

src/
 components/ # 200 files, no domain grouping
 hooks/ # useAuth next to useCartTotals
 utils/ # the grab-bag
 services/ # api calls that import from utils, which imports back

В структуре подобного типа разрушаются два фактора. Во-первых, на вопрос «Куда должен попасть этот файл?» нет четкого ответа, поэтому всё неоднозначное оказывается в папке utils/. Во-вторых, отсутствие ограничений на направление зависимостей приводит к образованию циклов. Представьте, что сервис обработки заказов подключает utils/user.ts, этот утилитный модуль зависит от services/auth.ts, а сервис авторизации снова импортирует сервис обработки заказов. Такие циклы проявляются незаметно; обычно они становятся очевидны гораздо позже в виде значения undefined, когда модуль впервые загружается, поскольку один из файлов в цикле обрабатывался раньше, чем завершила работу модель, от которой он зависит, и к тому моменту структура зависимостей становится слишком запутанной для понимания.

FSD решает оба этих вопроса, придавая всему проекту определенную структуру вместо того, чтобы полагаться на привычки отдельных разработчиков. На момент написания текста текущей версией спецификации является v2.1; для узнания о последующих изменениях обратитесь к официальной документации.

Шесть уровней и содержимое каждого из них

FSD структурирует код по уровням в фиксированном порядке, сверху вниз: App, Pages, Widgets, Features, Entities, Shared. Существует также более старый уровень Processes, но он устарел и может быть проигнорирован. Этот порядок не является чисто декоративным; он определяет, кто может зависеть от кого.

  • App: маршрутизация, точки входа, глобальные стили и поставщики данных.
  • Pages: полные страницы или крупные разделы интерфейса, связанные с определенным маршрутом.
  • Widgets: крупные, самодостаточные блоки интерфейса, способные сами по себе реализовывать целый функционал.
  • Функции: реализации функциональности продукта, которые используются в нескольких местах.
  • Энтитеты: бизнес-концепции, с которыми работает проект, такие как пользователь или продукт.
  • Общий код: код, который можно использовать повторно и не требует знаний бизнес-логики, включая систему дизайна и универсальные помощники.
  • Четыре средних уровня (Pages до Entities) делятся на сегменты, по одному на каждую область бизнеса, например entities/user или features/auth. Уровни App и Shared не имеют сегментов. Каждый сегмент затем делится на фрагменты в соответствии с технической ролью: ui — для компонентов, api — для типов запросов и ответов сервера, model — для схем и хранилищ, lib — для помощников, используемых только внутри данного сегмента, и config.

    Правило импорта, исключающее циклы

    Правило, лежащее в основе всей системы, довольно простое: модуль может импортировать данные только из слоев, строго находящихся ниже его собственного. Страница может использовать ту или иную функцию, но функция никогда не может использовать страницу. Кроме того, элементы на одном и том же слое вообще не могут импортировать данные друг у друга. Внутри одного элемента файлы могут свободно ссылаться друг на друга; ограничение действует при переходе между элементами и слоями.

    Применим это правило к ранее описанному циклу. После переразделения на элементы логика порядка находится в entities/order, логика пользователя — в entities/user, а процесс аутентификации — в features/auth. Разрешенные зависимости становятся следующими:

    features/auth → can import entities, shared
    entities/order → can import shared only
    entities/user → can import shared only
    

    Если теперь entities/order попытается импортировать элементы из features/auth, это будет нарушением структуры архитектуры, а не просто вопросом стиля. Сформировать цикл невозможно, поскольку каждая связь в графе зависимостей направлена вниз. Это также меняет подход к анализу кода: вместо пошагового изучения импортов файл за файлом вы рассматриваете код через призму шести слоев, что является моделью, которую человек действительно может запомнить.

    Публичные API сохраняют внутренности слоев в тайне

    Каждый слой предоставляет публичный API, обычно файл index.ts, который переимпортирует элементы, доступные для использования извне. В документации FSD четко сформулирована цель: открывать только необходимое и скрывать детали реализации.

    Очень распространенная версия такого файла сводит на нет эту цель:

    // features/auth/index.ts (the anti-pattern)
    export * from "./ui";
    export * from "./model";
    

    Шаблоны-заменители экспортируют всё, включая model/tokenStore.ts, который должен был оставаться приватным. Рано или поздно какая-нибудь страница начнёт импортировать хранилище токенов напрямую, и после этого будет невозможно изменить его структуру без нарушения работы какого-то приложения, о существовании которого вы даже не знали. Кроме того, использование шаблонов-заменителей может затруднить процесс оптимизации кода с помощью инструментов типа bundlers, в результате чего ненужный код может попасть к пользователям.

    Альтернативный вариант предусматривает явный перечень всех экспортируемых элементов:

    // features/auth/index.ts (the boundary)
    export { LoginForm } from "./ui/LoginForm";
    export { useSession } from "./model/useSession";
    // tokenStore stays internal. Nobody outside auth can touch it.
    

    Теперь этот компонент имеет ровно два публичных имени. Всё остальное внутри него можно переименовать, разделить или переписать без влияния на вызывающие его части кода. Именно сочетание читаемой диаграммы зависимостей и свободы изменения внутренней структуры является основным преимуществом, обещаемым FSD.

    В документации FSD упоминается один важный недостаток: файлы index могут приводить к циклическим импортам, вызывать проблемы с оптимизацией кода в папках shared/ui и shared/lib, а также замедлять работу инструментов сборки в крупных проектах. Практическое решение заключается в том, чтобы делать публичные API максимально явными и минималистичными, а также проявлять особую осторожность в разделе Shared, где многие небольшие, независимые модули лучше импортировать напрямую, чем через один большой файл.

    Межимпорты, чрезмерная фрагментация и использование инструментов проверки кода

    Обозначение @x для элементов, которые взаимно ссылаются друг на друга

    Некоторые сущности действительно зависят друг от друга; например, заказ требует типа User. Поскольку импорты внутри одного уровня запрещены, в версии v2.1 была стандартизирована нотация @x именно для таких случаев. Слой user предоставляет специальную точку входа, предназначенную для слоя order, и order импортирует данные из неё:

    // entities/user/@x/order.ts
    export type { User } from "../model/types";
    
    // in entities/order/model/types.ts
    import type { User } from "entities/user/@x/order";
    

    Название файла ясно показывает связь между сущностями и облегчает поиск: entities/user/@x/order.ts — это та часть слоя user, которую может видеть слой order. В документации это рассматривается как исключение ограниченного характера. Сокращайте количество межслоевых импортов до минимума и используйте @x только на уровне Entities. Если вам приходится использовать его по всему кодбазе, это означает, что сегменты вашего кода разделены неправильно.

    Флаги Steiger отслеживают как нарушения, так и чрезмерную сложность реализации

    Правила полезны только тогда, когда они соблюдаются. Steiger — инструмент проверки архитектуры для FSD (его правила поставляются как @feature-sliced/steiger-plugin), работает без настройки и на момент написания статьи все еще находился в бета-версии. Помимо проверки правил импорта, он противодействует чрезмерной сложности архитектуры: правило insignificant-slice активируется, когда функция или элемент используется только на одной странице, а правило excessive-slicing указывает на слишком мелкое разделение проекта на части. Если Steiger сообщает о незначительном разделении, прислушайтесь к совету: вы создали папку, вместо которой хватило бы просто локальной функции.

    Когда полная версия FSD — избыточность

    Названия папок — это наименее важная часть FSD. Копирование всех шести уровней в небольшое приложение создаёт своего рода беспорядок: больше файлов, чем логики, причём каждая функция разбросана по четырём каталогам, из-за чего простая замена превращается в поиск.

    В спецификации это учтено, и поэтому v2.1 рекомендует подход «сначала страницы» при внедрении. Начните с Приложения, Страниц, Виджетов и Общего доступа и подумайте о том, чтобы остановиться на этом. Для многих приложений это уже является полной и понятной структурой.

    Перемещайте код в разделы Features и Entities только тогда, когда есть конкретная причина. Полезным подходом является ожидание, пока одна и та же логика потребуется на трех или более страницах/виджетах, прежде чем выделять ей отдельный раздел. До этого момента правильным выбором будет использование процесса оформления заказа, полностью находящегося внутри папки pages/checkout, а не какой-либо ускоренный вариант. Однократное повторное использование может быть случайностью; три случая указывают на реальную закономерность, и именно для представления таких повторяющихся шаблонов существует слой Features.

    Если вы также рассматриваете вариант FSD по сравнению с более простыми структурами папок или логикой, основанной на доменах, наше сравнение шаблонов структуры папок в React дает подробный анализ всех альтернатив.

    Краткий чек-лист для внедрения

    • Полностью внедрите FSD, когда команда превысит несколько инженеров, количество бизнес-доменов продолжит расти, и папка utils/ уже превратится в свалку кода.
    • Для небольшого продукта с одним явным владельцем сохраняйте правило импорта вниз и сокращайте объем публичных API, избегая лишних уровней структуры.
    • Пишите каждый файл index.ts в виде явного списка экспортируемых элементов; избегайте использования export *.
    • Резервируйте суффикс @x для ссылок между объектами и рассматривайте его частое использование как проблему границ определений.
    • Используйте инструменты проверки кода, такие как Steiger, чтобы правила и предупреждения о ненужных фрагментах кода применялись автоматически.

    Заключение

    Шесть папок никогда не были целью. На самом деле FSD предоставляет граф зависимостей, в котором поток данных идёт только в одном направлении, а структура компонентов скрыта за небольшими контрактами, которые в совокупности помогают сохранять код понятным даже через год. Большую часть этих преимуществ можно получить уже с помощью правил импорта и публичных API, добавляя дополнительные слои лишь тогда, когда это действительно необходимо из-за потребности в повторном использовании.