Всем привет! Меня зовут Дима, и я не люблю 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 примера.
Ссылки
Репозиторий с примерами.
Статья, Часть 1 - описание архитектуры, сущностей и принципов их взаимодействия.
Комментарии (7)
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? Я даже код в комментарии нормально отформатировать не могу.Yoskutik Автор
14.02.2022 10:46Спасибо за комментарий. По комментарию г-на@fljot, было решено в функции
onRemoveClick
вызывать функцию родительской ViewModel.Строгий режим отключен, можете посмотреть код.
И ещё на Mobx 6 можно уже давно писать без декораторов.
Я являюсь ярым сторонником декораторов, и в статье с описанием работы Моделей, я покажу пример, как они могут быть полезны. Но даже без отдельной сущности Модели мне кажется логичным назначать obsevable поля через декораторы, так как в таком случае в конструкторе не приходится дублировать описательную логику.
Куда делать разметка кода из wisiwyg редактора habr?
В блоке текста, пока вы не начали его вводить есть значок "+". Выберете там пункт "Код", а затем при написании кода выберете нужный язык
ogregor
14.02.2022 20:09Там в конструкторе добавляется только один метод. makeAutoObservable(this), а что до декораторов или их отсутствия привыкаешь и в ту и другую сторону. Отсутствие декораторов сокращает количество ненужного кода. Содержание не observable полей в модели, является большой редкостью, и это то же можно настроить.
Yoskutik Автор
15.02.2022 09:50Насколько я знаю makeAutoObservable не занимается настройкой оптимизации и всем полям назначает
@observable
без каких либо модификаторов. А использование@observable.ref
и@observable.shallow
может помочь уменьшить количество потребляемой памяти. К тому же документация говорит, что makeAutoObservable does not support subclassing, так что в данном случае он точно не подойдетAlexandroppolus
15.02.2022 11:18Насколько я знаю makeAutoObservable не занимается настройкой оптимизации и всем полям назначает
@observable
без каких либо модификаторов.у makeAutoObservable есть второй параметр, где можно уточнить для некоторых полей.
хотя мне тоже больше нравится с декораторами - декларативно, явно.
fljot
Дима, вы своим императивным автораном похерили нормальную
@computed
'ную декларативность и создали утечку памяти.И ещё
onRemoveClick
меняет публичные поля модели, что делает эту модель анемичной, а ведь она могла бы энкапсулировать доменную логику в себе и выдавать наружу более специализированный апи и readonly данные.Yoskutik Автор
Хех, знаете, вы определенно правы. Материала получилось много, так что немного в мелочах зарылся.
Да, решение с
@computed
выглядит гораздо более логичным. Выделение удаления записи вAppViewModel
- тоже правильное решение.Правда, по поводу анемичной модели, я не могу с вами согласиться с тем, что это прям плохо. Но доказывать то, что анемичная модель не всегда является антипаттерном в рамках данной статьи не буду. Мне кажется, это довольно серьезная тема для обсуждения.