Применение паттерна observer в Redux и Mobx
- среда, 14 февраля 2018 г. в 03:16:59
Паттерн "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
для ромбовидных зависимостей, но об этом в следующей статье.