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

Туториал

Рассмотрим применение Feature-Sliced Design на примере TodoApp

  • Сначала разберем подготовительные аспекты создания приложения
  • А затем - как концепции методологии помогают гибко и эффективно проектировать бизнес-логику без лишних затрат

В конце статьи есть codesandbox-вставка с финальным решением, которое может помочь для уточнения деталей реализации

Стек: React, Effector, TypeScript, Sass, AntDesign

Туториал призван раскрыть практическую идею самой методологии. Поэтому описанные здесь практики - во многом подойдут и для других технологических стеков фронтенд-проектов

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);

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

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

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

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

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

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

const TestPage = () => {
    return <div>Test Page</div>;
};

export default TestPage;
Сформируем роуты
// Либо использовать @loadable/component, в рамках туториала - некритично
import { lazy } from "react";
import { Route, Routes, Navigate } from "react-router-dom";

const TestPage = lazy(() => import("./test"));

export const Routing = () => {
    return (
        <Routes>
            <Route path="/" element={<TestPage/>} />
            <Route path="*" element={<Navigate to="/" />} />
        </Routes>
    );
};
Подключаем роутинг к приложению
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

Но вы можете использовать любой другой UIKit или же создать собственный, расположив компоненты в shared/ui - именно там рекомендуется хранить UIKit приложения:

import { Checkbox } from "antd"; // ~ "shared/ui/checkbox"
import { Card } from "antd"; // ~ "shared/ui/card"

2. Реализация бизнес-логики

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

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/>

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

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

export { Card as FooCard, Thumbnail as FooThumbnail, ... } from "./ui";
export * as fooModel from "./model"; 

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

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

import { Card as FooCard, Thumbnail as 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 - можете сразу использовать его
// В рамках туториала некритично
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) Фильтрация на уровне данных

import { combine, createEvent, createStore } from "effector";

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 - можете сразу использовать его
// В рамках туториала некритично
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>

К текущему этапу, такое разбиение может показаться излишним - "Почему бы не положить все сразу на уровне страницы / фичи"?

Но тогда попробуем задать себе вопросы:

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

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

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 уровне, в зависимости от масштабности

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

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

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

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

Итого

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

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

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

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

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

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

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

Пример

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

См. также