javascript

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

  • суббота, 21 октября 2017 г. в 03:13:20
https://habrahabr.ru/post/340592/
  • ReactJS
  • JavaScript




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