Быстрый старт
Рассмотрим применение Feature-Sliced Design на примере TodoApp
- Сначала разберем подготовительные аспекты создания приложения
- А затем - как концепции методологии помогают гибко и эффективно проектировать бизнес-логику без лишних затрат
В конце статьи есть codesandbox-вставка с финальным решением, которое может помочь для уточнения деталей реализации
Стек: React, Effector, TypeScript, Sass, AntDesign
note
Туториал призван раскрыть практическую идею самой методологии. Поэтому описанные здесь практики - во многом подойдут и для других технологических стеков фронтенд-проектов
1. Подготовительные моменты
1.1 Инициализируем проект
На данный момент имеется множество способов сгенерировать и запустить шаблон проекта
Не будем акцентироваться сильно на этом шаге, но для быстрой инициализации можно воспользоваться CRA (для React):
$ npx create-react-app todo-app --template typescript
1.2 Подготавливаем структуру
Получили следующую заготовку под проект
└── src/
├── App.css
├── App.test.tsx
├── App.tsx
├── index.css
├── index.ts
├── logo.svg
├── react-app-env.d.ts
├── reportWebVitals.ts
├── setupTests.ts
└── index.tsx/
Как это обычно происходит
И обычно большинство проектов на данном этапе превращаются в примерно такое:
└── src/
├── api/
├── components/
├── containers/
├── helpers/
├── pages/
├── routes/
├── store/
├── App.tsx
└── index.tsx/
Они могут как сразу стать такими, так и по прошествии долгой разработки
При этом, если мы заглянем внутрь, как правило обнаружим:
- Сильно ветвистые по вложенности директории
- Сильно связные друг с другом компоненты
- Огромное количество разнородных компонентов/контейнеров в соответствующих папках, связанные "абы как"
Как это можно делать иначе
Каждый, кто хоть сколько давно разрабатывал фронтенд-проекты, примерно понимает преимущества и недостатки такого подхода.
Однако все еще большинство фронтенд-проектов представляют из себя нечто такое, поскольку нет проверенной опытом гибкой и расширяемой альтернативы
Помножим это на вольные адаптации структуры под каждый проект, без запрета со стороны фреймворка - и получим "уникальные как снежинки проекты"
Цель данного туториала - показать другой взгляд на привычные практики при проектировании
Адаптируем структуру к нужному виду
└── src/
├── app/ # Инициализирующая логика приложения
| ├── index.tsx # Энтрипоинт для подключения приложения (бывший App.tsx)
| └── index.css # Глобальные стили приложения
├── pages/ #
├── widgets/ #
├── features/ #
├── entities/ #
├── shared/ #
└── index.tsx # Подключение и рендеринг приложения
Возможно, на первый взгляд, такая структура покажется непривычной, но со временем вы сами заметите, что используете знакомые вам абстракции, но в консистентном и упорядоченном виде.
Также, подключаем поддержку абсолютных импортов для удобства
{
"compilerOptions": {
"baseUrl": "./src",
// Либо же альясы, если так удобнее
Вот, как это поможет нам в будущем
- import App from "../app"
- import Button from "../../shared/ui/button";
+ import App from "app"
+ import Button from "shared/ui/button";
Layers: app
Как можно заметить - мы перенесли всю базовую логику в директорию app/
Именно там, согласно методологии, стоит располагать всю подготовительную логику:
- подключение глобальных стилей (
/app/styles/**
+/app/index.css
) - провайдеры и HOCs с инициализирующей логикой (
/app/providers/**
)
Пока что перенесем туда всю существующую логику, а другие директории оставим пустыми, как на схеме выше.
import "./index.css";
const App = () => {...}
1.3 Подключим глобальные стили
Установим зависимости
В туториале устанавливаем sass, но можно взять и любой другой препроцессор, поддерживающий импорты
$ npm i sass
Заводим файлы для стилей
Для css-переменных
:root {
--color-dark: #242424;
--color-primary: #108ee9;
...
}
Для нормализации стилей
html {
scroll-behavior: smooth;
}
...
Подключаем все стили
@import "./normalize.scss";
@import "./vars.scss";
...
@import "./styles/index.scss";
...
import "./index.scss"
const App = () => {...}
1.4 Добавим роутинг
Установим зависимости
$ npm i react-router react-router-dom compose-function
$ npm i -D @types/react-router @types/react-router-dom @types/compose-function
Добавим HOC для инициализации роутера
import { Suspense } from "react";
import { BrowserRouter } from "react-router-dom";
export const withRouter = (component: () => React.ReactNode) => () => (
<BrowserRouter>
<Suspense fallback="Loading...">
{component()}
</Suspense>
</BrowserRouter>
);
import compose from "compose-function";
import { withRouter } from "./with-router";
export const withProviders = compose(withRouter);
import { withProviders } from "./providers";
...
const App = () => {...}
export default withProviders(App);
Добавим реальные страницы
note
Это лишь одна из реализаций роутинга
- Можно объявлять его декларативно либо через список роутов (+ react-router-config)
- Можно объявлять его на уровне pages либо app
Методология пока никак не регламентирует реализацию этой логики
Временная страница, только для проверки роутинга
Ее можно удалить позднее
const TestPage = () => {
return <div>Test Page</div>;
};
export default TestPage;
Сформируем роуты
// Либо использовать @loadable/component, в рамках туториала - некритично
import { lazy } from "react";
import { Route, Switch, Redirect } from "react-router-dom";
const TestPage = lazy(() => import("./test"));
export const Routing = () => {
return (
<Switch>
<Route exact path="/" component={TestPage} />
<Redirect to="/" />
</Switch>
);
};
Подключаем роутинг к приложению
import { Routing } from "pages";
const App = () => (
// Потенциально сюда можно вставить
// Единый на все приложение хедер
// Либо же делать это на отдельных страницах
<Routing />
)
...
Layers: app, pages
Здесь мы использовали сразу несколько слоев:
1.5 Подключим UIKit
Для упрощения туториала, воспользуемся готовым UIKit от AntDesign
$ npm i antd @ant-design/icons
@import 'antd/dist/antd.css';
tip
Но вы можете использовать любой другой UIKit или же создать собственный, расположив компоненты в shared/ui
- именно там рекомендуется хранить UIKit приложения:
import { Checkbox } from "antd"; // ~ "shared/ui/checkbox"
import { Card } from "antd"; // ~ "shared/ui/card"
2. Реализация бизнес-логики
note
Постараемся сконцентрироваться не на реализации каждого модуля, а на их последовательной композиции
2.1 Проанализируем функциональность
Прежде чем приступать к коду, надо определиться - какую ценность мы хотим донести конечному пользователю
Для этого, декомпозируем нашу функциональность по зонам ответственности (слоям)
Примечание: на схеме представлен экспериментальный слой "Виджетов", который излишен в рамках туториала и спецификация которого скоро добавится
Pages
Набросаем базово необходимые страницы, и пользовательские ожидания от них:
TasksListPage
- страница "Список задач"- Смотреть список задач
- Переходить к странице конкретной задачи
- Помечать выполненной/невыполненной конкретную задачу
- Задавать фильтрацию по выполненным/невыполненным задачам
TaskDetailsPage
- страница "Карточка задачи"- Смотреть информацию по задаче
- Помечать выполненной/невыполненной конкретную задачу
- Возвращаться к списку задач
Каждая из описанных возможностей - представляет из себя часть функциональности
Обычный подход
И есть большой соблазн
- либо всю логику реализовать в директории каждой конкретной страницы.
- либо все "возможно переиспользуемые" модули вынести в общую папку
src/components
или подобную
Но если для маленького и недолгоживущего проекта такое решение подошло бы, то в реальной корпоративной разработке, оно может поставить крест на дальнейшем развитии проекта, превратив его в "еще одно дремучее легаси"
Обусловлено это обычными условиями развития проекта:
- требования меняются достаточно часто
- появляются новые обстоятельства
- техдолг копится с каждым днем и все сложнее добавлять новые фичи
- нужно масштабировать как сам проект, так и его команду
Альтернативный подход
Даже при базовом разбиении мы видим, что:
- между страницами есть общие сущности и их отображение (Task)
- между страницами есть общие фичи (Помечать задачу выполненной / невыполненной)
Соответственно, кажется логичным продолжать декомпозировать задачу, но уже исходя из перечисленных выше возможностей для пользователя.
Features
Части функциональности, несущие ценность пользователю
<ToggleTask />
- (компонент) Пометить задачу выполненной / невыполненной<TasksFilters/>
- (компонент) Задать фильтрацию для списка задач
Entities
Бизнес-сущности, на которых будет строится более высокоуровневая логика
<TaskCard />
- (компонент) Карточка задачи, с отображением информацииgetTasksListFx({ filters })
- (effect) Подгрузка списка задач с параметрамиgetTaskByIdFx(taskId: number)
- (effect) Подгрузка задачи по ID
Shared
Переиспользуемые общие модули, без привязки к предметной области
<Card />
- (компонент) UIKit компонент- При этом можно как реализовывать собственный UIKit под проект, так воспользоваться готовым
getTasksList({ filters })
- (api) Подгрузка списка задач с параметрамиgetTaskById(taskId: number)
- (api) Подгрузка задачи по ID
В чем профит?
Теперь все модули можно проектировать со слабой связностью и со своей зоной ответственности, а также распределить по команде без конфликтов при разработке
А самое главное - теперь каждый модуль служит для построения конкретной бизнес-ценности, что снижает риски для создания "фич ради фич"
2.2 Про что еще стоит помнить
Слои и ответственность
Как было описано выше, благодаря слоистой структуре мы можем предсказуемо распреледять сложность приложения согласно зонам ответственности, т.е. слоям.
При этом более высокоуровневая логика строится на основание нижележащих слоев:
// (shared) => (entities) + (features) => (pages)
<Card> + <Checkbox> => <TaskCard/> + <ToggleTask/> => <TaskPage/>
Подготовка модулей к использованию
Каждый реализуемый модуль должен предоставлять к использованию свой публичный интерфейс:
export { FooCard, FooThumbnail, ... } from "./ui";
export * as fooModel from "./model";
info
Если вам нужны именованные экспорты неймспейсов для декларации Public API, можно посмотреть в сторону @babel/plugin-proposal-export-namespace-from
Либо же, как альтернатива, использовать более развернутую конструкцию
import { FooCard, FooThumbnail, ... } from "./ui";
import * as fooModel from "./model";
export { FooCard, FooThumbnail, fooModel };
2.3 Отобразим базово список задач
(entities) Карточка задачи
import { Link } from "react-router-dom";
import cn from "classnames"; // Можно смело использовать аналоги
import { Row } from "antd"; // ~ "shared/ui/row"
export const TaskRow = ({ data, titleHref }: TaskRowProps) => {
return (
<Row className={cn(styles.root, { [styles.completed]: data.completed })}>
{titleHref ? <Link to={titleHref}>{data.title}</Link> : data.title}
</Row>
)
}
(entities) Подгрузка списка задач
Можно разбивать по типу сущности, либо хранить все в duck-modular-стиле
Более подробно с реализацией API по туториалу можно ознакомиться здесь
import { createStore, combine, createEffect, createEvent } from "effector";
import { useStore } from "effector-react";
import { typicodeApi } from "shared/api";
import type { Task } from "shared/api";
// В каждом эффекте так же может быть своя доп. обработка
const getTasksListFx = createEffect((params?: typicodeApi.tasks.GetTasksListParams) => {
// Здесь также может быть доп. обработка эффекта
return typicodeApi.tasks.getTasksList(params);
});
// Можно хранить и в нормализованном виде
export const $tasks = createStore<Task[]>([])
.on(getTasksListFx.doneData, (_, payload) => ...)
export const $tasksList = combine($tasks, (tasks) => Object.values(tasks));
// Можно промаппить и другие вещи вроде `isEmpty`, `isLoading`, ...
(pages) Соединим всю логику на странице
import { useEffect } from "react";
// Если чувствуете себя уверенно с @effector/reflect - можете сразу использовать его
// В рамках quick-start некритично
import { useStore } from "effector";
import { Layout, Row, Col, Typography, Spin, Empty } from "antd"; // ~ "shared/ui/{...}"
import { TaskRow, taskModel } from "entities/task";
import styles from "./styles.module.scss";
const TasksListPage = () => {
const tasks = useStore(taskModel.$tasksList);
const isLoading = useStore(taskModel.$tasksListLoading);
const isEmpty = useStore(taskModel.$tasksListEmpty);
/**
* Запрашиваем данные при загрузке страницы
* @remark Является плохой практикой в мире effector и представлено здесь - лишь для наглядной демонстрации
* Лучше фетчить через event.pageMounted или reflect
*/
useEffect(() => taskModel.getTasksListFx(), []);
return (
<Layout className={styles.root}>
<Layout.Toolbar className={styles.toolbar}>
<Row justify="center">
<Typography.Title level={1}>Tasks List</Typography.Title>
</Row>
{/* TODO: TasksFilters */}
</Layout.Toolbar>
<Layout.Content className={styles.content}>
<Row gutter={[0, 20]} justify="center">
{isLoading && <Spin size="large" />}
{!isLoading && tasks.map((task) => (
<Col key={task.id} span={24}>
<TaskRow
data={task}
titleHref={`/${task.id}`}
// TODO: ToggleTaskCheckbox
/>
</Col>
))}
{!isLoading && isEmpty && <Empty description="No tasks found" />}
</Row>
</Layout.Content>
</Layout>
);
};
2.4 Добавим переключение статуса задач
(entities) Переключение статуса задачи
export const toggleTask = createEvent<number>();
export const $tasks = createStore<Task[]>(...)
...
.on(toggleTask, (state, taskId) => produce(state, draft => {
const task = draft[taskId];
task.completed = !task.completed;
console.log(1, { taskId, state, draft: draft[taskId].completed });
}))
// Делаем хуком, чтобы завязаться на обновления react
// @see В случае эффектора, использование хука - это крайняя мера, т.к. более предпочтительны computed-сторы
export const useTask = (taskId: number): import("shared/api").Task | undefined => {
return useStoreMap({
store: $tasks,
keys: [taskId],
fn: (tasks, [id]) => tasks[id] ?? null
});
};
(features) Чекбокс для задачи
import { Checkbox } from "antd"; // ~ "shared/ui/checkbox"
import { taskModel } from "entities/task";
// resolve / unresolve
export const ToggleTask = ({ taskId }: ToggleTaskProps) => {
const task = taskModel.useTask(taskId);
if (!task) return null;
return (
<Checkbox
onClick={() => taskModel.toggleTask(taskId)}
checked={task.completed}
/>
)
}
(pages) Внедряем чекбокс в страницу
Что примечательно - карточка задачи совсем не знает ни про страницу где используется, ни про то, какие кнопки-действия в нее могут вставляться (то же самое можно сказать и про саму фичу)
Такой подход позволяет одновременно грамотно разделять ответственность и гибко переиспользовать логику при реализации
import { ToggleTask } from "features/toggle-task";
import { TaskRow, taskModel } from "entities/task";
...
<Col key={task.id} span={24}>
<TaskRow
...
before={<ToggleTask taskId={task.id} withStatus={false} />}
/>
</Col>
2.5 Добавим фильтрацию задач
(entities) Фильтрация на уровне данных
export type QueryConfig = { completed?: boolean };
const setQueryConfig = createEvent<QueryConfig>();
// Можно вынести в отдельную директорию (для хранения нескольких моделей)
export const $queryConfig = createStore<QueryConfig>({})
.on(setQueryConfig, (_, payload) => payload)
/**
* Отфильтрованные таски
* @remark Можно разруливать на уровне эффектов - но тогда нужно подключать дополнительную логику в стор
* > Например скрывать/показывать таск при `toggleTask` событии
*/
export const $tasksFiltered = combine(
$tasksList,
$queryConfig,
(tasksList, config) => {
return tasksList.filter(task => (
config.completed === undefined ||
task.completed === config.completed
))},
);
(features) UI-контролы для фильтров
// Если чувствуете себя уверенно с @effector/reflect - можете сразу использовать его
// В рамках quick-start некритично
import { useStore } from "effector";
import { Radio } from "antd"; // ~ "shared/ui/radio"
import { taskModel } from "entities/task";
import { filtersList, getFilterById, DEFAULT_FILTER } from "./config";
export const const TasksFilters = () => {
const isLoading = useStore($tasksListLoading);
return (
<Radio.Group defaultValue={DEFAULT_FILTER} buttonStyle="solid">
{filtersList.map(({ title, id }) => (
<Radio.Button
key={id}
onClick={() => taskModel.setQueryConfig(getFilterById(id).config)}
value={id}
disabled={isLoading}
>
{title}
</Radio.Button>
))}
</Radio.Group>
);
};
(pages) Внедряем фильтрацию в страницу
И мы снова реализовали логику, особо не задаваясь вопросами:
- А куда положить логику фильтрации?
- А могут ли эти фильтры переиспользоваться в будущем?
- А могут ли фильтры знать про контекст страницы?
Мы просто разделили логику согласно зонам ответственности (слоям)
import { TasksFilters } from "features/tasks-filters";
...
<Layout.Toolbar className={styles.toolbar}>
...
<Row justify="center">
<TasksFilters />
</Row>
</Layout.Toolbar>
note
К текущему этапу, такое разбиение может показаться излишним - "Почему бы не положить все сразу на уровне страницы / фичи"?
Но тогда попробуем задать себе вопросы:
- А где гарантии, что сложность страницы не увеличится в будущем настолько, что все аспекты логики сильно будут переплетены? Как при этом без лишних затрат добавлять новую функциональность?
- А где гарантии, что новый человек, пришедший в команду (или даже вы, если на полгода отойдете от проекта) - поймет, что здесь происходит?
- А как построить логику, чтобы не нарушить поток данных / реактивность с другими фичами?
- А что, если эта логика фильтрации настолько сильно прикрепится к контексту страницы, что ее будет невозможно использовать на других страницах?
Именно по этому мы и разбиваем ответственность, чтобы каждый слой занимался только одной задачей, и чтобы это понимал каждый из разработчиков
2.6 Страница задачи
Аналогичным образом реализуем страницу задачи:
- Выделяем shared логику
- Выделяем entities логику
- Выделяем features логику
- Выделяем pages логику
(pages) Страница "Карточка задачи"
import { ToggleTask } from "features/toggle-task";
import { TaskCard, taskModel } from "entities/task";
import { Layout, Button } from "antd"; // ~ "shared/ui/{...}"
import styles from "./styles.module.scss";
const TaskDetailsPage = (props: Props) => {
const taskId = Number(props.match?.params.taskId);
const task = taskModel.useTask(taskId);
const isLoading = useStore(taskModel.$taskDetailsLoading);
/**
* Запрашиваем данные по задаче
* @remark Является плохой практикой в мире effector и представлено здесь - лишь для наглядной демонстрации
* Лучше фетчить через event.pageMounted или reflect
*/
useEffect(() => taskModel.getTaskByIdFx({ taskId }), [taskId]);
// Можно часть логики перенести в entity/task/card (как контейнер)
if (!task && !isLoading) {
return ...
}
return (
<Layout className={styles.root}>
<Layout.Content className={styles.content}>
<TaskCard
data={task}
size="default"
loading={isLoading}
className={styles.card}
bodyStyle={{ height: 400 }}
extra={<Link to="/">Back to TasksList</Link>}
actions={[
<ToggleTask key="toggle" taskId={taskId} />
]}
/>
</Layout.Content>
</Layout>
)
};
2.7 Что дальше?
А дальше поступают новые задачи, выявляются новые требования
При этом старая кодовая база не требует значительных переработок
Появилась функциональность, завязанная на пользователе?
=> Добавляем entities/user
Понадобилось поменять логику фильтрации?
=> Меняем обработку на entities
или pages
уровне, в зависимости от масштабности
Нужно добавить больше фичей в карточку задачи, но при этом, чтобы ее можно было использовать по-старому?
=> Добавляем фичи и вставляем их в карточку только на нужной странице
Какой-то модуль стал слишком сложным для поддержки?
=> Благодаря заложенной архитектуре, мы можем изолированно отрефакторить только этот модуль - без неявных сайд-эффектов для других (и даже переписать с нуля)
Итого
Мы научились применять методологию для базовых случаев
Понятно, что мир гораздо сложнее, но уже здесь мы зацепились за некоторые спорные моменты и разрешили их таким образом, чтобы проект оставался поддерживаемым и расширяемым.
Мы получили масштабируемую и гибкую кодобазу
Переиспользуемые и расширяемые модули
- shared, features, entities
Равномерное и предсказуемое распределение логики
- Поскольку композиция у нас идет в одном направлении (вышележащие слои используют нижележащие) - мы можем предсказуемо ее отслеживать и модифицировать, не боясь непредвиденных последствий
Структуру приложения, которая рассказывает о бизнес логике сама за себя
- Какие есть страницы?
TasksList
,TaskDetails
- Какие есть фичи? Что может пользователь?
ToggleTask
TasksFilters
- Какие есть бизнес-сущности? С чем ведется работа?
Task (TaskCard, ...)
- Что можно переиспользовать из вспомогательного?
UIKit (Card, ...)
API (tasksApi)
- Какие есть страницы?
Пример
Ниже в Codesandbox представлен пример получившегося TodoApp, где можно подробно изучить финальную структуру приложения
См. также
- (Обзор) How to Organize Your React + Redux Codebase
- Разбор нескольких подходов к структуризации React проектов
- Гайды и примеры применения методологии (+ Миграция с v1)
- Про разбиение приложения
- Справочный материал по методологии