
Читая чат русскоязычного 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-а.
