Pull to refresh

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

Reading time 6 min
Views 17K


Паттерн "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 (React.Component.isPrototypeOf(target)  ? 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 = React.Component.isPrototypeOf(target)  ? 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 для ромбовидных зависимостей, но об этом в следующей статье.


upd: Появилось продолжение статьи

Tags:
Hubs:
+12
Comments 0
Comments Leave a comment

Articles