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