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


  1. alexey-m-ukolov
    21.10.2017 00:01

    Дум…


    1. inoyakaigor
      21.10.2017 01:35

      Я сначала подумал, что это опечатка.


      1. Ashot
        21.10.2017 02:06

        А я сначала подумал, что это некая ирония (DOM — DOOM), но уже после третьего "дум" начало резать глаза и мозг


  1. inoyakaigor
    21.10.2017 01:49

    Вот что мне не нравится в подходе с мутабельностью данных так это то, что это порождает целый класс ошибок. Например у нас есть некий объект в котором хранится начальное состояние приложения. Юзер взаимодействуя с приложением изменит этот объект и, если мы не предусмотрели очистку, то данные сохранятся в исходном объекте и могут попасть туда где им не положено быть.
    Как пример условная кнопка «Написать (какое-то особое) письмо» которая создаёт особой формы форму (извиняюсь за тавтологию) с предзаполненными дефолтными значениями полями.
    В тоже время иммутабельный подход исключает такое поведение в принципе.

    Что касается mobx, то мне кажется плохой идеей подменять примитивы какими-то своими объектами. Например в mobx вместо Array используется ObservableArray который не пройдёт проверку на typeof someVariable == Array. С другой стороны, иного способа реализовать такое же поведение я не знаю. Может быть Proxy поможет, но я сильно в этом сомневаюсь.


    1. Akuma
      21.10.2017 10:29

      С первым как-то не сталкивался. Просто пишу отдельные сторы для каждого «класса» данных, которые работают сами-посебе и о внешнем мире не знают: и вроде бы нет подобных проблем.

      Для проверок просто использую самописную is_array() в которой проводится так же проверка на isObservableArray(). Тоже проблем не возникает.


    1. mayorovp
      21.10.2017 12:31

      Как будто иммутабельные данные очищать не нужно…


    1. gnaeus
      21.10.2017 19:01

      Может быть Proxy поможет

      Vue 3-й версии как раз переписывают на Proxy (сейчас там все работает как в Mobx).
      Так что особый ObservableArray это скорее всего временное решение, которое умрет вместе с IE.


      Основная фишка MobX – это не пляски с геттерами-сеттерами, а реактивная парадигма. Вон, Knockout вообще использует obj.prop() как геттер и obj.prop(value) как сеттер.
      Да, неудобно. Зато работает со времен IE 6 (не к ночи будь помянут).
      Но сама идея библиотеки точно такая же.


  1. vtvz_ru
    21.10.2017 02:35

    Активно в проекте использую redux. Полюбился он мне. Многие ругают его за многословность и шаблонность. Поставил redux-actions и радуюсь жизни. Асинхронная логика лежит в саге, в редких случаях в middleware. Данные храню нормализовано, для выборок использую селекторы. Зато все прозрачно. Проблемы с дебагом возникают только в генераторах.


  1. Ashot
    21.10.2017 02:36

    … мне кажется плохой идеей подменять примитивы какими-то своими объектами. Например в mobx вместо Array используется ObservableArray

    Так Array это и не примитив


    … вместо Array используется ObservableArray который не пройдёт проверку на typeof someVariable == Array

    А что вообще пройдет такую проверку, если typeof [] === 'object"?


    Не холивара ради, просто правда не понял этих аргументов


    1. Ashot
      21.10.2017 02:43

      Посылаю голову пеплом, предназначалось в ветку этого комментария


    1. 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


      1. inoyakaigor
        21.10.2017 12:24

        Да это я и имел ввиду


  1. alex_blank
    21.10.2017 02:43

    Несмотря на то что react изобрел виртуальный дум со слоганом что реальный дум медленный, а виртуальный быстрый потому что он сравнивает только деревья объектов в памяти, а в реальном думе обновляет только измененные части, в реальности мы не можем при любом обновлении данных в приложении вызвать это сравнение виртуального дума для всего приложения потому что это медленно.

    Это не так, если использовать иммутабельность, чистые функции и мемоизацию (для исключения перерендера самого VDOM когда исходные данные не поменялись). С иммутабельностью достаточно легко перендерить только поменявшиеся части, достаточно сравнить ссылки при траверсинге структуры вглубь!


    Подробнее можно почитать в моей статье на хабре.


    1. mayorovp
      21.10.2017 12:32

      Достаточно чистых функций и мемоизации, иммутабельность не обязательна. :-)


  1. noneim
    21.10.2017 03:20

    Если мы определили список компонентов, которые необходимо обновить, то также нужна их иеархичность:
    например компонент A содержит в себе компонент B, который содержит в себе компонент C.
    Если вдруг оказалось, что надо обновить все эти 3 компонента — то важна последовательность, т.к. нет смысла запускать обновление компонента С вначале, т.к. вполне может оказаться так, что в процессе обновления компонента B компонент C просто будет удален.
    А теперь другой вариант — если компонент A отмечен к обновлению, но изменений в компоненте B не замечено, то при обновлении компонента A будет обновлен также и компонент B (не знаю точно, реализовано ли в mobx прослеживание этой ситуации в функции shouldComponentUpdate компонента B)


    1. 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)


      1. bgnx Автор
        21.10.2017 14:16

        Тьху, я перепутал — в shouldComponentUpdate он возвращает false а не true конечно же.


  1. yogurt1
    21.10.2017 08:38

    Первый недостаток — это то, что мы не можем теперь просто взять и обновить любое свойство объекта данных в приложении. Из-за требования возвращать каждый раз новый иммутабельный объект целого состояния, нам нужно вернуть новый объект и также пересоздать все родительские объекты и массивы. Например, если объект состояния хранит массив проектов, каждый проект хранит массив задач, и каждая задача хранит массив комментариев:

    Это как бы суть иммутабельности


  1. 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]}).


    Не надо, пожалуйста, так делать. Храните данные в нормализованное виде: сущности в плоских словарях, ссылки как идентификаторы.


    1. 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))


      1. Ikarr
        22.10.2017 00:20

        А с точки зрения производительности мы все равно выполняем кучу работы — а) создаем новый объект


        Тут сложно спорить, но оптимизации такого рода нужны довольно редко. И когда они нужны, есть компромиссные решения (например, redux-ignore). Зато мы получаем иммутабельность и полную определенность чистых функций. Тоже, кстати, простор для оптимизации быстродействия, которого лишён мутабельный подход.

        Но основная проблема с нормализованным подходом в другом — мы теряем возможность обращаться к другим частям состояния просто обращаясь по ссылке


        И это, на самом деле, хорошо, потому что заставляет писать ортогональный код. Такой компонент может использовать идентификатор для самостоятельной подписки на стор. Это значительно упрощает компоненты и позволяет их использовать в отрыве друг от друга.

        И с точки зрения производительности — операция получения объекта по ссылке


        Я бы не оценивал доступ по ключу объекта к свойству как log n, т.к. разницей для небольших объектов можно пренебречь. Что, на мой взгляд важнее, нормализация решает проблему дупликации данных.


        1. mayorovp
          22.10.2017 09:47

          Что, на мой взгляд важнее, нормализация решает проблему дупликации данных...

          … порожденную иммутабельностью. В мутабельном мире есть намного более простые решения этой проблемы.


          1. Ikarr
            22.10.2017 14:11

            Раскройте мысль? Я говорю о том, что если не нормализовать данные, то в дереве могут быть дубликаты объектов (например, на третьем уровне иерархии). Если вы все таки как-то от них избавитесь, это будет, внезапно, нормализацией. И неясно, какое отношение топология данных имеет к мутабельности.


            1. mayorovp
              22.10.2017 15:48
              +2

              Зачем туда класть дубликаты объектов, когда можно положить тот же самый объект?


              1. Ikarr
                22.10.2017 23:09

                Очень интересно посмотреть на передачу того самого объекта, например, в json.


                1. mayorovp
                  23.10.2017 10:21
                  +1

                  Структура транспортного сообщения не обязана совпадать с структурой вью-модели.


                  Конечно, если выбранная архитектура в принципе не предусматривает их разделения — все становится печально...


                  1. Ikarr
                    23.10.2017 14:54
                    -1

                    Печально становится, когда вы мне отвечаете на манер китайской комнаты. Всего доброго.


      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


        1. Ikarr
          22.10.2017 23:51

          Универсальных решений не бывает. Редукс позволяет описать логику приложения в чистых функциях, которые легко тестировать; предоставляет удобный фреймворк для описания изменения состояния сложных данных и дебаггинга их переходов. Но не во всех приложениях это к месту: делать 60 fps анимацию через redux это безумие — спорить сложно и зачем :)


        1. 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);

          Мемоизация селекторов вряд ли поможет, т.к. в них нету тяжелых расчетов.


  1. AlexSkvr
    21.10.2017 14:06

    Описанные проблемы успешно решаются с помощью нормализации данных. Перед тем как ложить данные в store, они разбиваются на мелкие сущности с помощью normalizr, и дальше универсальным редьюсером добавлются в стор. Если нужно отобразить объект в компоненте, то он денормализуется с помощью denormalize в каком-нибудь селекторе. При редактировании объекта лучше использовать нормализованную форму.

    Плюс Mobx что там такой функционал уже есть из коробки в mobx-state-tree.


  1. rockon404
    21.10.2017 18:15

    Уход от функционального программирования, иммутабельности и чистых функций. Я уже молчу про магию и неявную логику обновления. Я бы не рискнул использовать подобное в крупных коммерческих проектах.


    1. mayorovp
      21.10.2017 18:33

      А что не так с чистыми функциями?


      1. inoyakaigor
        21.10.2017 19:01

        Он имеет ввиду, что их тут нет


        1. gnaeus
          21.10.2017 19:10
          +1

          Вот кстати, @computed в Mobx – именно что чистые функции. Иначе работать не будет.


          1. vintage
            21.10.2017 19:45
            +1

            Не чистые (зависят от изменяемых свойств), но идемпотентные (при неизменных зависимостях дают неизменный результат).


            1. gnaeus
              21.10.2017 20:01

              Да, действительно. Как-то упустил этот момент


            1. mayorovp
              21.10.2017 20:15

              Скажу так, для каждого @computed можно составить чистую функцию которую оно реализует — но в коде она не представлена.


  1. dagen
    22.10.2017 10:45

    Вредная статья в стиле "сам себе придумал проблему, сам решил". Про нормализацию стейта вообще странно, автор будто даже официальную документацию реакта не удосужился прочитать.