Прокачайте свое взаимодействие с MobX. Часть 2

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

Всем привет! Меня зовут Дима, и я не люблю 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 });
  };
}

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 есть пропы.

Фильтрация происходит автоматически внутри функции autorun. Поля parent, viewProps и parent.todos являются observable, поэтому каждый раз при их обновлении функция внутри autorun будет вызываться заново, перезаписывая значение поля filteredData. При первом вызове 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 = () => runInAction(() => {
    viewModel.todos = viewModel.todos.filter(it => it.id !== item.id);
    viewModel.chosenTodo = null;
  });

  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 было выбрано такое написание компонента.

Думаю, тут ещё важно будет обозначить, зачем я применяю функцию runInAction. Дело в том, что я в одном обработчике обновляю сразу два observable поля. Обновляя их в action, MobX сможет более оптимально выстроить процесс реакций.

Когда нужно создавать 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 примера.

Ссылки

  • Реализация описанных сущностей: NPM пакет / GitHub.

  • Репозиторий с примерами.

  • Статья, Часть 1 - описание архитектуры, сущностей и принципов их взаимодействия.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
А нужны ли паттерны в Front-end разработке?
100% Да, они упрощают процесс разработки 2
0% Нет, паттерны нужны только для Backend'а 0
Проголосовали 2 пользователя. Воздержались 3 пользователя.
Источник: https://habr.com/ru/post/651231/


Интересные статьи

Интересные статьи

На днях газета Ведомости попросила нас поделиться историями выпускников-разработчиков, которые перешли в IT из совершенно других сфер. Они вошли в статью о том, как работодатели относятся к соискателя...
Как работает книжный бизнес сейчас и насколько реально простому айтишнику выпустить книгу, не имея подписчиков и статуса медийной личности? Сегодня — и после долгого перерыва — поговорим, наконец...
Перевод интересного лонгрида посвященного визуализации концепций из теории информации. В первой части мы посмотрим как отобразить графически вероятностные распределения, их взаимодействие и у...
Привет, %username%. Как я и обещал ранее, я немного пропал в связи со своей командировкой. Нет, она ещё не закончилась, но навеяла некоторые мысли, которыми я решил поделиться с тобой. ...
Сегодня мы поговорим о перспективах становления Битрикс-разработчика и об этапах этого пути. Статья не претендует на абсолютную истину, но даёт жизненные ориентиры.