Рассказ о том, как создать хранилище и понять Redux

в 13:11, , рубрики: redux, TypeScript, Блог компании RUVDS.com, обучение, разработка, хранение данных, хранилища данных, хранилище

Redux — это интересный шаблон, и, по своей сути, он очень прост. Но почему его сложно понять? В этом материале мы рассмотрим базовые концепции Redux и разберёмся с внутренними механизмами хранилищ. Поняв эти механизмы, вы сможете освоиться со всем тем, что происходит, что называется, «под капотом» Redux, а именно — с тем, как работают хранилища, редьюсеры и действия. Это поможет вам вывести на новый уровень отладку приложений, поможет писать более качественный код. Вы будете точно знать, какие именно функции выполняет та или иная строка вашей программы. Мы будем идти к пониманию Redux через практический пример, который заключается в создании собственного хранилища с использованием TypeScript.

Этот материал основан на исходном коде хранилища Redux, написанном на чистом TypeScript. Автор предлагает всем желающим взглянуть на этот код и разобраться с ним. Однако, он указывает на то, что этот проект предназначен для учебных целей.

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

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

▍Действия

Не пытайтесь воспринимать действия (actions) как JavaScript API. У действий есть определённая цель — и это нужно понять в первую очередь. Действия информируют хранилище о намерении (intent).

Работая с хранилищем, ему дают указания, например, что-то вроде этого: «Эй, хранилище! У меня есть к тебе просьба. Пожалуйста, обнови дерево состояния, добавив в него эти данные».

Сигнатура действия, при использовании TypeScript для её демонстрации, выглядит так:

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

Payload (полезная нагрузка) — это необязательное свойство, так как иногда мы можем отправлять в хранилище действия, которые не принимают полезной нагрузки, хотя в большинстве случаев это свойство оказывается задействованным. Тут имеется в виду то, что, создавая действие, мы описываем его, например, так:

const action: Action = {
  type: 'ADD_TODO',
  payload: { label: 'Eat pizza,', complete: false },
};

Это, по сути, шаблон действия. Но об этом после, а пока — продолжим знакомство с терминологией.

▍Редьюсеры

Редьюсер (reducer) — это всего лишь чистая функция, которая принимает состояние (state) приложения (внутреннее дерево состояния, которое хранилище передаёт редьюсеру), и, в качестве второго аргумента, отправленное хранилищу действие. То есть, выглядит всё это так:

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 в новый
        // и затем добавляем в конец этого массива новый элемент
        todos: [...state.todos, { label: 'Eat pizza,', complete: false }],
      };
    }
  }

  return state;
}

Обратите внимание на то, что в нижней части кода имеется команда возврата state. Делается это для того, чтобы возвратить исходное состояние в том случае, если в редьюсере нет ветви case, соответствующей некоему действию. Тут же можно добавить, что в качестве первого аргумента здесь добавлена конструкция state = {}. Это — значение параметра по умолчанию. Исходные объекты состояний обычно формируются за пределами редьюсеров, мы ещё об этом поговорим.

Последнее, на что надо обратить внимание — это стремление к иммутабельности. Мы возвращаем совершенно новый объект в каждой ветви case, Он представляет собой комбинацию предыдущего состояния и изменений, внесённых в него. Как результат, на выходе оказывается слегка изменённый вариант первоначального дерева состояния. Тут, сначала, применяется команда …state, оператор расширения, после чего в текущее состояние добавляются новые свойства.

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

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

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

  return state;
}

▍Хранилище

Мне постоянно приходится видеть, как состояние (state) путают с хранилищем (store). Хранилище — это контейнер, а состояние просто размещается в этом контейнере.

Хранилище — это объект с API, которое позволяет взаимодействовать с состоянием, модифицировать его, читать его значения, и так далее.

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

Мне хотелось бы отметить, что, по сути, функции хранилища заключаются в реализации структурированного процесса обновления свойств в объекте. Собственно говоря, это и есть Redux.

API хранилища

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

const store = new Store(reducers, initialState);

▍Метод Store.dispatch()

Метод

dispatch

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

▍Метод Store.subscribe()

Метод subscribe позволит организовать передачу в хранилище функций-подписчиков, интересующихся изменениями дерева состояний. Этим функциям, при изменении дерева состояний, передаются соответствующие сведения.

▍Свойство Store.value

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

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

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

export class Store {
  constructor() {}

  dispatch() {}

  subscribe() {}
}</cosourcede>
Пока всё выглядит вполне нормально, но мы забыли об объекте для состояния, <code>state</code>. Добавим его:

<source>export class Store {
  private state: { [key: string]: any };

  constructor() {
    this.state = {};
  }

  get value() {
    return this.state;
  }

  dispatch() {}

  subscribe() {}
}

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

Кроме того, тут добавлен метод get value() {}, который возвращает объект state, когда к нему обращаются как к свойству:

console.log(store.value);

Итак, теперь создадим экземпляр хранилища:

const store = new Store();

В данный момент вполне можно вызвать метод dispatch:

store.dispatch({
  type: 'ADD_TODO',
  payload: { label: 'Eat pizza', complete: false },
});

Однако, такой вызов пока ни к чему не приводит, поэтому займёмся работой над методом dispatch, приведём его к такому виду, чтобы ему можно было передать действие:

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

Итак, в методе dispatch надо обновить дерево состояния. Но сначала зададимся вопросом — а как оно выглядит — это дерево состояния?

▍Структура данных для хранения состояния

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

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

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

В данном случае свойство todo в дереве состояний, или слой свойств todo, будет управляться редьюсером. На данный момент наш редьюсер будет работать со свойствами 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,
      },
    };
  }
  // ...
}

После того, как мы отправим методу dispatch действие 'ADD_TODO', состояние будет выглядеть так:

{
  todos: {
    data: [{ label: 'Eat pizza', complete: false }],
    loaded: false,
    loading: false,
  }
}

Разработка редьюсеров

Теперь, когда нам известно, что редьюсер обновляет некий слой состояния, опишем этот слой:

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

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

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

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,
      },
    };
  }
}

Нам нужно сделать так, чтобы в хранилище можно было добавлять редьюсеры:

export class Store {
  private state: { [key: string]: any };
  private reducers: { [key: string]: Function };

  constructor(reducers = {}, initialState = {}) {
    this.reducers = reducers;
    this.state = {};
  }

}

Кроме того, мы предоставляем хранилищу исходное состояние, initialState, поэтому мы можем, при желании, передать его, когда мы создаём хранилище.

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

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

const reducers = {
  todos: todosReducer,
};

const store = new Store(reducers);

Тут происходит самое интересное, и обычно непонятное. А именно, здесь свойство todos становится результатом операции вызова хранилищем редьюсера todosReducer, который как мы знаем, возвращает новое состояние, основываясь на некоем действии.

▍Вызов редьюсеров в хранилище

Принцип работы редьюсеров, по своей сути, напоминает работу функции Array.prototype.reduce, которая приводит обрабатываемый ей массив к некоему единственному значению. Редьюсеры работают похожим образом, принимая старое состояние, выполняя над ним некие действия, и возвращая состояние новое.
Теперь мы собираемся обернуть логику редьюсера в функцию, которая тут названа reduce:

export class Store {
  // ...
  dispatch(action) {
    this.state = this.reduce(this.state, action);
  }

  private reduce(state, action) {
// найти и возвратить новое состояние
    return {};
  }
}

Когда мы передаём в хранилище действие, мы, фактически, вызываем метод reduce класса Store, который только что создали, и передаём ему состояние и действие. Эта конструкция называется корневым редьюсером. Можно заметить, что он принимает state и action — так же, как делает и 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 будут пропущены, и в результате у нас окажется дерево состояния, полученное через constructor.

Механизмы работы с подписчиками

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

В нашем случае работа с механизмами подписки будет выглядеть так:

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));
  }
  // ...
}

И это, опять же, просто. Каждый раз, когда мы вызываем dispatch, мы передаём методу reduce состояние и обходим подписчиков, передавая им this.value (помните о том, что тут срабатывает геттер value).

Теперь нам осталось решить лишь одну задачу. Когда мы вызываем .subscribe(), мы не хотим (в этот конкретный момент) получать значение state. Мы хотим получить его после выполнения метода dispatch. Поэтому примем решение информировать новых подписчиков о текущем состоянии как только они подпишутся:

export class Store {
  // ...

  subscribe(fn) {
    this.subscribers = [...this.subscribers, fn];
    fn(this.value);
  }

  // ...
}

Тут мы берём переданную через метод subscribe функцию и, после оформления подписки, вызываем её с передачей ей дерева состояния.

▍Отписка от хранилища

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

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

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;
  }
}

Как видите, всё не так уж и сложно.

Итоги

Вполне возможно, что вы слышали обо всех тех механизмах, о которых мы сегодня говорили, или даже использовали их, но не интересовались тем, как они устроены. Надеюсь, создав собственное хранилище, вы поняли, как работает всё то, из чего оно состоит. В работе действий и редьюсеров нет ничего таинственного. Метод dispatch сообщает хранилищу о необходимости выполнить процесс определения нового состояния путём вызова каждого редьюсера и попытки сопоставления action.type с одной из ветвей оператора switch. А дерево состояния — это итоговое представление того, что получается после вызова всех редьюсеров.

Я, благодаря примеру, которым поделился с вами, наконец понял Redux. Надеюсь, он поможет в этом и вам.

Уважаемые читатели! Как вы осваиваете новые технологии?

Автор: ru_vds

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js