Понимание Redux путём создания Redux

1656659064 ponimanie redux putyom sozdaniya

Джонни Снелгроув

1*3-d_IpVFeG6uozLxANq2sg

Две важнейшие техники, которые я нашел, чтобы помочь быстро понять концепцию упрощение и обучение в процессе работы. Redux — очень популярная библиотека JavaScript для разработки «контейнеров предполагаемого состояния для JavaScript». Он использует функциональный подход к моделированию данных, бросающий вызов традиционному шаблону MVC.

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

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

Что такое Уменьшить?

Ключ к пониманию Redux заключается в понимании мощности уменьшить функция. Из документов Mozilla:

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

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

function reduce (collection, transitionFn, initialState) {  let accumulator = initialState || collection[0]  for (let i = (initialState ? 0 : 1); i < collection.length; i++) {    accumulator = transitionFn(accumulator, collection[i])  }  return accumulator}

Дополнительное примечание: функция перехода фактически принимает четыре аргумента: накопитель, текущее значение (т.е. коллекция[i]), индекс текущего значения та же коллекция. Однако для демонстрации аргументы индекса и коллекции здесь опущены, поскольку они не имеют значения.

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

Использование Уменьшить

Чтобы понять силу уменьшитьмы начнем с канонической функции редуктора, сумма():

function sum (nums) {  return nums.reduce((state, nextVal) => state + nextVal)}
sum([1, 2, 3, 4]) // => 10

Этот пример никогда не дал мне этого «Ага!» момент. Возможно потому, что это скрывает сигнатуру функции и не очень интересно. Вот тот же пример со всем четким описанием:

function sum (nums) {  function transition (prevState, nextVal) {    return prevState + nextVal  }  const [initialState, ...tail] = nums  return tail.reduce(transition, initialState)}
sum([1, 2, 3, 4]) // => 10

Примечание: const [initialState, …tail] = nums использует деструктуризацию ES6, чтобы разделить массив на первый элемент (initialState) и другие элементы (tail).

Здесь мы видим, что функция уменьшения принимает функцию перехода в качестве первого аргумента, а начальное начальное состояние как второй аргумент. По умолчанию, если исходное состояние не предоставлено, функция уменьшения использует первый элемент в массиве в качестве исходного состояния.

Конкретизация

Чтобы концептуально перейти к моделированию более интересных данных, мы можем переписать сумма с именами переменных домена:

function move (steps) {  return steps.reduce((state, direction) => state + direction)}
xPosition = -2xPosition = xPosition + move([-1, -1, 0, 1, 1, 1])console.log(xPosition) // => -1

Это та же функция суммирования, но теперь стало немного понятнее, как ее можно использовать в реальных программах. Наш игровой персонаж начинает с начальной позиции -2, которую мы затем объединяем со списком указаний, чтобы определить новую позицию. Каждое значение в массиве передается в двигаться функцию можно рассматривать как действие подскажет редуктору, как изменить свое состояние. Здесь наши действия не имеют названий, но придерживаясь некоторых простых сделок, мы приходим к основе redux:

let store = 0 // initial position
const reducer = (state, action) => {  switch (action.type) {    case 'MOVE_LEFT':      return state - action.distance    case 'MOVE_RIGHT':      return state + action.distance    case 'WAIT':    default:      return state  }}
console.log(store) // => 0
store = [  {type: 'MOVE_LEFT', distance: 2 },  {type: 'MOVE_LEFT', distance: 3 },  {type: 'MOVE_RIGHT', distance: 7 },  {type: 'WAIT'}].reduce(reducer, store)
console.log(store) // => 2

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

Мы также приходим к концепции, сходной с концепцией класса с переменными экземпляров и методами. В ООП все в сохранять может быть переменной экземпляра, а типами действий будут методы:

class Mover {  constructor (x) {    this.x = x  }
  moveLeft (distance) {    this.x -= distance  }
  moveRight (distance) {    this.x += distance  }}
let agent = new Mover(0)agent.moveLeft(1)agent.moveLeft(1)agent.moveRight(1)

Комплексные данные

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

let store = { x:0, y:0, health: 100 } // initial state
const reducer = (state, action) => {  switch (action.type) {    case 'MOVE_LEFT':      return { ...state, x: state.x - action.distance }    case 'MOVE_RIGHT':      return { ...state, x: state.x + action.distance }    case 'MOVE_UP':      return { ...state, y: state.y - action.distance }    case 'MOVE_DOWN':      return { ...state, y: state.y + action.distance }    case 'TAKE_DAMAGE':      return { ...state, health: state.health - action.damage }    case 'DRINK_POTION':      return { ...state, health: state.health + action.health }    case 'WAIT':    default:      return state  }}
console.log(store) // => { x:0, y:0, health: 100 }store = [  {type: 'MOVE_LEFT', distance: 2 },  {type: 'MOVE_LEFT', distance: 3 },  {type: 'MOVE_RIGHT', distance: 7 },  {type: 'WAIT'},  {type: 'MOVE_DOWN', distance: 7 },  {type: 'TAKE_DAMAGE', damage: 50 },  {type: 'DRINK_POTION', health: 25 },  {type: 'MOVE_UP', distance: 2 },].reduce(reducer, store)console.log(store) // => { x:2, y:5, health: 75 }

Здесь государство – это объект с формой {x: Float, y: Float, health: Float}. Редуктор должен вернуть новый объект такой же формы. Чтобы вернуть новый объект, мы используем деструктуризацию объекта ES6 (например, {...state}), чтобы создать копию переданного объекта состояния, а затем перезаписать поле, которое мы хотим обновить, одним кратким декларативным выражением: return {...oldState, key: newKeyVal}. Теперь мы готовим еду на огне!

Обобщение и инкапсуляция

Чтобы завершить эту логику и сделать магазины всеобщими и многократными, мы можем написать a createStore функция для инкапсуляции состояния и обеспечения согласованного API для чтения состояния и диспетчеризации действий:

const createStore = (reducer, initialState) => {  let store = initialState || reducer(undefined, {type: 'INIT'})  return {    dispatch: (action) => {      store = [action].reduce(reducer, store)    },    getState: _ => store  }}
var moverReducer = (state = { x:0, y:0 }, action) => {  switch (action.type) {    case 'MOVE_LEFT':      return { ...state, x: state.x - action.distance }    case 'MOVE_RIGHT':      return { ...state, x: state.x + action.distance }    case 'MOVE_UP':      return { ...state, y: state.y - action.distance }    case 'MOVE_DOWN':      return { ...state, y: state.y + action.distance }    case 'WAIT':    default:      return state  }}
let agent = createStore(moverReducer)agent.dispatch({type:'MOVE_UP', distance: 1})agent.dispatch({type:'MOVE_LEFT', distance: 2})agent.dispatch({type:'MOVE_RIGHT', distance: 4})agent.dispatch({type:'MOVE_DOWN', distance: 2})agent.getState() // => { x:-2, y:0 }

Здесь мы можем или пройти createStore() исходное состояние (возможно, что-то, что мы загружаем из localStorage), или оно инициализируется с помощью аргумента состояния по умолчанию редюсера и фиктивного действия. Наше состояние инкапсулировано с помощью закрытия, и единственный способ читать и писать в него – через возвращенный getState() и отправка() методы, соответственно.

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

Плюсы и минусы

Первое очевидное преимущество подхода редуктора состоит в том, что все легко сериализируется. Мы могли бы легко использовать localStorage для сохранения и загрузки состояния, сериализации последовательностей действий, отправки действий через WebSockets или HTTP-запросы и т.п., без создания обработчиков для перевода полезных данных JSON в вызовы методов экземпляров.

Кроме того, поскольку редукторы должны быть чистыми функциями, нет гарантии, что неожиданные побочные эффекты не возникнут в других частях программы при обновлении данных модели. Магазин занимается исключительно моделированием данных и логикой. Это делает наши модели данных очень портативными, поскольку их не касается среда выполнения. Те же редукторы можно потенциально использовать в приложении cli node.js, веб-приложении или нативном приложении через что-то вроде React Native. Перенос программы становится вопросом написания специфических для платформы побочных эффектов и кода просмотра.

Наконец я лично считаю редукторы элегантными. Концепция ближе к математическому уравнению, устанавливающему значение в модели из сценария контроллера. Рассмотрите формулу Q-обучения в качестве примера. Его подписью является пара состояние/действие! Это упрощает перевод формулы в код.

Недостатком является то, что redux не имеет твердого мнения по поводу того, как справиться с побочными эффектами (например, визуализация в DOM, вход в консоль, сохранение в localStorage, запуск запроса Ajax и так далее). Вы не можете создать интересную программу без побочных эффектов, поэтому это может немного огорчать.

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

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

Завертывание и блуждание

Чтобы подытожить все это, мы можем добавить цикл обновления для отправки случайных действий и визуализации состояния агента (здесь я использую React, но мы можем использовать любой слой просмотра, который нам нравится). После каждого тыка агент или двигается в определенном направлении, ждет, принимает снадобье или получает повреждение. Если здоровье агента равно нулю, он сбрасывается.

Обратите внимание, как логика начинает скапливаться в цикле обновления/рендеринга верхнего уровня. Кроме того, нам пришлось дублировать код для исходного состояния редуктора, чтобы сбросить агент, когда его работоспособность достигает нуля.

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

Добавить комментарий

Ваш адрес email не будет опубликован.