Вникаем в механизмы Redux, делая своё хранилище (Store)

Перевод статьи Finally understand Redux by building your own Store с сайта toddmotto.com для CSS-live.ru, автор — Тодд Мотто

Redux — интересный паттерн, нехитрый по своей сути, но почему же его порой так тяжело понять? В этой статье мы рассмотрим основные идеи Redux и внутренние механизмы хранилища (Store).

Смысл здесь в том, чтобы глубже познать магию «под капотом» Redux, хранилища (Store), редюсеров (reducers) и дейтвий (action) — и механизмы их работы. Это поможет лучше отлаживать и писать более качественный код, и понимать, что именно он делает. Мы изучим всё это на примере собственного пользовательского хранилища, написанного на языке TypeScript.

Оглавление

В основе этой статьи лежит моё собственное хранилище Redux, написанное на ванильном TypeScript, при желании можете сверяться с его исходным кодом. Учитывайте, что это для понимания внутренних механизмов хранилища — паттерна Redux.

Терминология

Если Redux для вас в новинку и вы лишь мельком пролистали документацию, то для начала вам следует поближе познакомиться со следующими терминами.

Действия

Не относитесь к действиям как к API JavaScript, они для другого — учитывайте это в первую очередь. Они информируют хранилище о наших намерениях.

Мы как бы говорим «Эй, хранилище, лови инструкцию и обнови дерево состояния с этим новым куском информации, пожалуйста».

Вот как выглядит сигнатура действия на TypeScript:

interface Action { type: string; payload?: any;
}

Payload (полезная нагрузка) — необязательное свойство, поскольку иногда можно диспетчить что-нибудь типа действия «load», которое не принимает никакой полезной нагрузки, хотя чаще всего мы будем использовать свойство payload.

Это значит, что у нас выйдет что-то вроде этого:

const action: Action = { type: 'ADD_TODO', payload: { label: 'Съесть пиццу,', complete: false },
};

Практически это и есть заготовка действия. Продолжим!

Редюсеры

Редюсер — просто чистая функция, принимающая состояние state нашего приложения (наше внутреннее дерево состояния, переданное хранилищем в редюсер), а вторым аргументом «прилетевшее» действие action. И в итоге мы получим примерно вот что:

function reducer(state, action) { //... это было легко }

Хорошо, что ещё нужно, чтобы понять редюсер? Мы уже поняли, что в этот редюсер передаётся наше состояние, и чтобы сделать что-то полезное (скажем, обновить наше дерево состояния), нужно отреагировать на свойство type действия (которое мы только что видели выше). Обычно в этом помогает switch:

function reducer(state, action) { switch (action.type) { case 'ADD_TODO': { // Полагаю, здесь надо бы что-нибудь сделать... } }
}

Каждый case внутри switch реагирует на свой конкретный тип действий, составляющих состояние в наших приложениях. Чтобы, к примеру, добавить свойство со значением в наше дерево состояния, можно просто вернуть это:

function reducer(state = {}, action) { switch (action.type) { case 'ADD_TODO': { return { ...state, // разворачиваем старый массив todos в новый массив с помощью оператора расширения // и добавили затем наш новый todo в конец todos: [...state.todos, { label: 'Съесть пиццу,', complete: false }], }; } } return state;
}

Заметьте, если для конкретного действия соответствия не нашлось, мы возвращаем состояние обратно. Я добавил state = {} первым аргументом (в качестве значения по умолчанию для параметра). Эти начальные объекты состояния обычно абстрагируются выше редюсера, как мы увидим дальше.

Наконец, тут надо бы отметить, как мы боремся за неизменяемость. В каждом случае мы возвращаем совершенно новый объект, содержащий как новые изменения дерева состояния, так и его прежнее представление — то есть, мы получаем немного модифицированный объект состояния. Мы присоединяем текущее состояние с помощью ...state, где разворачиваем текущее состояние и после добавляем в него отдельные свойства.

Сама идея чистых функций предполагает, что при одинаковом вводе всегда должен возвращаться один и тот же результат. Редюсеры обрабатывают динамические состояния и действия как чистые функции, короче говоря, мы их только устанавливаем, а они уже берут на себя всё остальное. Это инкапсулированные функции, содержащие кусок логики для обновления нашего дерева состояния, в зависимости от того, какую инструкцию мы отправляем (с помощью action).

Редюсеры чисто синхронны, и нужно избегать поползновений на асинхронность внутри них.

Итак, что насчёт action.payload? В идеале не стоит напрямую «вбивать» в редюсер значения, если это не простые булевы переключатели с false на true. Чтобы уж до конца придерживаться правила «чистых функций», мы обращаемся к свойству action.payload из аргументов функции, чтобы получить любые данные, которые мы диспатчим с помощью action:

function reducer(state = {}, action) { switch (action.type) { case 'ADD_TODO': { // Дай мне новые данные const todo = action.payload; // Создай новую структуру данных const todos = [...state.todos, todo]; // Верни новое представление состояния return { ...state, todos, }; } } return state;
}

Хранилище

Я заметил, что есть путаница между «состоянием» и «хранилищем». Хранилище — это ваш контейнер, а состояние живёт в этом контейнере. Хранилище — объект со своим API, который позволяет взаимодействовать с вашим состоянием: изменять его, запрашивать его значение, и так далее.

Думаю, теперь самое время перейти к практике, и по ходу того, как мы будем строить наше собственное хранилище, все вопросы будут сами собой проясняться.

Мне нравится объяснять это так: «это всего лишь структурированный процесс для обновления свойства в объекте». Это Redux.

API хранилища

У нашего Redux-хранилища будет только несколько публичных свойств и методов. Затем мы зададим нашему Store все редюсеры и начальное состояние для нашего приложения.

const store = new Store(reducers, initialState);

Store.dispatch()

Метод dispatch позволит передать инструкцию для Store, что мы хотим изменить дерево состояния. Это обрабатывается с помощью только что рассмотренного редюсера.

Store.subscribe()

Метод subscribe позволит передать в Store функцию-подписчик, которой, когда наше дерево состояния изменится, мы сможем передать эти его новые изменения с помощью аргумента в обратном вызове .subscribe().

Store.value

Свойство value создают как геттер и будет возвращать внутреннее дерево состояния (так мы сможем обращаться к свойством)

Контейнер хранилища

Как мы знаем, Store содержит наше состояние, позволяет диспатчить действия и подписываться на новые обновления дерева состояния. Так что начнём с класса Store:

export class Store { constructor() {} dispatch() {} subscribe() {}
}

Пока что выглядит хорошо, но мы забыли наш объект state для состояния:

export class Store { private state: { [key: string]: any }; constructor() { this.state = {}; } get value() { return this.state; } dispatch() {} subscribe() {}
}

Здесь я использую TypeScript, поскольку он мне нравится, чтобы определить, что у нашего объекта состояния ключи будут с типом string, а значения — какие угодно. Потому что именно это нужно для нашей структуры данных.

И ещё мы добавили get value() {}, который внутри возвращает этот объект состояния при обращении как к свойству: console.log(store.value);.

Итак, теперь это у нас есть, давайте создадим его экземпляр:

const store = new Store();

Вуаля.

На этом этапе мы могли бы при желании вызвать dispatch:

store.dispatch({ type: 'ADD_TODO', payload: { label: 'Съешь пиццу', complete: false },
});

Но он ничего не делает, поэтому давайте более детально сосредоточимся на нашем dispatch и передаче этого действия:

export class Store { // ... dispatch(action) { // Обновляем дерево состояния здесь! } // ...
}

Хорошо, итак, внутри dispatch нужно обновить наше дерево состояния. Но погодите, а как вообще оно выглядит?

Наша структура данных состояния

В этой статье наша структура данных состояния будет выглядеть так:

{ todos: { data: [], loaded: false, loading: false, }
}

Почему? Мы уже поняли, что редюсеры обновляют наше дерево состояния. В реальных приложениях у нас будет много редюсеров, каждый из которых отвечает за обновление конкретных частей дерева состояний («кусочки» состояния, как мы обычно их называем). Каждый «кусочек» управляется редюсером.

В данном случае наше свойство todos в дереве состояния — «кусочек» todos, который будет управляться редюсером. Здесь наш редюсер будет просто управлять свойствами этого кусочка: data, loaded и loading. Мы используем loaded и loading, поскольку при выполнении асинхронных задач вроде загрузки JSON по HTTP мы по-прежнему хотим контролировать разные действия, которые он совершает от начала запроса и до самого его выполнения.

Так что, вернёмся к нашему методу dispatch.

Обновление нашего дерева состояния

Чтобы состояние обновлялось как неизменяемый объект, нужно назначить новое представление состояния в свойстве state в качестве совершенного нового объекта. Сам этот новый объект состоит из любых изменений, которые мы хотим создать в дереве состояния с помощью действия.

В следующем примере давайте пока вообще на время забудем про редюсеры и обновим состояние вручную:

export class Store { // ... dispatch(action) { this.state = { todos: { data: [...this.state.todos.data, action.payload], loaded: true, loading: false, }, }; } // ...
}

Если мы теперь задеспетчим это действие 'ADD_TODO', наше дерево состояния будет выглядеть так:

{ todos: { data: [{ label: 'Съешь пиццу', complete: false }], loaded: false, loading: false, }
}

Реализация функциональности редюсера

Теперь, когда мы знаем, что редюсер обновляет «кусочек» состояния, для начала определим исходный «кусочек»:

export const initialState = { data: [], loaded: false, loading: false,
};

Создание редюсера

Далее, нужно передать в нашу функцию-редюсер этот аргумент state с вышеуказанным объектом initialState в качестве значения по умолчанию. Это задаст редюсер для начальной загрузки при вызове редюсера в Store, чтобы привязать всё начальное состояние внутри всех редюсеров:

export function todosReducer( state = initialState, action: { type: string, payload: any }
) { // Не забудьте вернуть меня return state;
}

К этому моменту мы, пожалуй, можем угадать оставшуюся часть редюсера:

export function todosReducer( state = initialState, action: { type: string, payload: any }
) { switch (action.type) { case 'ADD_TODO': { const todo = action.payload; const data = [...state.data, todo]; return { ...state, data, }; } } return state;
}

Итак, пока всё отлично — но нужно еще как-то связать редюсер с хранилищем, чтобы можно было вызвать его и передать состояние и любые действия.

Поэтому в Store нужно сделать вот что:

export class Store { private state: { [key: string]: any }; constructor() { this.state = {}; } get value() { return this.state; } dispatch(action) { this.state = { todos: { data: [...this.state.todos.data, action.payload], loaded: true, loading: false, }, }; }
}

Теперь нужно добавить возможность докидывать редюсеры в Store:

export class Store { private state: { [key: string]: any }; private reducers: { [key: string]: Function }; constructor(reducers = {}, initialState = {}) { this.reducers = reducers; this.state = {}; } }

Мы также задаём любые initialState в Store, поэтому можно задать его в любой момент при вызове Store.

Регистрация редюсера

Для регистрации редюсера нам нужно запомнить то свойство todos в нашем ожидаемом дереве состояния и связать с ним нашу функцию-редюсер. Помните, что мы управляем кусочком состояния с названием «todos»:

const reducers = { todos: todosReducer,
}; const store = new Store(reducers);

Это волшебное место, где свойство todosрезультат вызова хранилищем Store метода todosReducer — который, как мы знаем, возвращает новое состояние, основанное на кокретном действии.

Вызов редюсера в Store

Редюсеры названы так потому, что они сводят новое состояние к единственному значению. Думайте о Array.prototype.reduce, где в конце мы получаем одно итоговое значение. В нашем случае это итоговое значение — новое представление состояния. Видимо нам нужен цикл.

То, что нам нужно сделать, это обернуть логику сведения к одному значению в фукнкцию с названием reduce:

export class Store { // ... dispatch(action) { this.state = this.reduce(this.state, action); } private reduce(state, action) { // Вычислить и вернуть новое состояние return {}; }
}

При диспатче действия мы фактически вызываем метод reduce, созданный в классе Store, и передаём внутрь состояние и действие. Это называется корневым редюсером. Видно, что он принимает состояние и действие — подобно нашему todosReducer.

Итак, давайте более пристально присмотримся к нашему приватному методу reduce, поскольку это самый важный шаг, чтобы устройство нашего дерева состояния полностью стало на своё место.

export class Store { private state: { [key: string]: any }; private reducers: { [key: string]: Function }; constructor(reducers = {}, initialState = {}) { this.reducers = reducers; this.state = {}; } dispatch(action) { this.state = this.reduce(this.state, action); } private reduce(state, action) { const newState = {}; for (const prop in this.reducers) { newState[prop] = this.reducers[prop](state[prop], action); } return newState; }
} 

Вот что здесь происходит:

  • Создаём объект newState, содержащий новое дерево состояния
  • Перебираем this.reducers, которые мы зарегистрировали в хранилище
  • Привязываем каждое свойство в редюсере — todos — в newState
  • Поочерёдно вызываем каждый редюсер, передав в него «кусочек» состояния (с помощью state[prop]) и действие.

В данном случае значение prop — просто todos, поэтому думайте о нём вот как:

newState.todos = this.reducers.todos(state.todos, action);

Обработка начального состояния (initialState)

И наконец, наш объект initialState. Если хотите использовать синтаксис Store(reducers, initialState), чтобы задать начальное состояние для всего хранилища, тогда его тоже надо обработать при создании хранилища.

export class Store { private state: { [key: string]: any }; private reducers: { [key: string]: Function }; constructor(reducers = {}, initialState = {}) { this.reducers = reducers; this.state = this.reduce(initialState, {}); } // ...
}

Помните о return state внизу каждого редюсера? Теперь вы знаете, для чего это! Это нужно для возможности передать {} в качестве действия, что означает, что веток switch можно избежать — и мы останемся в итоге с деревом состояния, которое мы передали через конструктор насквозь.

Включение подписчиков

Термин «подписчики» часто встречается в мире паттерна «Observable», где каждый раз, когда наблюдаемый объект (Observable) порождает новое значение, мы узнаём об этом благодаря подписке. Подписка — это просто «Дай мне данные, когда они будет доступны или изменились».

В нашем случае это обработалось бы примерно так:

const store = new Store(reducers); store.subscribe(state => { // делаем что-то со `state`
});

Подписчики на хранилище

Давайте добавим ещё несколько свойств в наше хранилище, чтобы можно было настроить эту подписку:

export class Store { private subscribers: Function[]; constructor(reducers = {}, initialState = {}) { this.subscribers = []; // ... } subscribe(fn) {} // ...
}

У нас здесь есть метод subscribe, принимающий функцию (fn) в качестве аргумента. Нам осталось только передать каждую функцию в наш массив подписчиков subscribers:

export class Store { // ... subscribe(fn) { this.subscribers = [...this.subscribers, fn]; } // ...
}

Это было легко! Итак, где есть смысл информировать наших подписчиков об изменениях? В dispatch, конечно!

export class Store { // ... get value() { return this.state; } dispatch(action) { this.state = this.reduce(this.state, action); this.subscribers.forEach(fn => fn(this.value)); } // ...
}

Снова проще некуда! При каждом диспатче мы сокращаем состояние и перебираем наши подписчики, передавая им this.value (не забываем, что это наш геттер значения value)

Нооо, это ещё не всё! При вызове .subscribe() нам не удастся получить значение состояния сразу. Только, когда действие задиспетчено. Давайте специально сделаем так, чтобы новые подписчики узнавали текущее состояние сразу же, как только подпишутся:

export class Store { // ... subscribe(fn) { this.subscribers = [...this.subscribers, fn]; fn(this.value); } // ...
}

И снова всё просто — с помощью метода subscribe мы получили функцию fn, и можем просто вызвать её сразу после подписки, передав значение в дерево состояния.

Отписывание от хранилища

Раз мы можем подписаться, нужно уметь и отписываться во избежание утечек памяти, ну или просто потому, что данные нам больше не интересны.

Всё, что нужно, это вернуть замыкание функции, которое отпишет нас при вызове (удалив эту функцию из нашего списка подписчков):

export class Store { // ... subscribe(fn) { this.subscribers = [...this.subscribers, fn]; fn(this.value); return () => { this.subscribers = this.subscribers.filter(sub => sub !== fn); }; } // ...
}

Мы просто используем ссылку на эту функцию, перебираем подписчики и проверяем, не равен ли текущий подписчик нашему fn, и при помощи Array.prototype.filter он, как по волшебству, удаляется из нашего массива подписчиков.

И мы можем использовать это так:

const store = new Store(reducers); const unsubscribe = store.subscribe(state => {}); destroyButton.on('click', unsubscribe, false);

И это всё, что нам нужно.

Прелесть подписок в том, что у нас может быть несколько подписчиков, а значит, разные части приложения могут следить за разными «кусочками» состояния.

Окончательный код

Вот всё целиком и готовое решение:

export class Store { private subscribers: Function[]; private reducers: { [key: string]: Function }; private state: { [key: string]: any }; constructor(reducers = {}, initialState = {}) { this.subscribers = []; this.reducers = reducers; this.state = this.reduce(initialState, {}); } get value() { return this.state; } subscribe(fn) { this.subscribers = [...this.subscribers, fn]; fn(this.value); return () => { this.subscribers = this.subscribers.filter(sub => sub !== fn); }; } dispatch(action) { this.state = this.reduce(this.state, action); this.subscribers.forEach(fn => fn(this.value)); } private reduce(state, action) { const newState = {}; for (const prop in this.reducers) { newState[prop] = this.reducers[prop](state[prop], action); } return newState; }
}

Можно видеть, что здесь не так уж и много кода в целом:

Заключение

Было вполне неплохо, да? Вы наверняка уже видели, слышали и даже использовали многие из эти терминов,но, может быть, не задумывались, что за ними стоит.

Теперь, на примере собственного хранилища, мы, наконец-то, поняли, что оно делает. Оно избавляет простое создание действия, редюсера, от магии и позволяет ему просто «работать». Теперь нам полностью понятны все механизмы: наш диспатч приказал хранилищу выполнить процесс определения нового состояния, вызывая каждый редюсер и пытаясь найти соответствующий case для action.type в операторе switch. Наше дерево состояния — просто конечное представление вызовов всех наших редюсеров.

Для меня это стало самым важным этапом в понимании Redux, надеюсь, и для вас тоже.

Вы можете продвинуться ещё дальше с моим бесплатным курсом по NGRX для Angular, и изучить то, как овладеть управлением состояния с помощью хранилища NGRX и Effects.

P.S. Это тоже может быть интересно: