Всем привет! Меня зовут Дима, и я не люблю Redux. Я люблю MobX. И в своем сборнике статей я показываю, как можно использовать MobX так, чтобы он стал ещё удобнее.

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

Без особо долгого вступления, с места в карьер, я предлагаю начать разбор примеров из прошлой статьи в том порядке, в котором они там выдавались. В самом первом примере описывается взаимодействие сущностей View, ChildView и ViewModel.

Первый пример является обычным списком дел - базовой задачей для Frontend-разработчика. В нем есть возможность добавлять новые дела, помечать их выполненными, удалять их, а также фильтровать их по строке поиска.

Корневым компонентом всего приложения является компонент App.

Рассмотрим его код
import React from 'react';
import { makeObservable, observable, action } from 'mobx';
import { injectable } from 'tsyringe';
import { view, ViewModel } from '@yoskutik/mobx-react-mvvm';
import { HBox, VBox } from '@components';
import { LeftPanel } from './LeftPanel';
import { RightPanel } from './RightPanel';
import '../Style.scss';

export type TTodoItem = {
  id: string;
  title: string;
  done: boolean;
};

@injectable()
export class AppViewModel extends ViewModel {
  @observable todos: TTodoItem[] = [];

  @observable.ref chosenTodo: TTodoItem = null;

  constructor() {
    super();
    makeObservable(this);
  }

  @action addNewTodo = (title: string) => {
    this.todos.push({ id: Math.random().toString(), title, done: false });
  };

  @action removeTodo = (id: string) => {
    this.todos = this.todos.filter((it) => it.id !== id);
    this.chosenTodo = null;
  };
}

export const App = view(AppViewModel)(({ viewModel }) => (
  <VBox style={{ margin: 30 }}>
    <h2>TODO List</h2>
    <HBox>
      <LeftPanel/>
      <RightPanel onAdd={viewModel.addNewTodo}/>
    </HBox>
  </VBox>
));

В файле с компонентом App я храню как View, так и ViewModel. Не вижу в таком хранении никаких проблем, если размер файла не становится слишком большим. Однако при желании можно, конечно, распределять их по разным файлам.

Первое, что бросается в глаза - сам код компонента App, который состоит только из JSX кода, в нем нет абсолютно никакой логики, ни единного вызова хука. При использовании <App/> и любого другого View передавать проп viewModel нельзя. Этот проп появляется благодаря HOC-функции view.

Теперь рассмотрим класс AppViewModel. Не сложно заметить, что напрямую App не использует некоторые поля своей ViewModel. И в рамках данной архитектуры это является нормальной практикой. Эти поля будут в дальнейшем использоваться в ChildView и в других ViewModel'ях.

AppViewModel имеет декоратор @injectable. В рамках взаимодействия View, ChildView и ViewModel этот декоратор не имеет особого смысла. Однако, он потребуется в дальнейшем при добавлении Сервисов. Декоратор @injectable может быть заменен на декоратор @singleton. Использовать ViewModel'и с таким декоратом рекомендуется только в исключительных случаях, так как информация, хранимая в таких ViewModel'ях не удаляется даже после удаления View из разметки.

Рассмотрим следующий компонент.

LeftPanel
export const LeftPanel = memo(() => {
  const [searchText, setSearchText] = useState('');

  return (
    <VBox style={{ marginRight: 10 }}>
      <SearchTodoField value={searchText} onChange={setSearchText} />
      <List searchText={searchText} />
    </VBox>
  );
});

Его я специально сделал в виде обычного компонента. Несмотря на то, что я придерживаюсь некоторой определенной архитектуры, нет абсолютно никаких проблем в использовании обычных компонент. MobX, MVVM и DI должны применяться только тогда, когда их применение может упростить процесс разработки.

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

List
import React, { VFC } from 'react';
import { injectable } from 'tsyringe';
import { makeObservable, observable, autorun, action } from 'mobx';
import { view, ViewModel } from '@yoskutik/mobx-react-mvvm';
import { VBox } from '@components';
import type { TTodoItem, AppViewModel } from '../App';

type ListProps = {
  searchText?: string;
};

@injectable()
class ListViewModel extends ViewModel<AppViewModel, ListProps> {
  @observable.shallow filteredData: TTodoItem[] = [];

  constructor() {
    super();
    makeObservable(this);

    autorun(() => {
      this.filteredData = this.parent?.todos.filter(it => (
        !this.viewProps?.searchText || it.title.toLowerCase().includes(this.viewProps.searchText)
      )) || [];
    });
  }

  @action onItemClick = (id: string) => {
    this.parent.chosenTodo = this.parent.todos.find(it => it.id === id);
  };
}

export const List: VFC<ListProps> = view(ListViewModel)(({ viewModel }) => (
  <VBox cls="list">
    {viewModel.filteredData.length ? (
      viewModel.filteredData.map(it => (
        <div key={it.id} onClick={() => viewModel.onItemClick(it.id)}
             className={`list__item ${it.done ? 'done' : ''} ${
               viewModel.parent.chosenTodo?.id === it.id ? 'chosen' : ''
             }`}>
          {it.title}
        </div>
      ))
    ) : (
      <div className="list__item">No items in todo list</div>
    )}
  </VBox>
));

В этом компоненте хранится основная логика приложения. Он должен взять данные, которые хранятся в AppViewModel, и отфильтровать их по строке, получаемой в пропах компонента.

List использует observable поля своей ViewModel, поэтому он должен быть observer-компонентом. И им он и является, так как по умолчанию view делает компонент observer'ом. Поэтому при измненении поля filteredData, компонент List будет обновляться автоматически. Также этот компонент смотрит на поле родительской ViewModel chosenTodo, чтобы подсветить выбранную пользователем запись.

List находится где-то внутри компонента App. Поэтому родительской ViewModel для данного компонента будет являться AppViewModel. Тип родительской ViewModel я передал дженериком. Также дженериком я указал, какие у компонента List есть пропы.

Фильтрация происходит автоматически внутри computed геттераfilteredData. В таких геттерах MobX автоматически отслеживает необходимые зависимости, и при их обновлении рассчитывает новое значение. При первом вызове autorun значения parent и viewProps будут undefined, поэтому при их использовании был использован оператор ?.

Также ListViewModel взаимодействует с родительской AppViewModel, обновляя значение chosenTodo.

Ещё вы могли заметить, что один из импортов импортирует только тип. Это было сделано не случайно. Компонент App где-то внутри себя использует компонент List. Поэтому при импортировании AppViewModel напрямую могут возникнуть циклические зависимости. А они могут изрядно попортить жизнь разработчику. Но стоит указать import type, и таких проблем не возникает.

ChildView: ChosenItem
import { runInAction } from 'mobx';
import React, { VFC } from 'react';
import { childView } from '@yoskutik/mobx-react-mvvm';
import { HBox, VBox } from '@components';
import type { AppViewModel } from '../App';

const Button: VFC<{ text: string; onClick: () => void }> = ({ text, onClick }) => (
  <button onClick={() => onClick()} style={{ marginRight: 10 }}>
    {text}
  </button>
);

export const ChosenItem = childView<AppViewModel>(({ viewModel }) => {
  const item = viewModel.chosenTodo;
  if (!item) return null;

  const onDoneClick = () => {
    item.done = !item.done;
  };

  const oRemoveClick = () => viewModel.removeTodo(item.id);

  return (
    <VBox>
      <div className={`list__item ${item.done ? 'done' : ''}`}>{item.title}</div>
      <HBox style={{ marginTop: 5 }}>
        <Button text={item.done ? 'Undone' : 'Done'} onClick={onDoneClick} />
        <Button text="Remove" onClick={oRemoveClick} />
      </HBox>
    </VBox>
  );
});

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

Этот компонент является ChildView, то есть он не создает дополнительную ViewModel, а просто ссылается на ViewModel того View, внутри которого он находится.

Логика этого компонента хранится в самом компоненте. Безусловно в данной ситуации можно было бы, как и в случае List создать дополнительную ViewModel, и держать логику в ней. Но для примера использования ChildView было выбрано такое написание компонента.

Когда нужно создавать ViewModel?

Я показал, что помимо связки View/ViewModel, в разметке могут быть обычные компоненты и ChildView, которые хранят в себе логику. Потому может родиться вопрос, а когда же нужно выделять логику в отдельный класс ViewModel? Ответ довольно прост.

  • Когда ViewModel может потребоваться в других дочерних ViewModel'ях

  • Когда во ViewModel будет необходимость в использовании Сервисов (об этом в следующей статье)

  • Когда лично Вы посчитаете выделение логики в отдельную ViewModel удобным.

Лично для себя я определил, что вызов пары хуков и создание пары функций не сильно визуально захломляют код компонента, поэтому в таких случаях я редко выделяю отдельную ViewModel. Но когда observable полей становится больше 3, а иногда даже больше 10, и когда функций-обработчиков становится много, выделение логики в отдельный класс кажется мне очень даже разумным.

Дополнительно

В моей реализации View и ChildView я добавил обертку из ErrorBoundary. Это было сделано, так как в React приложениях в случае, когда один из компонентов бросает исключение, и нигде нет обработки этой ошибки в форме ErrorBoundary (в таком случае обычный try/catch не сработает), все React приложение перестает работать.

В первой статье я говорил, что View и ChildView не обязательно должны быть observer'ами, так как могут использовать только статичные поля ViewModel'и или методы. И в своей реализации я тоже добавил такую возможность - функции view и childView вторым параметром принимает булевый параметр, говорящий о необходимости превращения компонента в observer. По умолчанию этот параметр равен true.

Резюмируя

Кратко перечислю все возможные полезные use case'ы описываемой архитектуры:

  • Между логикой и отображением можно проводить четкую грань в виде разделения на View и ViewModel

  • По большей части можно отказаться от использования контекста. Вместо него можно создавать View и ChildView, которые способны взаимодействовать с родительской ViewModel.

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

    При этом если у View много параметров, а реакцию нужно навесить на изменение кого-то одного определенного поля, во ViewModel можно создать @computed get, который бы ссылался на определенный проп.

  • Разработчику нужно меньше заботиться об обработке ошибок. Если на одном из узлов (View или ChildView) произойдет ошибка, из разметки пропадет только сам узел, а не все приложение.

  • У ViewModel есть возможность сохранять свое состояние, даже когда View уходит из разметки. Это бывает полезно, если, например, при переключении между страницами нужно не перезапрашивать данные. Для это нужно указать декоратор @singleton, вместо @injectable.

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

    Однако, в моей реализации по большей части для Singleton ViewModel'ей есть поле isActive, по которому можно удобно отслеживать, отображается ли в данный момент View или нет.

  • В своих примерах я этого не показал, но у ViewModel есть возможность создания обработчиков монтирования и размонтирования View - onViewMount и onViewUnmount.

Послесловие

В этой статье я описал только один из примеров первой статьи. В следующей статье я разберу оставшиеся 2 примера.

Ссылки

Комментарии (7)


  1. fljot
    14.02.2022 01:01
    +2

    Дима, вы своим императивным автораном похерили нормальную @computed'ную декларативность и создали утечку памяти.
    И ещё onRemoveClick меняет публичные поля модели, что делает эту модель анемичной, а ведь она могла бы энкапсулировать доменную логику в себе и выдавать наружу более специализированный апи и readonly данные.


    1. Yoskutik Автор
      14.02.2022 10:40

      Хех, знаете, вы определенно правы. Материала получилось много, так что немного в мелочах зарылся.

      Да, решение с @computed выглядит гораздо более логичным. Выделение удаления записи в AppViewModel - тоже правильное решение.

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


  1. kubk
    14.02.2022 10:27

    Вместо "const oRemoveClick = () => runInAction(() => { ... })" можно писать просто "const onRemoveClick = action(() => { ... })"

    А вот тут мутация observable вне экшена, что будет выдавать warning в Mobx 6, если вы только строгий режим не отключали:

    const onDoneClick = () => {
    item.done = !item.done;
    };

    И ещё на Mobx 6 можно уже давно писать без декораторов.

    UPD: Куда делать разметка кода из wisiwyg редактора habr? Я даже код в комментарии нормально отформатировать не могу.


    1. Yoskutik Автор
      14.02.2022 10:46

      Спасибо за комментарий. По комментарию г-на@fljot, было решено в функции onRemoveClick вызывать функцию родительской ViewModel.

      Строгий режим отключен, можете посмотреть код.

      И ещё на Mobx 6 можно уже давно писать без декораторов.

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

      Куда делать разметка кода из wisiwyg редактора habr?

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


      1. ogregor
        14.02.2022 20:09

        Там в конструкторе добавляется только один метод. makeAutoObservable(this), а что до декораторов или их отсутствия привыкаешь и в ту и другую сторону. Отсутствие декораторов сокращает количество ненужного кода. Содержание не observable полей в модели, является большой редкостью, и это то же можно настроить.


        1. Yoskutik Автор
          15.02.2022 09:50

          Насколько я знаю makeAutoObservable не занимается настройкой оптимизации и всем полям назначает @observable без каких либо модификаторов. А использование @observable.ref и @observable.shallow может помочь уменьшить количество потребляемой памяти. К тому же документация говорит, что makeAutoObservable does not support subclassing, так что в данном случае он точно не подойдет


          1. Alexandroppolus
            15.02.2022 11:18

            Насколько я знаю makeAutoObservable не занимается настройкой оптимизации и всем полям назначает @observable без каких либо модификаторов.

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

            хотя мне тоже больше нравится с декораторами - декларативно, явно.