Как работает mobx изнутри и сравнение его с redux



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

Поделиться публикацией
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама
Комментарии 41
  • +6
    Дум…
    • 0
      Я сначала подумал, что это опечатка.
      • +3

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

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

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

        Для проверок просто использую самописную is_array() в которой проводится так же проверка на isObservableArray(). Тоже проблем не возникает.
        • 0
          Как будто иммутабельные данные очищать не нужно…
          • +2
            Может быть Proxy поможет

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


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

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

                Либо я неправильно настроил babel, но построчное выполнение кода внутри генератора вообще уводит куда-то в сторону (redux-saga). Если какая-то ошибка, никакие source-map'ы не помогают. Стектрейс вообще неадекватный в консоли.

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

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


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

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


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

              • 0

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

                • 0

                  Вероятно, имелось в виду что-то такое, а не typeof:


                  Array.isArray([1, 2, 3]);  // true
                  Array.isArray({foo: 123}); // false
                  Array.isArray('foobar');   // false
                  Array.isArray(undefined);  // false
              • +1
                Несмотря на то что react изобрел виртуальный дум со слоганом что реальный дум медленный, а виртуальный быстрый потому что он сравнивает только деревья объектов в памяти, а в реальном думе обновляет только измененные части, в реальности мы не можем при любом обновлении данных в приложении вызвать это сравнение виртуального дума для всего приложения потому что это медленно.

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


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

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

                    Да, вы совершенно правы, mobx в @observer декораторе действительно переопределяет shouldComponentUpdate возвращая true если не изменились пропсы потому что вложенные компоненты будут обновляться отдельно. Я просто забыл добавить этот момент в статье. Более того для того чтобы не получилась ситуация когда мы обновляем более глубокий компонент раньше его родителя (в случае когда нужно обновить обоих) mobx использует специальный метод react-а ReactDOM.unstable_batchedUpdates который обновит компоненты в правильном порядке. В redux кстати существует точно такая же проблема и необходимо вручную добавлять middlerare с вызовом unstable_batchedUpdates (https://github.com/reactjs/redux/issues/1415)

                    • 0

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

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

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

                    • +1
                      То для того чтобы обновить текст у объекта комментария мы не можем просто выполнить comment.text = 'new text' — нам нужно выполнить сначала пересоздание объекта комментария (comment = {...comment, text: 'updated text'}), дальше нужно пересоздать объект задачи и скопировать у туда ссылки на другие комментарии (task = {...task, tasks: [...task.comments]}), дальше пересоздать объект проекта и скопировать туда ссылки на другие задачи (project = {...project, tasks: [...project.tasks]}) и в конце уже пересоздать объект состояние и также скопировать ссылки на другие проекты (AppStat = {...AppState, projects: [...AppState.projects]}).


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

                        Этот вариант уменьшает количество работы чтобы обновить объект, но с точки зрения производительности проблема остается. В нормализованном виде, если я правильном вас понял, у нас вместо дерева будет только два уровня — объект 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))

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


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

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


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

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


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

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

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

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


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

                                    • –1
                                      Печально становится, когда вы мне отвечаете на манер китайской комнаты. Всего доброго.
                          • +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
                            • 0
                              Универсальных решений не бывает. Редукс позволяет описать логику приложения в чистых функциях, которые легко тестировать; предоставляет удобный фреймворк для описания изменения состояния сложных данных и дебаггинга их переходов. Но не во всех приложениях это к месту: делать 60 fps анимацию через redux это безумие — спорить сложно и зачем :)
                              • 0
                                В примере с 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);

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

                            Плюс Mobx что там такой функционал уже есть из коробки в mobx-state-tree.
                            • –1
                              Уход от функционального программирования, иммутабельности и чистых функций. Я уже молчу про магию и неявную логику обновления. Я бы не рискнул использовать подобное в крупных коммерческих проектах.
                              • 0

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

                                • 0
                                  Он имеет ввиду, что их тут нет
                                  • +1

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

                                    • –1

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

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

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

                                • –1

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

                                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                  Самое читаемое