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


    Паттерн "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: Появилось продолжение статьи

    Поделиться публикацией
    Похожие публикации
    Ой, у вас баннер убежал!

    Ну. И что?
    Реклама
    Комментарии 0

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

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