Как мы приготовили Feature-Sliced Design в VK

8d1d25ccedd480cf65596d38e2185060.jpg

Всем привет! Меня зовут Дмитрий, я Frontend-разработчик в VK. В этой статье расскажу немного о том, как мы знакомились с архитектурой FSD (Feature-Sliced Design), как мы рефакторили свой проект под неё. И, самое главное, что из этого вышло. Постараюсь заинтересовать вас, чтобы и вы смело её внедряли в свои проекты. FSD — это, пожалуй, то, чего так не хватало в Frontend-мире.

Для тех, кто еще не слышал о FSD. Это не библиотека, не фреймворк. Это архитектурная методология. То есть свод некоторых правил и советов, как грамотно оформить код в приложении, чтобы потом не запутаться в нем и не сломать ничего при внесении очередных правок. 

Главная цель FSD — упорядочить и структурировать код от простого к сложному. 

Более подробно останавливаться на описании самой методологии не буду, потому что у меня не получится рассказать лучше, чем это удалось в документации по FSD. Да и раздувать статью пересказом не хочется. Поэтому, перед прочтением этой статьи, рекомендую ознакомиться с докой.

Исходные данные.

Итак, мы начали переезд на FSD с одного из наших проектов. Это простой (пока что) личный кабинет для управления процессами. Помимо специальных разделов в нем есть довольно типовой — управление пользователями. Есть и страницы списков, формы и редактирования ролей и приглашения пользователя — все что нужно для полноценного примера.

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

  • TypeScript

  • React

  • Next.js — роутинг

  • Redux-toolkit — стейт менеджмент

  • axios — API-клиент

  • formik + yup для форм

  • react-table — для таблиц

  • VKUI — ui-framework

Структура проекта примерно такова:

fbc4f6edaccaabc424ab088185f922fe.png

У нас изначально было разделение согласно бизнес-логике приложения, что упростило переезд на FSD. Основная рабочая директория — это features/project. Её структура фрактальна и зеркальна навигации приложения. Список пользователей находится тут:

features/project/credentials/users

А форма редактирования пользователя лежит во вложении к списку:

features/project/credentials/users/edit

Каждая такая фича состоит из «суб»-фич и более-менее стандартного набора модулей, назначение которых должно быть понятно без особых пояснений.

В редких случаях, при необходимости, те модели и прочие модули, которые используются в других разделах выносятся в features/common. Сюда же попадают различные виджеты, вроде шапки и подвала страницы, меню и т.д.

По некоторым причинам в features/common не попал UIkit приложения — логика в основном была в том, чтобы можно было бы этот UIkit легко скопировать в другой проект.

По другим причинам в ту же feature-common не попала и свалка helper’ов (папка utils) — туда были сосланы слишком абстрактные методы и слишком мелкие функции, которые нельзя назвать полноценными фичами.Выделены были в отдельные абстракции:

  • API (/network);

  • store — тут корневой combineReducer и инициализация RootState;

  • layouts — Layout’ы страниц NextJS;

  • styles — некоторые абстрактные глобальные стили, в основном «reset».

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

Да, пока в проекте трудится небольшая команда, да и масштаб приложения еще мал, следить за этим обилием директорий и поддерживать в них порядок было довольно просто. Но все изменится, когда связность приложения начнет расти и все больше моделей будет мигрировать в features/common, превращая её в свалку. Или когда придут новые разработчики и не поняв замысла «архитектора» начнут лепить что-то своё.

Недостатки были очевидны:

  • отсутствие формализованной методологии

  • отсутствие  единого способа деления модулей и четких границ деления кодовый базы — что-то в utils, что-то в ui, а что-то в features/common.

  • слабая масштабируемость приложения.

  • частичное дублирование структуры pages (Next.js) и features/project, которое было по архитектурным причинам только именно частичное, и это несколько путало.

  • растущая связность приложения.

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

Next.js vs FSD

В плане связки Next.js + FSD существует сразу несколько проблем. Как общих, так и весьма специфичных. Кто-то в интернетах даже высказывал мнение, что Next.js это не про FSD. Но на самом деле их можно подружить.

Первый камень — что делать с директорией pages? По FSD она должна иметь стандартный вид — слайсы и сегменты. Для последнего pages — это зарезервированное имя и структура этой папки должна соответствовать структуре страниц приложения. Это одна из ключевых механик роутинга в Next.js. Вариантов решения несколько, но мы пошли по самому безболезненному и на наш взгляд оптимальному: мы просто перенесли всю кодовую базу внутрь директории src, а все что касается Next.js оставили в корне проекта.

Помимо прочего, такой шаг нам позволил решить и другую проблему — куда девать Layout’ы страниц?

Доки FSD прямым текстом говорят, что страничная шапка и главное меню — это слой Widget. А страницы — это слой pages. Это два соседних слоя. У нас не остается «места» для композиции виджетов в шаблон страницы.

Варианты были разные, но в контексте Next.js логичнее и удобнее оставить Layout в слое страниц. Да, согласен, это не очень вяжется со здравым смыслом. Но мы сгруппировали эти слайсы в одной директории (что FSD разрешает и поощряет) и они там никому не мешают и нам не пришлось прибегать к кросс-импортам. А саму композицию шаблонов со страницами, как понимаете, мы делаем механизмами Next.js, которые вынесены за пределы FSD.

e9698a40994050ca2aceeda38b926fd2.png

Третий бонус от такого решения — низкая связность с самим Next.js. Да, при желании можно будет вообще от него отказаться и почти безболезненно переехать на другие рельсы, заменив только модули роутинга и i18n.

Два других способа поженить Next.js с FSD — переименовать слой pages во что-то другое или формировать его по структуре Next.js, но и в том и другом случае мы ломаем идеологию FSD.

Слой Shared: что это и с чем его едят

Вторым делом мы занялись наполнением слоя shared. Казалось бы это самый простой слой, но правки в нем влияют на весь проект.

В него улетели сходу и UIkit и utils и даже некоторая часть /features/common (те модули, что не относились к бизнес-логике, например Нотификация, Глобальный Лоадер). Вслед за ними были перенесены и такие модули, как API, и ролевая модель. И да, по поводу последних двух были сомнения, потому что «бизнесу» не место в shared. Но в этом споре выигрывает довод в пользу удобства.

Но что бы минимизировать последствия этого отступления от правил, мы вынесли в API только саму механику обращения к backend, создав таким образом интерфейс получения данных и не более того.

Вторая сложность со слоем shared заключается в том, чтобы не превратить его в помойку. В нем не действуют запреты на кросс-импорты, кто-то предлагает считать, что в нем нет слайсов, третьи заявляют, что в нем надо соблюдать аналогичную иерархию между сегментами, как в слоях: config → api → lib → model → ui. Вся эта условность и отсутствие внятных правил развязывает руки разработчикам и подталкивает к бессистемности. 

Совет: старайтесь этого избегать и выработайте свод своих правил, который бы подходил под ваше приложение, также правила должны автоматически валидироваться плагином для eslint’а, который проверяет кросс-импорты. 

App. Первые костыли

За shared последовал, наоборот самый верхний по иерархии FSD-слой — app. Здесь расположились, генерация Store, различные провайдеры, в том числе VKUI и, собственно, общая инициализация приложения. И тут пришлось решать вторую серьезную дилемму. Как быть с Redux и другими похожими глобальными контекстами? Поясню — композиция глобального Стейта происходит на самом верхнем уровне, в слое app. Иначе никак. Мы буквально должны собрать все редьюсеры, где они ни были, в слое entity, feature, widget или pages, а обращаться к этим слоям можно только из слоя app. При инициализации Store мы получим наш заветный тип RootState который надо использовать в дженерик‑функциях useDispatch и useSelector, но к этому типу нельзя будет обратиться из нижестоящих слоев по определению FSD.

Чтобы побороть эту проблему пришлось прибегнуть к небольшому костылю, который, впрочем, считается вполне валидным в FSD — объявить RootState глобальным типом в d.ts. После чего мы можем объявить наши кастомные, типизированные useAppDispatch и useAppSelector в слое shared и пользоваться ими в любом слое.

declare global {
  /**
   * ⚠️ FSD
   *
   * Its hack way to export redux inferring types from @/app
   * and use it in @/shared/model/hooks.ts
   */
  declare type RootState = import('../src/app/store/reducers').RootState
}
export {}

В остальном слой app не вызвал никаких сложностей. Он вместе с shared может формироваться без слайсов, только из сегментов.

0599771a7a9bede6c48bb4968aa506bb.png

Разбиваем на слои

Далее работа пошла уже не послойно, а постранично. Берем одну страницу, в данном случае список пользователей и дробим ее на абстракции разного уровня. Модели, схемы валидации в Entities. А представления же делим на четыре слоя:

Слой entities

Слой entities

В entities идут мелкие, совсем атомарные части, например иконка статуса пользователя, аватар и вывод имени. Последний надо представить в виде внешней ссылки на аккаунт VK.

Также стоит понимать, что сущность — это может быть не какой‑то один класс, одна модель. Для примера, имеется два класса — User и Invite. Они очень похожи по своей структуре, они частично делят схему валидации, и имеют общее представление. Вариантов решения подобной проблемы есть несколько, но в нашем случае логично было представить это все одной «сущностью», немного пожертвовав низкой связностью.

Слой features

Слой features

В features попали средней сложности куски кода. В основном это какие-то действия с сущностями. На странице пользователей у нас их вышло два:

1) Кнопка «Редактировать роли» — простая фича, которая использует глобальный контекст и выполняет одно единственное действие — вызывает модальное окно (widget).

2) «Статус пользователя» — иконка, которая при нажатии меняет статус пользователя, если надо временно отключить его учетку. Эта кнопка посложнее она диспатчит асинхронный Thunk, который отправляет запрос на сервер. Всю эту бизнес‑логику важно разместить именно на этом уровне по двум причинам. Первое — поддержание низкой связности. Второе в интерфейсе слайса мы экспортируем наш Thunk, на который может подписаться другой редьюсер из виджета или страницы. Чем ниже по структуре FSD мы будет располагать эти thunk, тем лучше.

Слой widget

Слой widget

В widget мы вынесли:

1) список пользователей, только его представление;

2) форму редактирования ролей в модальном окне.

Слой pages

Слой pages

На слое pages происходит композиция всего вышеперечисленного, плюс редьюсер, плюс обращение к API за массивом пользователей. Редьюсеры в отличие от Thunk будет удобнее располагать как можно выше, потому что, как Вы знаете, Redux предполагает событийную модель действий — соответственно в диспатч стора должны отправляться события, а не сеттеры, соответственно редьюсер должен подписываться на экшены из фич и виджетов, а не наоборот. В этом плане FSD очень помогает соблюдать один из основных принципов Redux, про который многие новички обычно забывают.

Совет №1: старайтесь вести разработки именно в таком порядке — от сущностей до страниц. Не надо пихать весь код кучей в pages и потом пытаться это оптимизировать. Наполняйте сначала самые низкие слои, и, если видите, что слайс становится слишком громоздким — это именно тот момент, чтобы задуматься, а не стоит ли декомпозировать ваш код на слой выше.

Совет №2: не гонитесь за тотальным единообразием. Если вы видите, что страница получается слишком простой и вся логика умещается в один слой — разместите её всю в pages. Не надо декомпозировать ради декомпозирования — вы потом замучаетесь это все поддерживать. Например, у нас есть список сервисов, но в отличие от пользователей это не таблица, а обычные плиточки с иконкой и заголовком. Плитку мы определили в представление сущности, а вывод массива оставили в слое pages. Мы пропустили в этой цепочке слои виджетов и фич, ибо они были совершенно избыточны. Не бойтесь так делать, если оно оправданно.

Заключение

По итогам нашего рефакторинга мы получили:

  • Наконец-то формализованные codestyle-правила

  • Замечательную масштабируемость

  • Низкую связность кода

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

Что в планах

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

Хоть я писал, что не надо устраивать свалку в shared, таки у нас не получилось без небольшого бардачка… В планах также разобраться с этой проблемой и подготовить UIkit к внедрению Story-book на проект.

Комьюнити

На всех этих этапах неоценимой поддержкой был канал от разработчиков методологии. Так отзывчивое, грамотное и дружелюбное комьюнити.

© Habrahabr.ru