Перейти к основному содержимому

Быстрый старт

Рассмотрим применение 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 # Подключение и рендеринг приложения

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

Также, подключаем поддержку абсолютных импортов для удобства

tsconfig.json
{
"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/**)

Пока что перенесем туда всю существующую логику, а другие директории оставим пустыми, как на схеме выше.

app/index.tsx
import "./index.css";

const App = () => {...}

1.3 Подключим глобальные стили

Установим зависимости

В туториале устанавливаем sass, но можно взять и любой другой препроцессор, поддерживающий импорты

$ npm i sass

Заводим файлы для стилей

Для css-переменных
app/styles/vars.scss
:root {
--color-dark: #242424;
--color-primary: #108ee9;
...
}
Для нормализации стилей
app/styles/normalize.scss
html {
scroll-behavior: smooth;
}
...
Подключаем все стили
app/styles/index.scss
@import "./normalize.scss";
@import "./vars.scss";
...
app/index.scss
@import "./styles/index.scss";
...
app/index.tsx
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 для инициализации роутера

app/providers/with-router.tsx
import { Suspense } from "react";
import { BrowserRouter } from "react-router-dom";

export const withRouter = (component: () => React.ReactNode) => () => (
<BrowserRouter>
<Suspense fallback="Loading...">
{component()}
</Suspense>
</BrowserRouter>
);
app/providers/index.ts
import compose from "compose-function";
import { withRouter } from "./with-router";

export const withProviders = compose(withRouter);
app/index.tsx
import { withProviders } from "./providers";
...

const App = () => {...}

export default withProviders(App);

Добавим реальные страницы

note

Это лишь одна из реализаций роутинга

  • Можно объявлять его декларативно либо через список роутов (+ react-router-config)
  • Можно объявлять его на уровне pages либо app

Методология пока никак не регламентирует реализацию этой логики

Временная страница, только для проверки роутинга

Ее можно удалить позднее

pages/test/index.tsx
const TestPage = () => {
return <div>Test Page</div>;
};

export default TestPage;
Сформируем роуты
pages/index.tsx
// Либо использовать @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>
);
};
Подключаем роутинг к приложению
app/index.tsx
import { Routing } from "pages";

const App = () => (
// Потенциально сюда можно вставить
// Единый на все приложение хедер
// Либо же делать это на отдельных страницах
<Routing />
)
...

Layers: app, pages

Здесь мы использовали сразу несколько слоев:

  • app - для инициализации роутера (HOC: withRouter)
  • pages - для хранения модулей страниц

1.5 Подключим UIKit

Для упрощения туториала, воспользуемся готовым UIKit от AntDesign

$ npm i antd @ant-design/icons
app/styles/index.scss
@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 Проанализируем функциональность

Прежде чем приступать к коду, надо определиться - какую ценность мы хотим донести конечному пользователю

Для этого, декомпозируем нашу функциональность по зонам ответственности (слоям)

layers-flow-themed

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

Pages

Набросаем базово необходимые страницы, и пользовательские ожидания от них:

  1. TasksListPage - страница "Список задач"

    • Смотреть список задач
    • Переходить к странице конкретной задачи
    • Помечать выполненной/невыполненной конкретную задачу
    • Задавать фильтрацию по выполненным/невыполненным задачам
  2. 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/>

Подготовка модулей к использованию

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

{layer}/foo/index.ts
export { FooCard, FooThumbnail, ... } from "./ui";
export * as fooModel from "./model";
info

Если вам нужны именованные экспорты неймспейсов для декларации Public API, можно посмотреть в сторону @babel/plugin-proposal-export-namespace-from

Либо же, как альтернатива, использовать более развернутую конструкцию

{layer}/foo/index.ts
import { FooCard, FooThumbnail, ... } from "./ui";
import * as fooModel from "./model";

export { FooCard, FooThumbnail, fooModel };

2.3 Отобразим базово список задач

(entities) Карточка задачи

entities/task/ui/task-row/index.tsx
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 по туториалу можно ознакомиться здесь

entities/task/model/index.ts
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) Соединим всю логику на странице

pages/tasks-list/index.tsx
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) Переключение статуса задачи

entities/task/model/index.ts
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) Чекбокс для задачи

features/toggle-task/ui.tsx
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) Внедряем чекбокс в страницу

Что примечательно - карточка задачи совсем не знает ни про страницу где используется, ни про то, какие кнопки-действия в нее могут вставляться (то же самое можно сказать и про саму фичу)

Такой подход позволяет одновременно грамотно разделять ответственность и гибко переиспользовать логику при реализации

pages/tasks-list/index.tsx
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) Фильтрация на уровне данных

entities/task/model/index.ts
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-контролы для фильтров

features/tasks-filters/ui.tsx
// Если чувствуете себя уверенно с @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) Внедряем фильтрацию в страницу

И мы снова реализовали логику, особо не задаваясь вопросами:

  • А куда положить логику фильтрации?
  • А могут ли эти фильтры переиспользоваться в будущем?
  • А могут ли фильтры знать про контекст страницы?

Мы просто разделили логику согласно зонам ответственности (слоям)

pages/tasks-list/index.tsx
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) Страница "Карточка задачи"

pages/task-details/index.tsx
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 уровне, в зависимости от масштабности

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

=> Добавляем фичи и вставляем их в карточку только на нужной странице

Какой-то модуль стал слишком сложным для поддержки?

=> Благодаря заложенной архитектуре, мы можем изолированно отрефакторить только этот модуль - без неявных сайд-эффектов для других (и даже переписать с нуля)

Итого

Мы научились применять методологию для базовых случаев

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

Мы получили масштабируемую и гибкую кодобазу

  1. Переиспользуемые и расширяемые модули

    • shared, features, entities
  2. Равномерное и предсказуемое распределение логики

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

    • Какие есть страницы?
      • TasksList, TaskDetails
    • Какие есть фичи? Что может пользователь?
      • ToggleTask TasksFilters
    • Какие есть бизнес-сущности? С чем ведется работа?
      • Task (TaskCard, ...)
    • Что можно переиспользовать из вспомогательного?
      • UIKit (Card, ...) API (tasksApi)

Пример

Ниже в Codesandbox представлен пример получившегося TodoApp, где можно подробно изучить финальную структуру приложения

См. также

[01/12] logo-mini