Объяснение паттерна Наблюдатель на примере Redux
Функция Frodo надела кольцо на палец и увидела его, class Observer. Горящее око взывало к Frodo…
Всем привет, читатели Хабра! Меня зовут Владислав, я frontend-разработчик в компании Nordclan. В этой статье я собираюсь простыми словами рассказать про паттерн Наблюдатель и как он используется в Redux. Также хотел бы обратить внимание на то, что статья ориентирована для новичков, однако может быть полезной для более опытных коллег.
Наблюдатель — поведенческий шаблон проектирования. Также известен как «подчинённые». Реализует у класса механизм, который позволяет объекту этого класса получать оповещения об изменении состояния других объектов и тем самым наблюдать за ними.
Знакомство с паттерном
Итак, что же из себя представляет наш наблюдатель (observer) и какую проблему он решает?
Начнем с примера из жизни: подписка на новостную ленту, либо подписка на почтовую рассылку. Допустим, Издатель — это объект который публикует что-то интересное и важное, Подписчик — тот кто следит за этими обновлениями и в зависимости от оповещения Издателя (Observable) выполняет свои действия.
Для небольших проектов и проектов средней сложности бывает происходит достаточно частая проблема — при непрерывном выполнении задач и реализации нового функционала без уделения должного внимания на архитектуру приложения, появляется высокая связанность компонентов в коде, которая делает в будущем любое изменение в проекте достаточно проблематичным из за постоянно нарастающей взаимосвязи различных объектов.
Такую проблему в частности может решить как раз паттерн наблюдателя, разрывая сильную связь между объектами и делая ее слабосвязанной.
Если два объекта могут взаимодействовать, не обладая практически никакой информацией друг о друге, такие объекты называют слабосвязанными.
Паттерн Наблюдатель определяет отношение «один-ко-многим» между объектами таким образом, что при изменении состояния одного объекта происходит автоматическое оповещение и обновление всех зависимых объектов.
Как раз то что нужно, для того чтобы улучшить ситуацию на проекте или изначально заложить правильный фундамент в приложении с нуля. Хотя зачастую проблема решится уже заранее готовой библиотекой в которой будет реализован этот паттерн, будет полезно иметь навык реализовать его на чистом языке для собственных нужд.
Внизу на схемах показана обобщенная реализация паттерна:
Схема подпискиСхема оповещения о событиях
Теперь, закончив с общими абстрактными описаниями, перейдем ближе к конкретике и языку, все примеры будут приведены на javascript, читатели знакомые с другими языками программирования без труда поймут что здесь происходит. Также в качестве визуального примера продемонстрирую примерную схему которая уже приближена к практической реализации:
Схема подпискиСхема оповещения о событиях
Первое из чего будет состоять паттерн — это основной класс в котором будет происходить вся «магия» вычислений:
class Observable {
observers = [];
subscribe(observer) {
this.observers.push(observer);
}
unsubscribe(observer) {
this.observers.filter((o) => o !== observer);
}
notify(payload) {
this.observers.forEach((observer) => observer(payload));
}
}
export default Observable;
Давайте поэтапно разберем что делает каждый метод в данном классе:
У нас есть базовый класс, назовем его также Observervable (наблюдаемым).
Первое с чего начнем — это определим переменную, где будем хранить будущих подписчиков в виде массива:
observers = [];
В методе subscribe реализуется механизм оформления подписки на объект Observer и добавление наблюдаемого компонента в массив подписчиков:
class Observable {
//...
subscribe(observer) {
this.observers.push(observer);
}
}
По аналогии с методом subscribe реализуем и метод отписки:
class Observable {
//...
unsubscribe(observer) {
this.observers.filter((o) => o !== observer);
}
}
В методе уведомления наблюдателей проходимся по всему массиву подписчиков и передаем им через параметр необходимую информацию:
class Observable {
//...
notify(payload) {
this.observers.forEach((observer) => observer(payload));
}
}
Данная тройка методов, описанных сверху, обязательна для правильной реализации паттерна, в дальнейшем на них будут построены методы бизнес-логики.
Теперь на основе этого класса реализуем практическое применение в виде рассылки события всем слушателям нажатием по кнопке:
class Observer {
subscribers = [];
constructor() {
if (!Observer.instance) {
Observer.instance = this;
}
return Observer.instance;
}
subscribe(subscriber) {
this.subscribers.push(subscriber);
return this;
}
unsubscribe(subscriber) {
this.subscribers.filter(sub => sub !== subscriber);
return this;
}
notify(payload) {
this.subscribers.forEach(subscriber => subscriber(payload));
return this;
}
}
// определим первого слушателя
function logToConsole(message) {
console.log(message);
}
// и второго
function logToDom(message) {
const logsContainer = document.getElementById('observer-logs');
logsContainer.innerHTML += `${message} `;
}
const btn = document.getElementById('btn');
const observer = new Observer();
// подписываем двух слушателей на observer
const subscribers = [logToConsole, logToDom];
subscribers.forEach(subscriber => observer.subscribe(subscriber));
// выполняем оповещение при нажатии на кнопку
btn.addEventListener('click', e => {
e.preventDefault();
observer.notify('btn clicked');
})
Вкратце вот как выглядит в общих чертах паттерн на простом примере, теперь же рассмотрим как он реализован внутри Redux.
Сравнение методов Observable и Redux
Чтобы понять как связаны методы класса наблюдателя и реализация их в Redux, взглянем на такие элементы Redux как store, action, dispatch, но reducer в данной статье я не упоминаю из-за того что он не принадлежит к паттерну и просто считается преобразующей функцией, передаваемой в параметры хранилища.
Давайте пройдемся по всем составляющим этой реализации.
Store — главный элемент хранилища состояния. Будучи основной функцией Redux, он возвращает дерево состояния и в нем же реализованы методы подписки и уведомления:
function store(reducer) {
let state;
let listeners = [];
const getState = () => state;
const dispatch = (action) => {
state = reducer(state, action);
listeners.forEach(listener => listener());
};
const subscribe = (listener) => {
listeners.push(listener);
return () => {
listeners = listeners.filter(l => l !== listener);
};
};
dispatch({ type: '@@redux/INIT' });
return {
getState,
dispatch,
subscribe
};
};
getState возвращает текущее состояние дерева в приложении:
const getState = () => state;
Метод dispatch, аналог в чистой реализации —notify (payload):
const dispatch = (action) => {
state = reducer(state, action);
listeners.forEach(listener => listener());
};
Обновляем состояние, передав в reducer текущее состояние и action:
state = reducer(state, action);
Эта строка кода выполняет перечисление всех подписчиков и вызывает каждого слушателя через listener (), уведомляя что состояние изменилось:
listeners.forEach(listener => listener());
Метод subscribe позволяет нам подписаться на обновление состояния, а callback в качестве аргумента в этом случае становится слушателем (listener)
который выполнится всякий раз, когда состояние хранилища будет обновлено.
Стоит отметить, что в практической разработке для обновления UI в реакте используются готовые решения в виде react-redux библиотеки и вместо метода subscribe применяется метод mapStateToProps ():
const subscribe = (listener) => {
listeners.push(listener);
return () => {
listeners = listeners.filter(l => l !== listener);
};
};
Также наряду с подпиской, в redux реализована отписка от наблюдателя, вызывается она не отдельным методом как в классе, а через return, который вернет массив с отфильтрованными слушателями и уберет ненужный:
return () => {
listeners = listeners.filter(l => l !== listener);
};
При создании хранилища отправляется действие «INIT», которое служит для того чтобы установить начальное общее содержимое состояния приложения.
dispatch({ type: '@@redux/INIT' });
Связывание Redux и UI (на примере с React)
Наконец, хотелось бы привести также небольшой упрощенный пример классового компонента чтобы показать как выглядит связывание Redux в UI и какие методы помещаются в жизненные циклы:
class Counter extends React.Component {
componentDidMount() {
const {subscribe} = this.props.store;
this.unsubscribe = subscribe(this.forceUpdate);
}
componentWillUnmount() {
this.unsubscribe();
}
render() {
const {getState, dispatch} = this.props.store;
return (
{getState().count}
);
}
}
Достаем метод subscribe из хранилища передающегося через props, далее подписываем наш компонент через this.forceUpdate, это ручной способ вызвать метод render в компоненте:
componentDidMount() {
const {subscribe} = this.props.store;
this.unsubscribe = subscribe(this.forceUpdate);
}
также задаем отписку и помещаем ее в жизненный компонент, где происходит размонтирование компонента:
componentWillUnmount() {
this.unsubscribe();
}
В методе render достаем оставшиеся методы и размещаем их в jsx разметке где getState будет предоставлять всегда актуальные данные состояния напрямую из хранилища, а dispatch привязывается к событию нажатия кнопки и вызывает выполнение всех остальных методов:
render() {
const {getState, dispatch} = this.props.store;
return (
{getState().count}
);
}
Заключение
На этом у меня все. Всем спасибо кто дочитал статью до конца, надеюсь информация была полезной как и для новичков, так и для опытных разработчиков и смогла открыть новое видение на казалось бы уже знакомый стейт-менеджер через пример реализации паттерна.
P.S. В качестве кода примеров использовались наработки из репозитория