Добрый день, друзья. Сегодня поговорим о сложном простыми словами - об архитектуре. О ней можно говорить в разных разрезах, в этот раз рассмотрим структуру проекта и взаимодействие модулей друг с другом. Сразу предупрежу о паре вещей:
Ниже я буду употреблять термин features (фичи), который никак не связан с одноименным слоем из методологии feature-sliced.
В примерах будем использовать 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
Страницы могут импортировать код из всех модулей. Контейнеры (умные компоненты) - все, кроме кода из страниц. А константы не могут импортировать ничего. Обычно это правило (несмотря на его важность) было зафиксировано на уровне договоренностей и не всегда выполнялось, но даже в таком виде оно сильно упрощало разработку в целом.
С ростом проекта этот подход приводит к следующему:
Вместо того чтобы думать о системе как о наборе технических слоев (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.
В 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 (именно в рамках фронтенд разработки, так как есть еще одноименная методология в менеджменте), и он успешно применяется на многих проектах, в которых я работал.
Таким фичам обычно выставляют следующие требования:
Рассмотрим пример модуля, который выводит детальную информацию о продукте - productDetails.
У этого модуля может быть свой запрос в API, свое состояние (загружаются данные, была ли получена ошибка), свой UI (общая сетка, изображения, пользовательские действия (добавить товар в корзину), доступные размеры и т.д.). Он ориентирован на пользователя (предоставляет детальную информацию о товаре). Его можно переиспользовать в различных местах: на отдельной странице или внутри какого-нибудь модального окна. Все требования выполнены - это фича.
Все приложение может состоять из таких вот фичей:
Но как объединить этот паттерн фичей со структурой проекта из прошлых разделов? Здесь нам поможет простое правило - не хватает уровня композиции для слоев, вводим новый слой!
Для решения проблемы можно ввести еще один слой, в котором складывать все бизнес сущности (корзина, товары, пользователь и т.д.). Этот слой не что-то новое, а давно известное понятие из чистой архитектуры и 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 другой, но давайте рассмотрим этот пункт подробнее.
Для того чтобы одна фича могла использовать другую, ее можно прокинуть в виде пропа. Такой подход называется слотами или, 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
) {}
}
Обещал в начале рассказать простыми словами, но вышло все равно довольно сложно. В любом случае мы рассмотрели две важные концепции: вертикальные слайсы в разрезе технических слоев приложения и фичи как самостоятельные модули приложения. Какую бы вы архитектуру не выбрали для своих проектов, всегда держите в уме эти подходы, которые сделают ваши проекты более гибкими к масштабированию и рефакторингу.
Полученная архитектура отлично масштабируется под микрофронтенды, обернув нашу структуру в еще один уровень вертикальных слайсов:
packages
├── catalog
│ ├── domain/
│ ├── features/
│ ├── pages/
│ ├── shared/
│ ├── index.ts
│ └── package.json
├── navbar/
├── support/
├── ...
└── other-microfrontend
По сути получается монорепозиторий, где каждый слайс является отдельным микрофронтом и внутри каждого из них реализована архитектура, описанная раньше.
Мы с вами по шагам спроектировали архитектуру. И к счастью для нас, уже есть готовая методология, которая решает рассмотренные вопросы (и многие другие) за нас. Называется она Feature-Sliced. Это архитектурная методология для фронтенда, которая говорит, как правильно должны быть расположены директории в проекте, а модули взаимодействовать друг с другом. Внутри себя FSD объединяет различные лучшие практики, часть из которых мы рассмотрели (вертикальные слайсы, чистая архитектура, low coupling - high cohesion). На мой взгляд это лучшее, что сейчас есть на рынке, поэтому при проектировании нового проекта очень советую обратить на нее внимание. Я же планирую написать пару постов о ней в будущем, потому подписывайтесь на телеграмм канал, чтобы не пропустить новый материал.
Если вы пойдете знакомиться с методологией после прочтения данного поста, то имейте в виду, слой features, рассмотренный ранее, равен widgets, а domain - entities.