Реализация архитектуры Redux на MobX. Часть 2: «Пример на MobX»

—

от автора

Ссылка на первую часть статьи: «Проблемные места Redux».

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

Я много раз читал, как кто-то попробовал MobX, у него код получился запутанным с не контролируемыми изменениями, после чего он продолжил писать на Redux. Для MobX нет рекомендованной архитектуры. Но при использовании и соблюдении в MobX строгой и однообразной (имеется ввиду одинаковой в различных участках проекта) архитектуры, можно получить понятный код с контролируемыми изменениями в сколь угодно большом проекте. Я опишу один из вариантов, как этого добиться. Отмечу, что последние 5 лет я работал только с REST-подобными API, поэтому код в статье заточен под работу с REST API.

Будет описано разделение на слои. Моя цель — не показать, как правильно реализовать каждый слой в приложении, а показать, что количество составляющих каждого слоя может быть гораздо меньше, чем в Redux, а их взаимодействие проще.

Подход будет рассмотрен на примере простого списка дел.
Код в примерах будет приведен не полностью. Полный пример кода находится в github и в codesanbox.
Структура папок проекта по большей части — Folder-by-feature. Если у вас проекте есть одна общая папка вроде ducks/stores, где находятся все редьюсеры/actions/stores, то структура папок у вас вряд ли хорошо масштабируется и вам стоит обратить внимание на структуру в моем примере. Суть такая, что файлы, которые относятся к конкретной feature/странице, стоит располагать рядом с ней, а не размещать в разных участках проекта.

Содержание

Пример слоя сторов на MobX

Как и в Redux, этот слой не зависит от других слоев.

Используется несколько сторов — один для работы со списком, другой для работы с формой, третий для работы с параметрами поиска (фильтрация, сортировка, пагинация). В статье приведен пример только одного стора. Не обязательно так разделять стор для каждой feature пока не будет видно, что от разделения будет польза. Без разделения, в моем случае объявление стора выглядело бы так: «BaseStore<TListItem, TEditItem, TSearchParams>», что как минимум затрудняет читабельность.

Для сторов и некоторых других программных сущностей (API, middleware) я буду использовать базовые классы. На github можно заметить, что у меня в папке todos (то есть в папке реализации конкретной страницы/feature) практически нет кода в файлах api.js, controllers.js, stores.js, т.к. общий код вынесен в базовые классы. Конечно, при разрастании кодовой базы, ситуация может измениться.

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

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

Пример стора
// Общие базовые типы // src/core/types/index.ts export type ObjectType = Record<string, unknown> | null | undefined; export type ErrorType = string | ObjectType; export interface IIdentifiable { id: number; }
// Базовый класс для сторов, хранящих списки объектов // src/core/store/BaseListStore.ts import { observable, action, computed, makeObservable } from 'mobx'; import { ErrorType, ObjectType, IIdentifiable } from 'core/types';  export interface IListState<TListItem extends IIdentifiable> {   results: TListItem[];   count?: number; // число элементов на сервере. Нужно для пэйджинга.   isLoading?: boolean;   error?: ErrorType; }  export default class BaseListStore<TListItem extends IIdentifiable> {   @observable   protected listState: IListState<TListItem> = {     results: [],   };    constructor() {     makeObservable<BaseListStore<TListItem>>(this);   }    @computed   get list(): TListItem[] {     return Array.isArray(this.listState.results)         ? this.listState.results         : [];   }    @action   setListState(list: IListState<TListItem>) {     this.listState = list;   }    @action   addToList(item: TListItem) {     this.list.push(item);   }    @action   updateListItem(item: TListItem) {     const foundTodo = this.list.find((i) => item && i.id === item.id);     if (foundTodo && item) {       Object.assign(foundTodo, item);     }   }   ... }  // для удобства экспортируется тип стора export type BaseListStoreType = BaseListStore<IIdentifiable>;

Далее стор для списка Todo. Пока-что нет необходимости создавать уникальных методов для функционала списка, поэтому вместо наследования можно воспользоваться обобщенным базовым классом BaseListStire<T>.

// src/pages/todos/stores.tsx import { IIdentifiable } from 'core/types';  export interface ITodoModel extends IIdentifiable {   title: string;   completed: boolean; }  export type TodoListStoreType = BaseListStore<ITodoModel>;

Сервисный слой (API и другие сервисы)

Чтобы избежать дублирования логики и не засорять код контроллеров, в отдельный слой вынесен код для взаимодействия с сервером. В примере используется библиотека axios. Данный слой ничего не знает о других слоях. Он ни в коем случае не должен изменять стор или читать из него. В Redux ничего не сказано про этой слой, но многие создают его, как и я.

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

В статье в качестве сервисного слоя приведены только api сервисы. Но могут быть и другие сервисы.

core/api/apiService.ts
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; import { ObjectType } from '../types';  axios.defaults.baseURL = process.env.REACT_APP_BASE_API_URL;  export type ApiServiceResponseType = Promise<AxiosResponse<any>>;  const apiService = {   get: function (     url: string,     config?: AxiosRequestConfig,   ): ApiServiceResponseType {     return axios.get(url, config);   },    post: function (     url: string,     data: ObjectType,     config?: AxiosRequestConfig,   ): ApiServiceResponseType {     return axios.post(url, data, config);   },    patch: function (     url: string,     data: ObjectType,     config?: AxiosRequestConfig,   ): ApiServiceResponseType {     return axios.patch(url, data, config);   },    delete: function (     url: string,     config?: AxiosRequestConfig,   ): ApiServiceResponseType {     return axios.delete(url, config);   }, };  export default apiService;

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

core/api/BaseApi.ts
import apiService from './apiService'; import { IResponseList, IResponseModel, IResponseError } from './types'; import { IIdentifiable, ObjectType } from '../types';  export default class BaseApi<T extends IIdentifiable> {   private readonly _apiUrl: string;    get apiUrl(): string {     return this._apiUrl;   }    constructor(apiUrl: string) {     this._apiUrl = apiUrl;   }    async getList(     params?: ObjectType,   ): Promise<IResponseList<T> | IResponseError> {     try {       const ret = await apiService.get(this._apiUrl, { params });       return { results: ret.data || [] };     } catch (error) {       return this.handleError(error);     }   }    async update(     modelData: { id: number },     params?: ObjectType,   ): Promise<IResponseModel<T> | IResponseError> {     try {       const ret = await apiService.patch(         `${this._apiUrl}/${modelData.id}`,         modelData,         { params },       );       return { model: ret.data };     } catch (error) {       return this.handleError(error);     }   }    protected handleError(e): IResponseError {     let message = '';     if (e.response) {       message = e.message;     }      return { isError: true, message };   } }  export type BaseApiType = BaseApi<IIdentifiable>;

Пример слоя controller (альтернатива Redux middleware в моем примере)

В своем коде вместо middleware я буду использовать термин из MVC — Controller. В Redux есть возможность объединять middlewares в цепочки. Т.к. это далеко не всегда нужно, я не стал без необходимости усложнять и не реализовывал у себя в контроллерах объединение действий в цепочки. К тому же, промежуточное ПО можно разместить при получении данных с сервера или при передачи данных из API в контроллер. Так оно будет располагаться в зависимости от своего назначения, а не вперемешку.

Основное назначение контроллера в данном подходе — быть посредником между api, стором и компонентом. То есть вызываться через компонент, вызывать api и методы обновления стора, а также содержать в методах логику, для выбора, какой api метод и стор использовать. Не стоит переусложнять бизнес-логикой контроллер. Общую для всех контроллеров бизнес-логику лучше выносить отдельно, как сделано в случае api. В идеале в больших проектах стоит стремиться к активной модели контроллера, но с другой стороны, в небольших проектах это может быть ненужным усложнением.

Контроллер используются только в слое View (компоненты) и в других контроллерах. Этот слой зависим от слоя сторов и слоя API.

Я использую базовый класс, в котором находятся несколько общих методов для обновления стора и для получения данных с сервера типичными CRUD методами.

src/core/controllers/BaseController.ts
import { BaseStoreType } from '../store/BaseStore'; import { SearchParamsStoreType } from '../store/SearchParamsStore'; import { BaseApiType } from '../api/BaseApi'; import IIdentifiable from '../types/IIdentifiable'; import ObjectType from '../types/ObjectType'; import { isIResponseError } from '../api/types'; import { toast } from 'react-toastify';  export default class BaseController {   private readonly _mainStore: BaseStoreType;   private readonly _searchParamsStore: SearchParamsStoreType;   private readonly _api: BaseApiType;    constructor(mainStore: BaseStoreType,                searchParamsStore: SearchParamsStoreType,                api: BaseApiType) {     this._mainStore = mainStore;     this._searchParamsStore = searchParamsStore;     this._api = api;   }    async getList() {     const searchParams = this._searchParamsStore.getSearchParamsMergedToJS();     const response = await this.api.getList(searchParams);     if (isIResponseError(response)) {       toast.error(response.message);     } else {       this.listStore.setListState({         results: response.results,         count: response.count,       });     }   }    async create(modelData: ObjectType) {     const response = await this.api.create(modelData);     if (isIResponseError(response)) {       toast.error(response.message);     } else {       await this.getList(); // for apply filters     }   }    setFilters = (filters: ObjectType) => {     this.searchParamsStore.setFilters(filters);   };   ... }

Пример инициализации сторов, api и controllers

Далее пример создания экземпляров классов сторов, api и контроллеров.

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

src/contexts.ts + src/App.tsx
import { createContext } from 'react'; import BaseListStore from 'core/store/BaseListStore'; import BaseEditStore from 'core/store/BaseEditStore'; import SearchParamsStore from 'core/store/SearchParamsStore'; import {   TodoListStoreType,   TodoEditStoreType,   TodoSearchParamsStoreType, } from './pages/todos/stores'; import { createTodoAPI } from './pages/todos/api'; import BaseController from 'core/сontrollers/BaseController'; import TodoPage from './pages/todos/views/Page';  export interface IStoresContextValue {   todoListStore: TodoListStoreType;   todoEditStore: TodoEditStoreType;   todoSearchParamsStore: TodoSearchParamsStoreType; }  export const StoresContext =    createContext<IStoresContextValue | null>(null)    as Context<IStoresContextValue>;  export const stores: IStoresContextValue = {   todoListStore: new BaseListStore(),   todoEditStore: new BaseEditStore(),   todoSearchParamsStore: new SearchParamsStore(), };  export interface IControllersContextValue {   todoController: BaseController; }  export const ControllersContext =    createContext<IControllersContextValue | null>(null)    as Context<IControllersContextValue>;  export const controllers: IControllersContextValue = {   todoController: new BaseController(     stores.todoListStore,     stores.todoEditStore,     stores.todoSearchParamsStore,     createTodoAPI('/todos'),   ), };  const App = () => {   return (     <div>       <StoresContext.Provider value={stores}>         <ControllersContext.Provider value={controllers}>           <TodoPage />         </ControllersContext.Provider>       </StoresContext.Provider>     </div>   ); };

В данном примере в использовании контекста нет необходимости. Можно было бы экспортировать объект со сторами и объект с контроллерами напрямую, а не через context. Честно говоря, я пока не вижу ситуации, где один из подходов работает, а другой нет.
Писать тесты с подменой сторов и контроллеров можно и без контекста. Например, в случае использования Jest, если у вас есть файл «srс/pageA/myStore.js», то в папке pageA надо создать папку __mocks__ и создать в ней файл myStore.js для использования его в тестах вместо оригинального файла. То есть расположить по такому пути: «srс/pageA/__mocks__ /myStore.js». А в файле с тестом (например: «srс/pageA/__tests__ /MyComponent.js») после import-ов достаточно написать «jest.mock(‘../myStore’);».

Пример использования стора и контроллера в компонентах

src/pages/todos/views/List.jsx
import { useEffect, useContext } from 'react'; import List from '@material-ui/core/List'; import ListItem from '@material-ui/core/ListItem'; import { observer } from 'mobx-react-lite'; import { ControllersContext, StoresContext } from 'contexts'; import { ITodoModel } from '../stores';  const TodoList = observer(() => {   const { todoListStore } = useContext(StoresContext);   const { todoController } = useContext(ControllersContext);      const handleChange = (item) => {     todoController.update({       id: item.id,       completed: !item.completed,     } as ITodoModel);   };      useEffect(() => {     todoController.getList();   }, []);   return (     <List>       {todoListStore.list.map((item) => (         <ListItem key={item.id} dense button>           ...         </ListItem>       ))}     </List>   ); });  export default TodoList;

Заключение. Схема архитектуры

Получилась архитектура со следующими составляющими:

  • Service (сервисы для работы с API, а также сервисы, в которые вынесен общий функционал для контроллеров)

  • Controller (для связи между API, сторами и компонентами)

  • Store (для работы с общими данными (состоянием) приложения)

  • View (компоненты)

Сравнение ее составляющих с Redux:

Redux

Services (опцио-нально)

Middle-ware’s

Action creators

Actions

Reducers

Selectors

Compo-nents

мой подход

Services

Controllers с функциями-действиями

Stores с функциями, аналогичными сеттерам и геттерам.

Compo-nents

Вместо 7-ми видов сущностей, которые нужно постоянно создавать, получилось 4-ре. Масштабируемость, на мой взгляд, примерно такая же.

Ниже изображены 2 схемы:
1) Схема зависимостей, отображающая, какие сущности использует такая-то сущность.

2) Схема потока данных, отображающая из каких сущностей в какие передаются данные. Под «get» имеется ввиду, что сущность сама запрашивают данные, а под «pass» имеется ввиду, что другая сущность является инициатором передачи данных.

Получившееся похоже на вариацию MV* с добавление стора. Вместо View Model как в MVVM, здесь используется стор, остальное же — обычное MVC.

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

Напоследок рассмотрю несколько ситуаций с использованием описанной архитектуры.

1. Что делать, если один стор должен использовать данные другого стора?
Я стараюсь избегать прямой связи одного стора с другим. Вместо этого я передаю эту обязанность контроллеру. В действии контроллера, которое должно обновить первый стор, считываю данные из второго стора и передаю их вместе с остальными данными в первый стор.

2. Что делать, когда наблюдаемые данные одного стора зависят от наблюдаемых данных другого стора и происходит обновление первого стора?
Я стараюсь избегать таких цепочек обновлений и выношу вычисления в контроллер. То есть
из сторов считываю необходимые данные, обрабатываю их, и затем передаю их сторам, использующим эти связанные данные.

3. Если в нескольких компонентах нужно вычислить и подписаться на значение, состоящее из данных нескольких сторов, можно вынести вычисление этого значения в отдельную функцию. Можно сделать custom hook или воспользоваться функцией MobX — computed. Спасибо @DmitryKazakov8 за его комментарий к предыдущей части статьи! После него решил добавить этот пункт.

4. Уменьшение бойлерплейта.

В описанном подходе, если для множества страниц приходиться писать однотипный функционал, можно написать обертку для инициализации и связывания экземпляров контроллеров, сторов, api.
Для примера, черновая версия у меня есть в отдельной ветке:
wrapFeature.ts (создает экземпляры переданных, либо базовых api, stores, controller для одной feature и возвращает их)
Пример использования

Важно не переусердствовать и не писать сложные универсальные решения.

Если вы не видите необходимости в использовании context или предпочитаете использовать import/export, то можно еще немного уменьшить количество кода.

ссылка на оригинал статьи https://habr.com/ru/post/546628/