Читая чат русскоязычного 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-а.
Комментарии (39)
inoyakaigor
21.10.2017 01:49Вот что мне не нравится в подходе с мутабельностью данных так это то, что это порождает целый класс ошибок. Например у нас есть некий объект в котором хранится начальное состояние приложения. Юзер взаимодействуя с приложением изменит этот объект и, если мы не предусмотрели очистку, то данные сохранятся в исходном объекте и могут попасть туда где им не положено быть.
Как пример условная кнопка «Написать (какое-то особое) письмо» которая создаёт особой формы форму (извиняюсь за тавтологию) с предзаполненными дефолтными значениями полями.
В тоже время иммутабельный подход исключает такое поведение в принципе.
Что касается mobx, то мне кажется плохой идеей подменять примитивы какими-то своими объектами. Например в mobx вместо Array используется ObservableArray который не пройдёт проверку на typeof someVariable == Array. С другой стороны, иного способа реализовать такое же поведение я не знаю. Может быть Proxy поможет, но я сильно в этом сомневаюсь.Akuma
21.10.2017 10:29С первым как-то не сталкивался. Просто пишу отдельные сторы для каждого «класса» данных, которые работают сами-посебе и о внешнем мире не знают: и вроде бы нет подобных проблем.
Для проверок просто использую самописную is_array() в которой проводится так же проверка на isObservableArray(). Тоже проблем не возникает.
gnaeus
21.10.2017 19:01Может быть Proxy поможет
Vue 3-й версии как раз переписывают на Proxy (сейчас там все работает как в Mobx).
Так что особый ObservableArray это скорее всего временное решение, которое умрет вместе с IE.
Основная фишка MobX – это не пляски с геттерами-сеттерами, а реактивная парадигма. Вон, Knockout вообще использует
obj.prop()
как геттер иobj.prop(value)
как сеттер.
Да, неудобно. Зато работает со времен IE 6 (не к ночи будь помянут).
Но сама идея библиотеки точно такая же.
vtvz_ru
21.10.2017 02:35Активно в проекте использую redux. Полюбился он мне. Многие ругают его за многословность и шаблонность. Поставил redux-actions и радуюсь жизни. Асинхронная логика лежит в саге, в редких случаях в middleware. Данные храню нормализовано, для выборок использую селекторы. Зато все прозрачно. Проблемы с дебагом возникают только в генераторах.
Ashot
21.10.2017 02:36… мне кажется плохой идеей подменять примитивы какими-то своими объектами. Например в mobx вместо Array используется ObservableArray
Так Array это и не примитив
… вместо Array используется ObservableArray который не пройдёт проверку на typeof someVariable == Array
А что вообще пройдет такую проверку, если
typeof [] === 'object"
?
Не холивара ради, просто правда не понял этих аргументов
alex_blank
21.10.2017 02:44Вероятно, имелось в виду что-то такое, а не typeof:
Array.isArray([1, 2, 3]); // true Array.isArray({foo: 123}); // false Array.isArray('foobar'); // false Array.isArray(undefined); // false
alex_blank
21.10.2017 02:43Несмотря на то что react изобрел виртуальный дум со слоганом что реальный дум медленный, а виртуальный быстрый потому что он сравнивает только деревья объектов в памяти, а в реальном думе обновляет только измененные части, в реальности мы не можем при любом обновлении данных в приложении вызвать это сравнение виртуального дума для всего приложения потому что это медленно.
Это не так, если использовать иммутабельность, чистые функции и мемоизацию (для исключения перерендера самого VDOM когда исходные данные не поменялись). С иммутабельностью достаточно легко перендерить только поменявшиеся части, достаточно сравнить ссылки при траверсинге структуры вглубь!
noneim
21.10.2017 03:20Если мы определили список компонентов, которые необходимо обновить, то также нужна их иеархичность:
например компонент A содержит в себе компонент B, который содержит в себе компонент C.
Если вдруг оказалось, что надо обновить все эти 3 компонента — то важна последовательность, т.к. нет смысла запускать обновление компонента С вначале, т.к. вполне может оказаться так, что в процессе обновления компонента B компонент C просто будет удален.
А теперь другой вариант — если компонент A отмечен к обновлению, но изменений в компоненте B не замечено, то при обновлении компонента A будет обновлен также и компонент B (не знаю точно, реализовано ли в mobx прослеживание этой ситуации в функции shouldComponentUpdate компонента B)bgnx Автор
21.10.2017 11:45Да, вы совершенно правы, mobx в
@observer
декораторе действительно переопределяетshouldComponentUpdate
возвращаяtrue
если не изменились пропсы потому что вложенные компоненты будут обновляться отдельно. Я просто забыл добавить этот момент в статье. Более того для того чтобы не получилась ситуация когда мы обновляем более глубокий компонент раньше его родителя (в случае когда нужно обновить обоих) mobx использует специальный метод react-а ReactDOM.unstable_batchedUpdates который обновит компоненты в правильном порядке. В redux кстати существует точно такая же проблема и необходимо вручную добавлять middlerare с вызовом unstable_batchedUpdates (https://github.com/reactjs/redux/issues/1415)bgnx Автор
21.10.2017 14:16Тьху, я перепутал — в shouldComponentUpdate он возвращает
false
а неtrue
конечно же.
yogurt1
21.10.2017 08:38Первый недостаток — это то, что мы не можем теперь просто взять и обновить любое свойство объекта данных в приложении. Из-за требования возвращать каждый раз новый иммутабельный объект целого состояния, нам нужно вернуть новый объект и также пересоздать все родительские объекты и массивы. Например, если объект состояния хранит массив проектов, каждый проект хранит массив задач, и каждая задача хранит массив комментариев:
Это как бы суть иммутабельности
Ikarr
21.10.2017 12:59То для того чтобы обновить текст у объекта комментария мы не можем просто выполнить comment.text = 'new text' — нам нужно выполнить сначала пересоздание объекта комментария (comment = {...comment, text: 'updated text'}), дальше нужно пересоздать объект задачи и скопировать у туда ссылки на другие комментарии (task = {...task, tasks: [...task.comments]}), дальше пересоздать объект проекта и скопировать туда ссылки на другие задачи (project = {...project, tasks: [...project.tasks]}) и в конце уже пересоздать объект состояние и также скопировать ссылки на другие проекты (AppStat = {...AppState, projects: [...AppState.projects]}).
Не надо, пожалуйста, так делать. Храните данные в нормализованное виде: сущности в плоских словарях, ссылки как идентификаторы.bgnx Автор
21.10.2017 14:01Этот вариант уменьшает количество работы чтобы обновить объект, но с точки зрения производительности проблема остается. В нормализованном виде, если я правильном вас понял, у нас вместо дерева будет только два уровня — объект AppState будет хранить список таблиц а каждая таблица будет хранить хеш объектов по их айдишнику
let AppState = { folders: { ...., '11': { id: '11', name: 'folder1', projects: ['34', '42'] } }, projects: { ... '34': { id: '34', title: 'project1', tasks: ['112', '213'], folder: '11' } }, tasks: { '112': { id: '112', text: 'task1' comments: ['211'], project: '34' } }, comments: { '211': { id: '211', text: 'comment1', parent: null, task: '112' } } }
Теперь если нам нужно обновить комментарий то мы можем "просто" написать так
AppState = {...AppState, comments: {...AppState.comments, [comment.id]: {...comment, text: 'new text'}}}
А с точки зрения производительности мы все равно выполняем кучу работы — а) создаем новый объект комментария и копируем туда остальные свойства, б) создаем новый объект AppState и копируем туда все остальные таблицы в) — это самое важное — создаем новый объект хеша и копируем туда все айдишники со ссылками на другие объекты. В варианте с деревом количество комментариев которые надо скопировать ограничивался только одним таском (а их обычно немного) то теперь мы совместили все комментарии всех тасков всех проектов со всех папок в одном большом хеше и нам теперь придется их все копировать каждый раз при обновлении. Можно сказать что это микроптимизации приведя цитату Кнута, но когда вы столкнетесь с высокочастотными обработчиками событий вроде перемещения мыши или скролла этот подход с копированием тысячи айдишников и созданием лишних объектов (особенно когда redux-у при любом обновлении стора нужно вызвать mapStateToProps абсолютно всех подключенных компонентов, а что мы делаем внутри mapStateToProps? — мы создаем новый объект указывая дополнительные пропсы) будет вызывать тормоза. Поэтому тут подход с обсерверами mobx выигрывает потому что у нас: a) не будет создан ни один лишний объект б) обновление свойства любого объекта все равно короче и проще —comment.text = 'new text';
Но основная проблема с нормализованным подходом в другом — мы теряем возможность обращаться к другим частям состояния просто обращаясь по ссылке. Поскольку связи мы теперь моделируем через айдишники, то каждый раз когда нам нужно обратится к родительской сущности или вложенным сущностям нам нужно каждый раз вытаскивать объект по его айдишнику из глобального стора. Например, когда нужно узнать рейтинг родительского комментария мы не можем просто написать как в mobx
comment.parent.rating
— нам нужно вытащить объект по айдишнику —AppState.comments[comment.parentId].raiting
. А как мы знаем ui может быть сколь угодно быть разнообразным и компонентам может потребоваться информация о различных частях состояния и такой код вытаскивания по айдишнику на каждый чих легко превращается в некрасивую лапшу и будет пронизывать все приложение. Например, нам нужно узнать самый большой рейтинг у вложенных комментариев, то вариант с обсерверами и ссылками между объетами —comment.children.sort((a,b)=>b.rating - a.rating))[0]
а в варианте с иммутабельностью и айдишниками нужно еще дополнительно замапить айдишники на объеты —comment.children.map(сhildId=>AppState.comments[childId]).sort((a,b)=>b.rating - a.rating))[0]
. Или вот, сравните пример когда у нас есть объект комментария нужно узнать имя папки в котором он находится: 1) — вариант c ссылками —comment.task.project.folder.name
2) вариант с айдишниками —AppState.folders[AppState.projects[AppState.tasks[comment.taskId].projectId].folderId].name
И с точки зрения производительности — операция получения объекта по ссылке это O(1), а операция вытаскивания объекта по айдишнику это уже O(log(n))Ikarr
22.10.2017 00:20А с точки зрения производительности мы все равно выполняем кучу работы — а) создаем новый объект
Тут сложно спорить, но оптимизации такого рода нужны довольно редко. И когда они нужны, есть компромиссные решения (например, redux-ignore). Зато мы получаем иммутабельность и полную определенность чистых функций. Тоже, кстати, простор для оптимизации быстродействия, которого лишён мутабельный подход.
Но основная проблема с нормализованным подходом в другом — мы теряем возможность обращаться к другим частям состояния просто обращаясь по ссылке
И это, на самом деле, хорошо, потому что заставляет писать ортогональный код. Такой компонент может использовать идентификатор для самостоятельной подписки на стор. Это значительно упрощает компоненты и позволяет их использовать в отрыве друг от друга.
И с точки зрения производительности — операция получения объекта по ссылке
Я бы не оценивал доступ по ключу объекта к свойству как log n, т.к. разницей для небольших объектов можно пренебречь. Что, на мой взгляд важнее, нормализация решает проблему дупликации данных.mayorovp
22.10.2017 09:47Что, на мой взгляд важнее, нормализация решает проблему дупликации данных...
… порожденную иммутабельностью. В мутабельном мире есть намного более простые решения этой проблемы.
Ikarr
22.10.2017 14:11Раскройте мысль? Я говорю о том, что если не нормализовать данные, то в дереве могут быть дубликаты объектов (например, на третьем уровне иерархии). Если вы все таки как-то от них избавитесь, это будет, внезапно, нормализацией. И неясно, какое отношение топология данных имеет к мутабельности.
mayorovp
22.10.2017 15:48+2Зачем туда класть дубликаты объектов, когда можно положить тот же самый объект?
Ikarr
22.10.2017 23:09Очень интересно посмотреть на передачу того самого объекта, например, в json.
mayorovp
23.10.2017 10:21+1Структура транспортного сообщения не обязана совпадать с структурой вью-модели.
Конечно, если выбранная архитектура в принципе не предусматривает их разделения — все становится печально...
Ikarr
23.10.2017 14:54-1Печально становится, когда вы мне отвечаете на манер китайской комнаты. Всего доброго.
bgnx Автор
22.10.2017 18:49+2Создал тут пример чтобы оценить влияние на производительность иммутабельного подхода redux-а — codesandbox.io/s/7yvmx50m06 против обсерверов mobx-а codesandbox.io/s/1qrvz4qp57. При клике на любой subtask будет происходить анимация движения подзадачи по экрану. На 11 тысячах подключенных компонентов фпс (инструмент в chrome devtools) в примере с redux у меня где-то 20, а если взять продакшн-сборку реакта и redux то больше 30 не поднимается. У mobx стабильно 59. Советую сделать замер на вкладке performance в chrome devtools и посмотреть гребешки выделения памяти. 11 тысяч компонентов это конечно много но это когда уже тормозит а значит для 60 фпс надо не больше 5 тысяч. А учитывая что обработчики могут быть посложнее обновления свойства то на обновление компонентов останется еще меньше времени а если еще будет несколько обработчиков высокочастотных событий или анимаций одновременно (а это частый случае потому что компоненты хочется делать независимыми а не шарить один обработчик анимации на всех) то падение производительности будет еще больше и количество компонентов надо еще больше уменьшить. Но это на компьютере, а если взять мобилки с медленным процессором и небольшой памятью то ситуация будет еще хуже. Может я чего-то пропустил но я даже не вижу способов что-то закешировать, замемоизировать или что там еще можно сделать с иммутабельностью в примере с redux
Ikarr
22.10.2017 23:51Универсальных решений не бывает. Редукс позволяет описать логику приложения в чистых функциях, которые легко тестировать; предоставляет удобный фреймворк для описания изменения состояния сложных данных и дебаггинга их переходов. Но не во всех приложениях это к месту: делать 60 fps анимацию через redux это безумие — спорить сложно и зачем :)
AlexSkvr
23.10.2017 08:27В примере с redux для мутаций используются spread-ы. Они достаточно медленные по сравнению с мутациями в immutable js, и годятся только для небольших проектов. Да и читаемость мутаций в immutable лучше:
return state .setIn(['app', 'currentItemId'], action.id) .setIn(['app', 'currentDirection'], 'down') .setIn(['subtasks', subtask.id, 'top'], newTop) .setIn(['subtasks', subtask.id, 'left'], newLeft);
Мемоизация селекторов вряд ли поможет, т.к. в них нету тяжелых расчетов.
AlexSkvr
21.10.2017 14:06Описанные проблемы успешно решаются с помощью нормализации данных. Перед тем как ложить данные в store, они разбиваются на мелкие сущности с помощью normalizr, и дальше универсальным редьюсером добавлются в стор. Если нужно отобразить объект в компоненте, то он денормализуется с помощью denormalize в каком-нибудь селекторе. При редактировании объекта лучше использовать нормализованную форму.
Плюс Mobx что там такой функционал уже есть из коробки в mobx-state-tree.
rockon404
21.10.2017 18:15Уход от функционального программирования, иммутабельности и чистых функций. Я уже молчу про магию и неявную логику обновления. Я бы не рискнул использовать подобное в крупных коммерческих проектах.
mayorovp
21.10.2017 18:33А что не так с чистыми функциями?
inoyakaigor
21.10.2017 19:01Он имеет ввиду, что их тут нет
gnaeus
21.10.2017 19:10+1Вот кстати,
@computed
в Mobx – именно что чистые функции. Иначе работать не будет.
dagen
22.10.2017 10:45Вредная статья в стиле "сам себе придумал проблему, сам решил". Про нормализацию стейта вообще странно, автор будто даже официальную документацию реакта не удосужился прочитать.
alexey-m-ukolov
Дум…
inoyakaigor
Я сначала подумал, что это опечатка.
Ashot
А я сначала подумал, что это некая ирония (DOM — DOOM), но уже после третьего "дум" начало резать глаза и мозг