Архитектура фронтенда на основе вертикальных слайсов

Добрый день, друзья. Сегодня поговорим о сложном простыми словами - об архитектуре. О ней можно говорить в разных разрезах, в этот раз рассмотрим структуру проекта и взаимодействие модулей друг с другом. Сразу предупрежу о паре вещей:

  1. Ниже я буду употреблять термин features (фичи), который никак не связан с одноименным слоем из методологии feature-sliced.

  2. В примерах будем использовать React и Redux, как самые популярные на сегодняшний день решения, чтобы материал был понятен большой части читателей. Каких-то специфичных вещей не будет, так что если вы пишете на Vue/Svetle/Solid, проблем с пониманием концепций возникнуть не должно.


Удивительно, что фронтенду, как самостоятельному направлению разработки, уже более 10 лет, если за старт считать начало бурного роста SPA, и целых 28 лет, если считать со дня появления JavaScript-а. Но до сих пор нет универсальных паттернов и подходов для построения архитектуры приложений. Точнее они есть, но каждое приложение - уникальное. Я работал на многих проектах (небольших, больших и очень больших), которые маинтейнили сильные (иногда не очень) разработчики. На большинстве из них применялись схожие подходы: построение архитектуры на основе фичей, попытка применять Low Coupling (низкая связанность) и High Cohesion (высокое зацепление), разделение компонентов на умные и глупые, композиция интерфейса на компоненты, DI. Но во всех не было четко описанных границ, как модули внутри приложения должны взаимодействовать друг с другом, и обычно получалась так называемая спагетти-архитектура.

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

С чего все начиналось #

Структура первых* проектов была устроена на основе разделения по техническим слоям (пример shopping-cart в redux/examples). В таком подходе весь проект делится на слои: папки для состояния (в случае использования Redux, есть отдельные директории для Reducers, Actions, Selectors), общие директории для компонентов и контейнеров (умные компоненты) и т.д.

src
├── actions/
├── api/
├── components/
├── constants/
├── containers/
├── pages/
├── reducers/
├── selectors/
└── index.js

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

* (на самом деле такой подход пришел с бекенда и использовался независимо от фреймворков и паттернов. MVC, MVVM, React с Flux - везде использовался подход с разбиением на слои.

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

↓ page
↓ containers
↓ store (actions, reducers, selectors)
↓ api
↓ components
↓ constants

Страницы могут импортировать код из всех модулей. Контейнеры (умные компоненты) - все, кроме кода из страниц. А константы не могут импортировать ничего. Обычно это правило (несмотря на его важность) было зафиксировано на уровне договоренностей и не всегда выполнялось, но даже в таком виде оно сильно упрощало разработку в целом.

Проблемы #

С ростом проекта этот подход приводит к следующему:

  • Неявные связи в рамках конкретных слоев: бизнес компоненты (ProductCard, UserProfile) смешиваются c UI компонентами (Link, Button) в components, то же самое касается бизнес логики - одни действия (actions) начинают использовать другие, которые напрямую с ними не связаны. Понять, какой компонент можно импортировать, а какой нельзя - становится нереально сложно;
  • Даже для небольших продуктовых фичей приходится править код по всему проекту, так как он раскидан по разным слоям.

«Vertical sliced» подход #

Вместо того чтобы думать о системе как о наборе технических слоев (Reducers, Actions, Selectors и компоненты), приложение можно представить как набор фичей (features), объектов из предметной области нашего проекта.

Другими словами это называется подходом вертикальных слайсов (slice, их еще называют срезами, но для простоты я ниже буду употреблять только «слайс»). Подход подразумевает, что архитектура строится не вокруг технических слоев (layer), а поверх конкретных слайсов (бизнес фич) проекта. Например, в случае онлайн магазина - это корзина/заказ/товар, в случае чата - контакт/чат/сообщение, в случае github-а - репозиторий/ветка/коммит.

Мы берем любую архитектуру на основе слоев (здесь так же может подойти и n-tier, гексагональная или чистая архитектура), удаляем границу между слоями и строим границы между слайсами:

Пример слайсов поверх слоев

Сперва начали складывать по слайсам только слои, которые относятся к состоянию приложения (в контексте редакса: Reducers, Actions, Selectors). Назвали это «Duck modular approach», а основные моменты описали в github/ducks-modular-redux:

src
├── api/
├── components/
├── constants/
├── containers/
├── ducks
│   ├── cart
│   │   ├── actions.ts
│   │   ├── reducer.ts
│   │   ├── selectors.ts
│   │   └── index.js
│   ├── product/
│   ├── ...
│   └── other/
├── pages/
└── index.js

Реальный пример можете посмотреть в одном из моих стареньких пет проектов github/react-ssr-tutorial/src/store/ducks/catalog/*.

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

src
├── features
│   ├── cart
│   │   ├── api/
│   │   ├── components/
│   │   ├── containers/
│   │   ├── constants/
│   │   ├── ducks/
│   │   └── index.js
│   ├── product/
│   ├── ...
│   └── other/
├── pages/
└── index.js

Добавляя или изменяя фичу в приложении, обычно затрагивается множество технических слоев. Например, при добавлении промокодов в корзину (cart), нужно поменять пользовательский интерфейс, добавить поля в интерфейс модели, изменить проверки и так далее. Вместо изменений по всему приложению и связями между слоями, вертикальные слайсы позволяют работать в контексте самого слайса.

На выходе получаем высокое зацепление (high cohesion) внутри слайса и низкую связанность (low coupling) между разными слайсами (противоположную модель из первой архитектуры).

Любая новая продуктовая фича не будет затрагивать написанный код чужих модулей, и не нужно переживать по поводу возможных сайд эффектов. А рефакторинг текущих - только связанные слайсы. Круто, не так ли?

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

Пользовательские истории в контексте слайса

Что с чистой архитектурой? #

В этом разделе я не могу не порекомендовать одну из лучших статей (если не лучшую) про чистую архитектуру во фронтенде: https://bespoyasov.ru/blog/clean-architecture-on-frontend. Саша Беспоясов проделал огромную работу и очень подробно расписал, как чистую архитектуру можно встроить во фронтенд, чтобы ее можно было применять на реальных проектах.

У Саши получилась следующая структура проекта (https://github.com/bespoyasov/frontend-clean-architecture/tree/master/src):

src
├── application/
├── domain/
├── lib/
├── services/
├── ui/
└── index.jsx

И она так же отлично сочетается с вертикальными слайсами (*):

Пример слайсов поверх слоев чистой архитектуры

src
├── features
│   ├── cart
│   │   ├── application/
│   │   ├── domain/
│   │   ├── lib/
│   │   ├── services/
│   │   ├── ui/
│   │   └── index.js
│   ├── product/
│   ├── ...
│   └── other/
├── pages/
└── index.js

* Далее будет расширенный пример с domain.

Что с общим кодом? #

На любом проекте есть код, не связанный с бизнес фичами напрямую: общие хелперы, UI компоненты (кнопки, формы, лайаут) или глобальное shared-состояние, которые используются во многих фичах одновременно. А так же различные инфраструктурные вещи (работа с конфигами, переводами, нотификациями и т.д.). На этот случай выделяют специальную директорию shared, в которой все и складируют. Для того чтобы это папка не превращалась в мусорку, она может иметь те же название поддиректорий, что и любой слайс из фич (api, компоненты, контейнеры, даки и т.д.):

src
├── features/
├── pages/
├── shared
│   ├── api/
│   ├── components/
│   ├── store/
│   ├── ...
│   └── other/
└── index.js

Глобальное shared-состояние - это кстати антипаттерн, но его часто можно встретить в боевых проектах

Уже здесь можно заметить, что у нас опять вырисовывается структура проекта на верхнем уровне на основе технических слоев, а в внутри каждого слоя код разделен на вертикальные слайсы (за исключением shared).

Важное правило #

Для вертикальных слайсов сохраняется однонаправленный поток импортов внутри самих слайсов. А так же добавляется новое правило: фичи не могут напрямую использовать друг друга. Если нужно использовать какие-нибудь данные/UI из соседней фичи, то можно использовать DI или паттерн render-prop, когда мы можем прокинуть зависимость снаружи (например, со страницы).

Это позволяет держать фичи максимально изолировано друг от друга (так называемая модульная архитектура), решить проблемы циклических импортов и следовать подходу high cohesion - low coupling.

Как работать c глобальным стором в Redux #

В Redux, в отличие от MobX или effector, есть общий глобальный стор, в котором будут храниться данные всех фич.

Это могло бы быть проблемой, если у нас не было Redux-Toolkit, который позволяет создавать изолированные подсторы, и combineReducers, который по сути нужен только для объединения всех редьюсеров (аналог RootStore в MobX). Поэтому технически мы храним все в одном месте, но в коде конкретной фичи работаем со стором только этой фичи:

// src/features/user/store/slice.ts

export const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {/*...*/},
})

И собираем все фичи в одном месте:

// src/store.ts

const rootReducer = combineReducers({
  [userSlice.name]: userSlice.reducer,
  // ...
})

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

Что такое фича? #

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

На примерах ранее мы в качестве фич рассматривали большие бизнес-сущности приложения (корзина, товар, заказ). На одном из проектов у нас было именно такое разделение, и некоторые фичи со временем стали очень монструозными. По сути нашу фичу можно было бы разделить на много более мелких фич, у которых так же может быть свое состояние, API ручки и UI. Например, productPopuparSlider, productAlreadyPurchasedGrid, productFavoriteList и т.д.

src
├── features
│   ├── product
│   │   ├── (?) productDetails/
│   │   ├── (?) productAlreadyPurchasedGrid/
│   │   ├── (?) productFavoriteList/
│   │   ├── (?) productPopuparSlider/
│   │   ├── (?) productListFilters/
│   │   ├── (?) productListSorting/
│   │   ├── (?) productListViewMode/
│   │   ├── store
│   │   ├── components
│   │   ├── ...
│   │   └── other/
│   ├── ...
│   └── other/
├── pages/
├── shared/
└── index.js

По идеи все эти фичи стоит вынести на внешний уровень (директория features). Но все они завязаны на данные соответствующих бизнес фичей (фичи из кода выше завязаны на стор и API product). Что можно сделать? Можно положить product в shared, но тогда мы опять возвращаемся к неявным связям и смешиванию бизнес сущностей с остальным кодом...

«Фича драйвен девелопмент» #

Давайте на время забудем о том, что мы обсуждали ранее, и посмотрим на фичи с другой стороны. Я уверен, что все, кто когда-то занимался архитектурой, приходит к следующему заключению: весь интерфейс можно разбить на самостоятельные модули (более высокоуровневые, чем просто компоненты или контейнеры), которые можно переиспользовать и содержащие свою бизнес логику. Такой подход даже получил название - feature driven development (именно в рамках фронтенд разработки, так как есть еще одноименная методология в менеджменте), и он успешно применяется на многих проектах, в которых я работал.

Таким фичам обычно выставляют следующие требования:

  • Изоляция (self-contained) - содержит внутри себя все необходимое для работы
  • Ориентация на пользователя (user-facing) - направлена на какую-то пользовательскую потребность (сделать действие, показать информацию и т.д.)
  • Переиспользуемость (reusable) - может быть переиспользована на различных страницах
  • Сложная логика (complex logic) - сложнее чем обычный компонент или контейнер (более высокий уровень абстракции)

Рассмотрим пример модуля, который выводит детальную информацию о продукте - productDetails.

Интерфейс модуля детальной информации и продукте

У этого модуля может быть свой запрос в API, свое состояние (загружаются данные, была ли получена ошибка), свой UI (общая сетка, изображения, пользовательские действия (добавить товар в корзину), доступные размеры и т.д.). Он ориентирован на пользователя (предоставляет детальную информацию о товаре). Его можно переиспользовать в различных местах: на отдельной странице или внутри какого-нибудь модального окна. Все требования выполнены - это фича.

Все приложение может состоять из таких вот фичей:

Разбиваем интерфейс на фичи

Но как объединить этот паттерн фичей со структурой проекта из прошлых разделов? Здесь нам поможет простое правило - не хватает уровня композиции для слоев, вводим новый слой!

Новый слой - «Domain» #

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

Правда мы немного отойдем от канонов чистой архитектуры и помимо самих сущностей (типов и преобразований данных) будем хранить базовые UI (все же у нас фронтенд и все сильно завязано на UI), походы в API (например, получение информации о текущем пользователе), TypeScript типы, состояние и т.д.

А в features/* мы будем складывать наши self-contained модули:

src
├── domain
│   ├── product
│   │   ├── api/
│   │   ├── components/
│   │   ├── store/
│   │   ├── ...
│   │   └── other/
│   ├── ...
│   └── other/
├── features/
│   ├── productDetails/
│   ├── productAlreadyPurchasedGrid/
│   │   ├── api/
│   │   ├── components/
│   │   ├── store/
│   │   ├── ...
│   │   └── other/
│   ├── productFavoriteList/
│   ├── productPopuparSlider/
│   ├── productListFilters/
│   ├── productListSorting/
│   ├── productListViewMode/
│   ├── ...
│   └── other/
├── pages/
├── shared/
└── index.js

Кстати фичи можно группировать по префиксу в названии (чтобы не увеличить вложенность). Например, все что касается товаров, начинать с product*, корзины - cart* и т.д.

Пример, что может лежать в домене #

Рассмотрим, что может находиться внутри домена (не забываем, что обсуждения идут в рамках фронтенд проекта). Есть какая-то фича со списком товаров:

Что может лежать в домене

Внутри списка используется карточка отображения, которая используется по всему приложению? Отличный кандидат для попадания в domain/product/components/ProductCard.tsx. Есть действие добавление в список желаний/корзину - domain/product/store/actions/*. Как то форматируется цена - domain/product/helpers/formatPrice.ts. И так далее. Все, что используется универсально на уровне бизнес сущности по всему приложению (в различных фичах или страницах) можно вынести в слой domain.


Теперь мы окончательно вернулись к тому, с чего начинали - технические слои снаружи (domain, features, pages, shared). И на первый взгляд, ничего не изменилось. Однако, есть большее отличие - внутри каждого слоя (за исключением shared) используются вертикальные слайсы.

↓ page
↓ features
↓ domain
↓ shared

Коммуникация между фичами #

Я уже кратко упоминал, как можно общаться между фичами, если одной фичи нужны данные или UI другой, но давайте рассмотрим этот пункт подробнее.

UI #

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

Разберем пример. Есть две фичи: LayoutHeader и LayoutProfileCard. Карточку профиля пользователя нужно использовать внутри хедера, но нельзя связывать компоненты между собой:

Пример использования фичи в фиче

Это можно сделать, создав рендер-проп rightContentSlot в LayoutHeader, который будет рендерить переданный в компонент контент. Т.е. LayoutHeader и LayoutProfileCard остаются изолированными фичами, ничего не зная друг о друге.

Место для рендер прода

В коде это может выглядеть следующим образом. Сам компонент LayoutHeader:

type Props = {
  rightContentSlot: ReactNode
}

function LayoutHeader(props: Props) {
  return (
    <header>
      <div className={css.right}>
        {props.rightContentSlot}
      </div>
    </header>
  )
}

И его использование вместе с LayoutProfileCard:

function Layout() {
  return (
    <LayoutHeader
      rightContentSlot={<LayoutProfileCard />}
    />
  )
}

В другом месте (на другой странице) мы можем переиспользовать фичу LayoutHeader и передать в нее любой другой контент. Дополнительно можете почитать статью от Кента Доттса на эту тему: https://epicreact.dev/one-react-mistake-thats-slowing-you-down/ .

Данные #

В случае с редаксом и его глобальным стором, у нас всегда есть простой доступ к данным из других слайсов (подсторов).

// Например мы находимся в LayoutHeader
// src/features/LayoutHeader/slice.ts

export const selectPopularProducts = (state: State) =>
// 👎 Используем данные из другой фичи
    state.popuparProducts.items
//        ^^^^^^^^^^^^^^^^^^^^^

Или в компоненте:

function LayoutHeader() {
  // 👎 Используем данные из другой фичи
  const popularProducts = useSelector(
    state: State => state.popuparProducts.items
//                        ^^^^^^^^^^^^^^^^^^^^^
  )

  return (<div />)
}

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

// Layout находится на уровне страниц
function Layout() {
  // 👍 Получаемся данные из другой фичи на уровне pages
  const popularProducts = useSelector(
    state: State => state.popuparProducts.items
  )

  return (
    <LayoutHeader
      popularProducts={popularProducts}
    />
  )
}

Здесь не совсем удачное название свойства, так как вряд-ли LayoutHeader нужен список с популярными продуктами. Поэтому в зависимости от ситуации, имя и тип пропа следует изменить.


Если вы используете решение на основе мультисторов, которое живет отдельно от view-слоя (например, MobX), то можно реализовать взаимодействие фич на основе DI, чтобы инжектить нужные зависимости:

@Service()
class LayoutHeaderService {
  constructor(
    public popularProducts: PopuparProductsService
  ) {}
}

Заключение #

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

Бонус 1: Микрофронты #

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

packages
├── catalog
│   ├── domain/
│   ├── features/
│   ├── pages/
│   ├── shared/
│   ├── index.ts
│   └── package.json
├── navbar/
├── support/
├── ...
└── other-microfrontend

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

Бонус 2: Есть ли что универсальное? #

Мы с вами по шагам спроектировали архитектуру. И к счастью для нас, уже есть готовая методология, которая решает рассмотренные вопросы (и многие другие) за нас. Называется она Feature-Sliced. Это архитектурная методология для фронтенда, которая говорит, как правильно должны быть расположены директории в проекте, а модули взаимодействовать друг с другом. Внутри себя FSD объединяет различные лучшие практики, часть из которых мы рассмотрели (вертикальные слайсы, чистая архитектура, low coupling - high cohesion). На мой взгляд это лучшее, что сейчас есть на рынке, поэтому при проектировании нового проекта очень советую обратить на нее внимание. Я же планирую написать пару постов о ней в будущем, потому подписывайтесь на телеграмм канал, чтобы не пропустить новый материал.

Если вы пойдете знакомиться с методологией после прочтения данного поста, то имейте в виду, слой features, рассмотренный ранее, равен widgets, а domain - entities.

Материл по теме #

Оставайтесь на связи
Чтобы не пропустить новые посты или анонсы проектов, которые я делаю, вы можете присоединиться к телеграм каналу. А так же оставаться на связи, задавать вопросы или просто вместе обсуждать различные инженерные темы.
Присоединиться

Вам может быть интересно

Почему я не использую NextJS

14/10/2022
О каких технологиях вы задумываетесь, когда речь заходит про SSR и React?
129

Трофей тестирования фронтенда

01/04/2023
Как писать тесты для фронтенда, что такое трофей тестирования и из каких слоев он состоит
62

Стоит ли использовать Redux с React Hooks

12/04/2020
Перепишем компонент на хуки с использованием react-redux и посмотрим на достоинства и недостатки
208