Читая чат русскоязычного react сообщества в телеграмме (https://t.me/react_js), я вижу как с постоянной регулярностью появляются обсуждения mobx-а, сравнения с redux-ом с аргументациями про магию, сложность и "мутабельность" и у многих есть большое недопонимание что такое mobx и какие задачи он решает. И я решил написать эту статью с "разбором полетов" чтобы можно было собрать всю аргументацию в одном посте. Мы разберем как работает mobx изнутри путем реализации собственной версии mobx-а и сравним с тем как работает redux.
Для начала mobx, несмотря на то что его сравнивают с другими библиотеками как библиотека для управления состоянием, не предоставляет практически никаких удобств для работы с состоянием за исключением вызова обновления компонентов реакта после того как меняется свойство помеченное @observable
декоратором. Мы можем легко выбросить mobx убрав все @observable
и @observer
декораторы и получить работающее приложение, добавив всего одну строчку update()
во конце всех обработчиков событий где мы меняем данные состояния которые выводятся в компонентах.
onCommentChange(e){
const {comment} = this.props;
comment.text = e.target.value;
update(); //добавили одну строчку
}
а функция update() просто вызовет "перерендер" реакт-приложения и благодаря виртуальному думу реакта в реальном думе применится только diff изменений
function update(){
ReactDOM.render(<App>, document.getElementById('root');
}
Говорить что mobx это целый стейт-менеджер потому что позволяет сэкономить одну строчку update()
в обработчиках как-то чересчур.
В отличии от него redux позволяет удобно организовывать работу с состоянием через event-sourcing паттерн когда мы не обновляем состояние на месте а "диспатчим" объект изменения (action) и обрабатываем в совсем другом месте — в так называемых чистых функциях-редюсерах, а благодаря единой шине событий мы можем добавлять какую-то удобную работу с асинхронностью, перехватывая эти actions в конвейере middleware-ов и упростить дебаг приложение через time-travel фичу.
То есть mobx это не та библиотека которая упрощает работу с состоянием — так в чем его основная задача? Его основная задача — это точечное обновление компонентов, а именно — вызывать обновление только тех компонентов которые зависят от данных которые поменялись.
В примере выше каждый раз когда меняется любые данные в приложении мы выполняем "перерендер" (сравнение виртуального дума) всего приложения, вызывая ReactDOM.render(<App>, document.getElementById('root'))
в функции update()
и, как можно догадаться, это влияет на производительность, и на больших приложениях интерфейс неизбежно будет тормозить.
Несмотря на то что react изобрел виртуальный дум со слоганом что реальный дум медленный, а виртуальный быстрый потому что он сравнивает только деревья объектов в памяти, а в реальном думе обновляет только измененные части, в реальности мы не можем при любом обновлении данных в приложении вызвать это сравнение виртуального дума для всего приложения потому что это медленно.
И тогда решением проблемы будет не полагаться на виртуальный дум и обновлять компоненты вручную, вызывая this.forceUpdate()
только тех компонентов в которых поменялись данные которые они выводят.
И вот эту проблему как раз и решает библиотека mobx и часть библиотеки redux.
Но давайте попробуем решить задачу точечного обновления компонентов не беря во внимания эти две библиотеки.
Тут можно придумать два подхода и оба они будут накладывать ограничения на то как мы работаем с состоянием.
Первый подход — это воспользоваться иммутабельностью и двоичным поиском — если каждое обновление состояния будет возвращать новые объекты данных которые изменились и всех родительских объектов (для случая когда состояние имеет иерархическую структуру) то тогда мы можем добиться почти точечного обновления компонентов путем сравнения ссылок на предыдущее и новое состояние и пропускать все поддеревья компонентов данные которых не изменились (newSubtree === oldSubtree) и в результате мы обновим наше приложение вызовав перерендер только нужных компонента сравнив при этом данные только O(log(n)) компонентов где n — это количество компонентов.
Так например работает ангуляр если выставить ему настройку ChangeDetectionStrategy.OnPush
. Но у решения спуска сверху-вниз есть пара недостатков. Во первых — несмотря на эффективность O(log(n)), если какой-то компонент выводит список других компонентов, то мы вынуждены пробежаться по всему массиву компонентов, чтобы у них сравнить их пропсы, и, если каждый компонент списка рендерит еще один список, то количество сравнений еще больше возрастает. Во вторых — компонент должен зависеть только от своих пропсов которые часто приходится прокидывать вложенным компонентам через промежуточные.
Также иммутабельный подход применяет и библиотека redux, но только слегка в измененном виде, решая недостаток с зависимостью только от пропсов. Помимо сравнения пропсов, redux сравнивает также и дополнительные данные которые вернула функция mapStateToProps()
(в connect
декораторе) в которой мы указываем зависимость от разных частей состояния и дальше они становятся дополнительными пропсами. Но для этого redux вынужден выполнить проверку всех n подключенных компонентов. Но даже это все равно быстрее чем делать обновление (ReactDOM.render(<App>, rootEl);
) всего приложения.
Но у иммутабельного подхода есть пара серьезных недостатков которые накладывают ограничения на работу с состоянием.
Первый недостаток — это то, что мы не можем теперь просто взять и обновить любое свойство объекта данных в приложении. Из-за требования возвращать каждый раз новый иммутабельный объект целого состояния, нам нужно вернуть новый объект и также пересоздать все родительские объекты и массивы. Например, если объект состояния хранит массив проектов, каждый проект хранит массив задач, и каждая задача хранит массив комментариев:
let AppState = {
projects: [
{..},
{...},
{name: 'project3', tasts: [
{...},
{...},
{name: 'task3', comments: [
{...},
{...},
{text: 'comment3' }
]}
]}
]
}
То для того чтобы обновить текст у объекта комментария мы не можем просто выполнить comment.text = 'new text'
— нам нужно выполнить сначала пересоздание объекта комментария (comment = {...comment, text: 'updated text'}
), дальше нужно пересоздать объект задачи и скопировать у туда ссылки на другие комментарии (task = {...task, tasks: [...task.comments]}
), дальше пересоздать объект проекта и скопировать туда ссылки на другие задачи (project = {...project, tasks: [...project.tasks]}
) и в конце уже пересоздать объект состояние и также скопировать ссылки на другие проекты (AppStat = {...AppState, projects: [...AppState.projects]}
).
Второй недостаток — это невозможность хранить в состоянии объекты которые ссылаются друг на друга. Если нам где-то в обработчике компонента нужно получить проект в котором он находится задача — то мы не можем при создании объекта просто присвоить ссылку на родительский проект — task.project = project
потому что необходимость при иммутабельном подходе возвращать новый объект не только задачи но и проекта приводит к тому что нам нужно обновить все остальные задачи в проекте — ведь ссылка на объект проекта поменялась, а значит нужно выполнить обновление всех задач, присвоив новую ссылку, а обновление как мы знаем нужно выполнить через пересоздание объекта, а если задачи хранят комментарии, нам нужно выполнить пересоздание всех комментариев, потому что они хранят ссылку на объект задачи, и так рекурсивно мы придем к пересозданию всего состояния и это будет ужасно медленно.
В итоге нам приходится либо каждый раз изменять пропсы вышестоящих компонентов чтобы передать нужный объект, либо вместо ссылок на объект сохранить айдишник task.project = '12345';
а потом где-то хранить и поддерживать хеш проектов по их айдишнику ProjectHash['12345'] = project;
Поскольку решение с иммутабельностью имеет кучу недостатков давайте подумаем можно ли решить задачу точечного обновления компонентов другим способом? Нам нужно при изменении данных в приложении выполнить перерендер только тех компонентов которые зависят от этих данных. Что значит зависят? Например есть простой компонент комментария который рендерит текст комментариия
class Comment extends React.Component {
render(){
const {comment} = this.props;
return <div>{comment.text}</div>
}
}
этот компонент зависит от comment.text
и его нужно обновить каждый раз когда меняется comment.text
. Но также если компонент выводит <div>{comment.parent.text}</div>
но теперь нужно обновлять компонент каждый раз когда изменится не только .text
но и .parent
. Решить эту задачу мы можем не применяя никакого иммутабельного подхода а задействовав возможности геттеров и сеттеров javascript и это второй из известных мне подходов решить задачу точечного обновления ui.
Геттеры и сеттеры — это довольно старая возможность javascript поставить свой обработчик на обновление свойства или получение значение свойства:
Object.defineProperty(comment, 'text', {
get(){
console.log('>text getter');
return this._text;
},
set(val){
console.log('>text setter');
this._text = val;
}
})
comment.text; // выведет в консоль >text getter
comment.text = 'new text' // выведет в консоль >text setter
Итак, мы можем поставить на сеттер функцию которая будет выполнятся каждый раз когда выполняется присвоение нового значение и будем вызывать перерендер списка компонентов которые зависят от этого свойства. Для того чтобы узнать какие компоненты от каких свойств зависят нужно перед в начале функции render()
компонента присвоить в некую глобальную переменную текущий компонент, а при вызове геттера любого свойства объекта нужно добавить в список зависимостей этого свойства текущий компонент который находится в глобальной переменной. И поскольку компоненты могут "рендерятся" древовидно надо еще не забывать возвращать назад в эту глобальную переменную предыдущий компонент.
let CurrentComponent;
class Comment extends React.Component {
render(){
const prevComponent = CurrentComponent;
CurrentComponent = this;
const {comment} = this.props;
var result = <div>{comment.text}</div>
CurrentComponent = prevComponent;
return result
}
}
comment._components = [];
Object.defineProperty(comment, 'text', {
get(){
this._components.push(CurrentComponent);
return this._text
},
set(val){
this._text = val;
this._components.forEach(component => component.setState({}))
}
})
Надеюсь идею вы уловили. При таком подходе каждое свойство будет хранить массив своих зависимых компонентов и при изменении свойства будет вызывать их обновление.
Теперь для того чтобы не смешивать хранение массива зависимых компонентов с данными и для упрощения кода вынесем логику такого свойства в класс Cell, который, как можно понять из аналогии, очень похож на принцип работы ячеек в excel — если другие ячейки содержат формулы от которых зависит текущая ячейка то нужно при изменении значения вызвать обновления всех зависимых ячеек.
let CurrentObserver = null;
class Cell {
constructor(val){
this.value = val;
this.reactions = new Set(); //для простоты и скорости воспользуеся классом множейства из es6 стандарта
}
get(){
if(CurrentObserver){
this.reactions.add(CurrentObserver);
}
return this.value;
}
set(val){
this.value = val;
for(const reaction of this.reactions){
reaction.run();
}
}
unsibscribe(reaction){
this.reactions.delete(reaction);
}
}
А вот роль ячейки c формулой будет играть класс ComputedCell
который наследуется от класса Cell
(потому что от этой ячейки может зависеть и другие ячейки). Класс ComputedCell
принимает в конструкторе функцию (формулу) для пересчета и также опционально функцию для выполнения сайд-эффектов (как например вызов .forceUpdate()
компонентов)
class ComputedCell extends Cell {
constructor(computedFn, reactionFn, ){
super(undefined);
this.computedFn = computedFn;
this.reactionFn = reactionFn;
}
run(){
const prevObserver = CurrentObserver;
CurrentObserver = this;
const newValue = this.computedFn();
if(newValue !== this.value){
this.value = newValue;
CurrentObserver = null;
this.reactionFn();
this.reactions.forEach(r=>r.run());
}
CurrentObserver = prevObserver;
}
}
А теперь для того чтобы не выполнять каждый раз установку геттеров и сеттеров мы воспользуемся декораторами из typescript или babel. Да, это накладывает ограничения на необходимость использование классов и создание объектов не через литерал const newComment = {text: 'comment1'}
а через const comment = new Comment('comment1')
но зато вместо ручной установки геттеров и сеттеров мы можем удобно пометить свойство как @observable
и дальше работать с ним как с обычным свойством.
class Comment {
@observable text;
constructor(text){
this.text = text;
}
}
function observable(target, key, descriptor){
descriptor.get = function(){
if(!this.__observables) this.__observables = {};
const observable = this.__observables[key];
if (!observable) this.__observables[key] = new Observable()
return observable.get();
}
descriptor.set = function(val){
if (!this.__observables) this.__observables = {};
const observable = this.__observables[key];
if (!observable) this.__observables[key] = new Observable()
observable.set(val);
}
return descriptor
}
А для того чтобы не работать напрямую с классом ComputedCell
внутри компонента, мы можем вынести этот код в декоратора @observer
, который просто оборачивает метод render()
и создает при первом вызове вычисляемую ячейку, передавая в качестве формулы метод render()
а в качестве функции-реакции вызов this.forceUpdate()
(в реальности нужно еще добавить отписку в методе componentWillUnmount()
и некоторые моменты правильного оборачивания компонентов реакта, но оставим пока для простоты понимания такой вариант)
function observer(Component) {
const oldRender = Component.prototype.render;
Component.prototype.render = function(){
if (!this._reaction) this._reaction = new ComputedCell(oldRender.bind(this), ()=>this.forceUpdate());
return this._reaction.get();
}
}
и будем использовать как
@observer
class Comment extends React.Component {
render(){
const {comment} = this.props;
return <div>{comment.text}</div>
}
}
Ссылка на демку
import React from 'react';
import { render } from 'react-dom';
let CurrentObserver;
class Cell {
constructor(val) {
this.value = val;
this.reactions = new Set();
}
get() {
if (CurrentObserver) {
this.reactions.add(CurrentObserver);
}
return this.value;
}
set(val) {
this.value = val;
for (const reaction of this.reactions) {
reaction.run();
}
}
unsubscribe(reaction) {
this.reactions.delete(reaction);
}
}
class ComputedCell extends Cell {
constructor(computedFn, reactionFn) {
super();
this.computedFn = computedFn;
this.reactionFn = reactionFn;
this.value = this.track();
}
track(){
const prevObserver = CurrentObserver;
CurrentObserver = this;
const newValue = this.computedFn();
CurrentObserver = prevObserver;
return newValue;
}
run() {
const newValue = this.track();
if (newValue !== this.value) {
this.value = newValue;
CurrentObserver = null;
this.reactionFn();
}
}
}
function observable(target, key) {
return {
get() {
if (!this.__observables) this.__observables = {};
let observable = this.__observables[key];
if (!observable) observable = this.__observables[key] = new Cell();
return observable.get();
},
set(val) {
if (!this.__observables) this.__observables = {};
let observable = this.__observables[key];
if (!observable) observable = this.__observables[key] = new Cell();
observable.set(val);
}
}
}
function observer(Component) {
const oldRender = Component.prototype.render;
Component.prototype.render = function(){
if (!this._reaction) this._reaction = new ComputedCell(oldRender.bind(this), ()=>this.forceUpdate());
return this._reaction.get();
}
}
class Timer {
@observable count;
constructor(text) {
this.count = 0;
}
}
const AppState = new Timer();
@observer
class App extends React.Component {
onClick=()=>{
this.props.timer.count++
}
render(){
console.log('render');
const {timer} = this.props;
return (
<div>
<div>{timer.count}</div>
<button onClick={this.onClick}>click</button>
</div>
)
}
}
render(<App timer={AppState}/>, document.getElementById('root'));
В нашем примере есть один недостаток — что если зависимости компонента могут меняться? Взглянем на следующий компонент
class User extends React.Component {
render(){
const {user} = this.props;
return <div>{user.showFirstName ? user.firstName : user.lastName}</div>
}
}
Компонент зависит от свойства user.showFirstName
и дальше в зависимости от значение может зависеть либо от user.firstName
либо от user.lastName
, то есть если user.showFirstName == true
, то мы не должны реагировать на изменение user.lastName
и наоборот если user.showFirstName
поменялось на false то мы не должны реагировать (и делать перерендер компонента) если меняется свойство user.firstName
;
Этот момент легко решается путем добавления списка зависимостей this.dependencies = new Set()
в класс ячейки и небольшой логики в функцию run()
— чтобы после вызова render() реакта мы сравнили предыдущий список зависимостей с новым и отписались от неактуальных зависимостей.
class Cell {
constructor(){
...
this.dependencies = new Set();
}
get() {
if (CurrentObserver) {
this.reactions.add(CurrentObserver);
CurrentObserver.dependencies.add(this);
}
return this.value;
}
}
class ComputedCell {
track(){
const prevObserver = CurrentObserver;
CurrentObserver = this;
const oldDependencies = this.dependencies; //сохраняем список текущих зависимостей
this.dependencies = new Set(); //заменяем на пустое множество в которое будут добавляться новые зависимости
const newValue = this.computedFn();
//отписываемся от зависимостей которых нет в новом списке
for(const dependency of oldDependencies){
if(!this.dependencies.has(dependency)){
dependency.unsubscribe(this);
}
}
CurrentObserver = prevObserver;
return newValue;
}
}
Второй момент — что если мы сразу меняем много свойств в объекте? Поскольку зависимые компоненты будут обновляться синхронно мы получим два лишних обновления компонента
comment.text = 'edited text'; //произойдет первый перередер компонента
comment.editedCount+=1; //будет второй перерендер компонента
Чтобы избежать лишних обновлений, в начале этой функции мы можем поставить глобальных флаг а наш @observer
декоратор не будет сразу вызывать this.forceUpdate()
а вызовет только тогда когда мы уберем этот флаг. И для упрощения мы вынесем эту логику в декоратор action
и вместо флага будем увеличивать или уменьшать счетчик потому что декораторы могут вызываться внутри других декораторов.
updatedComment = action(()=>{
comment.text = 'edited text';
comment.editedCount+=1;
})
let TransactionCount = 0;
let PendingComponents = new Set();
function observer(Component) {
const oldRender = Component.prototype.render;
Component.prototype.render = function(){
if (!this._reaction) this._reaction = new ComputedCell(oldRender.bind(this), ()=>{ TransactionCount ?PendingComponents.add(this) : this.forceUpdate() });
return this._reaction.get();
}
}
function action(fn){
TransactionCount++
const result = fn();
TransactionCount--
if(TransactionCount == 0){
for(const component of PendingComponents){
component.forceUpdate();
}
}
return result;
}
В итоге такой подход c использованием очень старого паттерна "observer" (не путать с observable RxJS) намного лучше подходит для реализации задачи точечного обновления компонентов чем подход с использованием иммутабельности.
Из недостатков можно заметить только необходимость создавать объекты не через литералы а через классы, а это значит что мы не можем просто принять какие-то данные от сервера и передать компонентам — необходимо провести дополнительную обработку данных оборачивая в объекты классов с @observable
декораторами.
Также к недостаткам можно записать невозможность добавлять новые свойства к объектам на лету (хотя это и так считается антипаттерном с точки зрения производительности js), неудобства дебага кода в chrome devtools потому что данные скрыты за геттерами и вместо значений мы будем видеть три точки и чтобы увидеть значение на кликнуть на это свойство, и также попытка выполнить по шагам любое изменение или получение свойства будет переносить нас в глубь сеттера или геттера внутри библиотеки.
Но достоинства намного превышают недостатки. Во первых — в отличии от иммутабельного подхода скорость работы никак не зависит от количества компонентов потому что мы сразу знаем список компонентов которые надо обновить — а значит имеем сложность o(1) вместо o(log(n)) или o(n) как заметил Ден Абрамов и что более важно — не происходит создание n-объектов в функции mapStateToProps
. Во вторых — когда нам нужно обновить какие-то данные мы можем просто написать comment.text = 'new text'
и нам не придется выполнять еще кучу работы по обновлению родительских объектов состояния, и что важно — не будет нагрузки на сборщик мусора из-за постоянного пересоздания объектов. Ну и главное — мы можем моделировать состояния с помощью объектов которые ссылаются друг на друга и сможем удобно работать с состоянием без необходимости хранить вместо объекта айдишник а потом вытаскивать каждый раз из хеша AppState.folders[AppState.projects[AppState.tasks[comment.taskId].projectId].folderId].name
вместо простого обращения по ссылке comment.task.project.folder.name
Вывод
Если вы разобрались в этих примерах — то поздравляю — вы теперь понимаете как работает изнутри "магия" mobx. И если не брать во внимание наличие в mobx @computed
декоратора который делает умную мемоизацию и не будет пересчитывать значение несколько раз в процессе инвалидации (эта оптимизация достойна отдельной статьи) и разных хелперов то мы только что реализовали весь механизм обсерверов mobx-а и выяснили что их работа проста и предсказуема и разобрались в преимуществах подхода с обсерверами против иммутабельного подхода для реализации задачи точечного обновления компонентов react-а.
Автор: bgnx