javascript

Применение паттерна observer в Redux и Mobx

  • среда, 14 февраля 2018 г. в 03:16:59
https://habrahabr.ru/post/348960/
  • ReactJS
  • JavaScript



Паттерн "observer" известен наверное с момента появления самого ооп. Упрощенно можно представить что есть объект который хранит список слушателей и имеет метод "добавить", "удалить" и "оповестить", а внешний код либо подписывается либо оповещает подписчиков


class Observable {
  listeners = new Set();
  subscribe(listener){
    this.listeners.add(listener)
  }
  unsubscribe(listener){
    this.listeners.delete(listener)
  }
  notify(){
    for(const listener of this.listeners){
       listener();
    }
  }
}

В redux-е этот паттерн применяется без всяких изменений  — пакет "react-redux" предоставляет функцию connect которая оборачивает компонент и при вызове componentDidMount вызовет subscribe() метод у Observable, при вызове componentWillUnmount()  вызовет  unsubscribе() а dispatch() просто вызовет метод trigger() который в цикле вызовет всех слушателей где каждый в свою очередь вызовет mapStateToProps() и потом в зависимости от того изменилось ли значение  —  вызовет setState() на самом компоненте. Все очень просто, но платой за простоту является необходимость явно указывать от каких частей стора зависит компонент внутри mapStateToProps().


Mobx очень похож на redux тем что использует этот паттерн observer только развивает его еще дальше  —  что если мы не будем писать mapStateToProps() а сделаем так чтобы компоненты зависели от данных которые они "рендерят" самостоятельно , по отдельности. Вместо того чтобы собирать подписчиков на одном объекте состояния всего приложения, подписчики будут подписываться на каждое отдельное поле в состоянии. Это как если бы для юзера, у которого есть поля firstName и lastName мы создали бы целый redux-стор отдельно для firstName и отдельно для lastName.


Таким образом, если мы найдем легкий способ создавать такие "сторы" и подписываться на них, то mapStateToProps() будет не нужен, потому что эта зависимость от разных частей состояния уже выражается в существовании разных сторов.


Итак на каждое поле у нас будет по отдельному "мини-стору"  —  объекту observer где кроме subscribe(), unsubscribe() и trigger() добавится еще поле value а также методы get() и set() и при вызове set() подписчики вызовутся только если само значение изменилось.


class Observable {
  listeners = new Set();
  constructor(value){
    this.value = value
  }
  get(){
    return this.value;
  }
  set(newValue){
    if(newValue !== this.value){
      this.notify();
    }
  }
  subscribe(listener){
    this.listeners.add(listener)
  }
  unsubscribe(listener){
    this.listeners.delete(listener)
  }
  notify(){
    for(const listener of this.listeners){
       listener();
    }
  }
}
const user = {
  fistName: new Observable("x"), 
  lastName: new Observable("y"),
  age: new Observable(0)
}
const listener = ()=>console.log("new firstName");
user.firstName.subscribe(listener)
user.firstName.get()
user.firstName.set("new name");
user.firstName.unsubscribe(listener);

Вместе с этим требование иммутабельности стора нужно трактовать немного по-другому  —  если мы в каждом отдельном сторе будем хранить только примитивные значение, то с точки зрения redux нет ничего зазорного в том чтобы вызвать user.firstName.set("NewName")  —  поскольку строка это иммутабельное значение  —  то здесь происходит просто установка нового состояния стора, точно так же как и в redux. В случаях когда нам нужно сохранить в "мини-сторе" объект или сложные структуры то можно просто вынести их в отдельные "мини-сторы". Например вместо этого


const user = {
 profile: new Observable({email: "...", address: "..."})
}

лучше написать так чтобы компоненты могли по отдельности зависеть то от "email" то от "address" и чтобы не было лишних "перерендеров"


const user = {
  profile: {
     email: new Observable("..."), 
     address: new Observable("..."}
  }
}

Второй момент  —  можно заметить что с таким подходом мы будем вынуждены на каждый доступ к свойству вызывать метод get(), что добавляет неудобств.


const App = ({user})=>(
  <div>{user.firstName.get()} {user.lastName.get()}</div>
)

Но эта проблема решается через геттеры и сеттеры javascript-а


class User {
  _firstName = new Observable("");
  get firstName(){ return this._firstName }
  set firstName(val){ this._firstName = val }
}

А если вы не относитесь негативно к декораторам то этот пример можно еще больше упростить


class User {
 @observable firstName = "";
}

В общем можно пока подвести итоги и сказать что 1) никакой магии в этом моменте нет  — декораторы это всего лишь геттеры и сеттеры 2) геттеры и сеттеры всего лишь считывают и устанавливают root-state в "мини-сторе" а-ля redux


Идем дальше — для того чтобы подключить все это к реакту нужно будет в компоненте подписаться на поля которые в нем выводятся и потом отписаться в componentWillUnmount


this.listener = ()=>this.setState({})
componentDidMount(){
  someState.field1.subscribe(this.listener)
  ....
  someState.field10.subscribe(this.listener)
}
componentWillUnmount(){
  someState.field1.unsubscribe(this.listener)
  ....
  someState.field10.unsubscribe(this.listener)
}

Да, при росте полей которые выводятся в компоненте, количество болерплейта будет возрастать многократно но одним небольшим движением ручную подписку можно убрать полностью если добавить несколько строчек кода  —  поскольку в шаблонах так или иначе будет вызываться метод .get() чтобы отрендерить значение то мы можем воспользоваться этим чтобы сделать автоматическую подписку  —  если перед вызовом метода render() компонента мы запишем в глобальной переменной текущий массив то в методе .get() мы просто добавим this в этот массив и потом в к конце вызова метода render() мы получим массив всех “мини-сторов” на которые подписан текущий компонент. Этот простой механизм решает даже ситуации когда сторы на которые подписан компонент динамически меняются во время рендера  —  например когда компонент рендерит <div>{user.firstName.get().length < 5 ? user.firstName.get() : user.lastName.get()}<div> ( если длина имени меньше 5 компонент не будет реагировать (то есть не будет подписан) на изменение фамилии а подписка автоматически произойдет когда длина имени будет больше-равно 5)


let CurrentObservables = null;
class Observable {
  listeners = new Set();
  constructor(value){
    this.value = value
  }
  get(){
    if(CurrentObservables) CurrentObservables.add(this);
    return this.value;
  }
  set(newValue){
    if(newValue !== this.value){
      this.notify();
    }
  }
  subscribe(listener){
    this.listeners.add(listener)
  }
  unsubscribe(listener){
    this.listeners.delete(listener)
  }
  notify(){
    for(const listener of this.listeners){
       listener();
    }
  }
}
function connect(target){
  return class extends (target instanceof React.Component ? target : React.Component) {
     stores = new Set();
     listener = ()=> this.setState({})
     render(){
        this.stores.forEach(store=>store.unsubscribe(this.listener));
        this.stores.clear();
        const prevObservables = CurrentObservables;
        CurrentObservables = this.stores;
        cosnt rendered = target instanceof React.Component ? super.render() : target(this.props);
        this.stores = CurrentObservables;
        CurrentObservables = prevObservables;
        this.stores.forEach(store=>store.subscribe(this.listener));
        return rendered;
     }
     componentWillUnmount(){
      this.stores.forEach(store=>store.unsubscribe(this.listener));
     }
  }
}

Здесь функция connect оборачивает компонент или stateless-component (функцию) реакта и возвращает компонент который благодаря это механизму автоподписки подписывается на нужные "мини-сторы".


В итоге у нас получился такой вот механизм автоподписок только на нужные данные и оповещения когда только эти данные изменились. Компонент будет обновляться только тогда когда изменились только те "мини-сторы" на которые он подписан. Учитывая что в реальном приложении где может быть тысячи этих "мини-сторов" с данным механизмом множественных сторов при изменении одного поля обновятся только те компоненты которые находятся в массиве подписчиков на это поле, а вот подходом redux когда мы подписываем все эти тысячи компонентов на один единственный стор, при каждом изменении нужно оповещать в цикле все эти тысячи компонентов (и при этом заставляя программиста вручную описывать от каких частей состояния зависят компоненты внутри mapStateToProps)


Более того этот механизм автоподписок способен улучшить не только redux а и такой паттерн как мемоизацию функций, и заменить библиотеку reselect  —  вместо того чтобы явно указывать в createSelector() от каких данных зависит наша функция, зависимости будут определяться автоматически точно так же выше сделано с функцией render()


Вывод


Mobx это логичное развитие паттерна observer для решения проблемы "точечных" обновлений компонентов и мемоизации функций. Если немного отрефакторить и вынести код в примере выше из компонента в Observable и вместо вызова .get() и .set() поставить геттеры и сеттеры, то мы почти что получим observable и computed декораторы mobx-а. Почти — потому что у mobx вместо простого вызова в цикле находится более сложный алгоритм вызова подписчиков для того чтобы исключить лишние вызовы computed для ромбовидных зависимостей, но об этом в следующей статье.