Spec-Zone.ru › Redux 3

Реализация истории отмены

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

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

Однако с Redux реализация истории отмены — это лёгкая задача. Причин для этого три:

  • Нет множества моделей — только поддерево состояния, за которым вы хотите следить.
  • Состояние уже неизменяемо, а мутации уже описаны как отдельные действия, что близко к концепции стека отмены.
  • Подпись редьюсера (state, action) => state делает естественной реализацию универсальных «усилителей редьюсеров» или «редьюсеров высшего порядка». Это функции, которые принимают ваш редьюсер и расширяют его дополнительной функциональностью, сохраняя при этом его подпись. История отмены — именно такой случай.

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

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

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

demo of todos-with-undo

Понимание истории отмены

Проектирование структуры состояния

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

Например, структура состояния приложения счётчика может выглядеть так:

{
  counter: 10
}

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

  • Осталось ли что-то для отмены или повтора?
  • Каково текущее состояние?
  • Какие прошлые (и будущие) состояния находятся в стеке отмены?

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

{
  counter: {
    past: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    present: 10,
    future: []
  }
}

Теперь, если пользователь нажмёт «Отменить», мы хотим, чтобы это привело к перемещению в прошлое:

{
  counter: {
    past: [0, 1, 2, 3, 4, 5, 6, 7, 8],
    present: 9,
    future: [10]
  }
}

И ещё дальше:

{
  counter: {
    past: [0, 1, 2, 3, 4, 5, 6, 7],
    present: 8,
    future: [9, 10]
  }
}

Когда пользователь нажмёт «Повторить», мы хотим переместиться на один шаг вперёд во времени:

{
  counter: {
    past: [0, 1, 2, 3, 4, 5, 6, 7, 8],
    present: 9,
    future: [10]
  }
}

Наконец, если пользователь выполняет действие (например, уменьшает счётчик) во время нахождения в середине стека отмены, мы отбросим существующее будущее:

{
  counter: {
    past: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    present: 8,
    future: []
  }
}

Интересная часть здесь заключается в том, что не имеет значения, хотим ли мы хранить стек отмены чисел, строк, массивов или объектов. Структура всегда будет одинаковой:

{
  counter: {
    past: [0, 1, 2],
    present: 3,
    future: [4]
  }
}
{
  todos: {
    past: [
      [],
      [{ text: 'Use Redux' }],
      [{ text: 'Use Redux', complete: true }]
    ],
    present: [
      { text: 'Use Redux', complete: true },
      { text: 'Implement Undo' }
    ],
    future: [
      [
        { text: 'Use Redux', complete: true },
        { text: 'Implement Undo', complete: true }
      ]
    ]
  }
}

В общем, это выглядит так:

{
  past: Array<T>,
  present: T,
  future: Array<T>
}

Мы также можем самостоятельно решить, хранить ли одну верхнюю историю:

{
  past: [
    { counterA: 1, counterB: 1 },
    { counterA: 1, counterB: 0 },
    { counterA: 0, counterB: 0 }
  ],
  present: { counterA: 2, counterB: 1 },
  future: []
}

Или множество фрагментированных историй, чтобы пользователь мог отменять и повторять действия в них независимо:

{
  counterA: {
    past: [1, 0],
    present: 2,
    future: []
  },
  counterB: {
    past: [0],
    present: 1,
    future: []
  }
}

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

Проектирование алгоритма

Независимо от конкретного типа данных, форма состояния истории отмены одинакова:

{
  past: Array<T>,
  present: T,
  future: Array<T>
}

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

Обработка отмены

  • Удалить последний элемент из past.
  • Установить present на элемент, удалённый на предыдущем шаге.
  • Вставить старое состояние present в начало future.

Обработка повтора

  • Удалить первый элемент из future.
  • Установить present на элемент, удалённый на предыдущем шаге.
  • Вставить старое состояние present в конец past.

Обработка других действий

  • Вставить present в конец past.
  • Установить present на новое состояние после обработки действия.
  • Очистить future.

Первый вариант: написание редьюсера

const initialState = {
  past: [],
  present: null, // (?) How do we initialize the present?
  future: []
}

function undoable(state = initialState, action) {
  const { past, present, future } = state

  switch (action.type) {
    case 'UNDO':
      const previous = past[past.length - 1]
      const newPast = past.slice(0, past.length - 1)
      return {
        past: newPast,
        present: previous,
        future: [present, ...future]
      }
    case 'REDO':
      const next = future[0]
      const newFuture = future.slice(1)
      return {
        past: [...past, present],
        present: next,
        future: newFuture
      }
    default:
      // (?) How do we handle other actions?
      return state
  }
}

Эта реализация непригодна, поскольку она опускает три важных вопроса:

  • Откуда мы берём начальное состояние present? Нам оно, похоже, заранее неизвестно.
  • Где мы реагируем на внешние действия, чтобы сохранить present в past?
  • Как мы фактически делегируем управление состоянием present пользовательскому редьюсеру?

Похоже, редьюсер — не лучший подход, но мы очень близки.

Знакомьтесь с усилителями редьюсеров

Вы, возможно, знакомы с функциями высшего порядка. Если вы используете React, вы, возможно, знакомы с компонентами высшего порядка. Вот вариация того же шаблона, применённого к редьюсерам.

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

Усилитель редьюсера, который ничего не делает, выглядит так:

function doNothingWith(reducer) {
  return function (state, action) {
    // Just call the passed reducer
    return reducer(state, action)
  }
}

Усилитель редьюсера, который объединяет другие редьюсеры, может выглядеть так:

function combineReducers(reducers) {
  return function (state = {}, action) {
    return Object.keys(reducers).reduce((nextState, key) => {
      // Call every reducer with the part of the state it manages
      nextState[key] = reducers[key](state[key], action)
      return nextState
    }, {})
  }
}

Второй вариант: написание усилителя редьюсера

Теперь, когда мы лучше понимаем усилители редьюсеров, мы можем увидеть, что именно это undoable должно было быть:

function undoable(reducer) {
  // Call the reducer with empty action to populate the initial state
  const initialState = {
    past: [],
    present: reducer(undefined, {}),
    future: []
  }

  // Return a reducer that handles undo and redo
  return function (state = initialState, action) {
    const { past, present, future } = state

    switch (action.type) {
      case 'UNDO':
        const previous = past[past.length - 1]
        const newPast = past.slice(0, past.length - 1)
        return {
          past: newPast,
          present: previous,
          future: [present, ...future]
        }
      case 'REDO':
        const next = future[0]
        const newFuture = future.slice(1)
        return {
          past: [...past, present],
          present: next,
          future: newFuture
        }
      default:
        // Delegate handling the action to the passed reducer
        const newPresent = reducer(present, action)
        if (present === newPresent) {
          return state
        }
        return {
          past: [...past, present],
          present: newPresent,
          future: []
        }
    }
  }
}

Теперь мы можем обернуть любой редьюсер в undoable усилитель редьюсера, чтобы научить его реагировать на UNDO и REDO действия.

// This is a reducer
function todos(state = [], action) {
  /* ... */
}

// This is also a reducer!
const undoableTodos = undoable(todos)

import { createStore } from 'redux'
const store = createStore(undoableTodos)

store.dispatch({
  type: 'ADD_TODO',
  text: 'Use Redux'
})

store.dispatch({
  type: 'ADD_TODO',
  text: 'Implement Undo'
})

store.dispatch({
  type: 'UNDO'
})

Важно помнить об одном нюансе: вам нужно помнить о добавлении .present к текущему состоянию при его получении. Вы также можете проверить .past.length и .future.length для определения того, следует ли включить или выключить кнопки «Отменить» и «Повторить» соответственно.

Вы, возможно, слышали, что Redux был вдохновлён архитектурой Elm. Не должно быть неожиданностью, что этот пример очень похож на пакет elm-undo-redo.

Использование Redux Undo

Всё это очень познавательно, но неужели мы не можем просто подключить библиотеку и использовать её вместо реализации undoable самостоятельно? Конечно, можем! Знакомьтесь с Redux Undo — библиотекой, которая предоставляет простую функциональность отмены и повтора для любой части вашего дерева Redux.

В этой части рецепта вы узнаете, как сделать пример списка дел отменяемым. Полный исходный код этого рецепта вы найдёте в todos-with-undo примере, который поставляется с Redux.

Установка

Прежде всего, вам нужно выполнить

npm install --save redux-undo

Это установит пакет, который предоставляет усилитель редьюсера undoable.

Обертывание редьюсера

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

reducers/todos.js

import undoable, { distinctState } from 'redux-undo'

/* ... */

const todos = (state = [], action) => {
  /* ... */
}

const undoableTodos = undoable(todos, {
  filter: distinctState()
})

export default undoableTodos

Фильтр distinctState() служит для игнорирования действий, которые не привели к изменению состояния. Есть множество других настроек для вашего отменяемого редьюсера, таких как настройка типа действия для отмены и повтора.

Обратите внимание, что ваш вызов combineReducers() останется точно таким же, но редьюсер todos теперь будет ссылаться на редьюсер, улучшенный с помощью Redux Undo:

reducers/index.js

import { combineReducers } from 'redux'
import todos from './todos'
import visibilityFilter from './visibilityFilter'

const todoApp = combineReducers({
  todos,
  visibilityFilter
})

export default todoApp

Вы можете обернуть один или несколько редьюсеров в undoable на любом уровне иерархии композиции редьюсеров. Мы выбрали обернуть todos вместо верхнего объединённого редьюсера, чтобы изменения в visibilityFilter не отражались в истории отмены.

Обновление селекторов

Теперь часть состояния todos выглядит так:

{
  visibilityFilter: 'SHOW_ALL',
  todos: {
    past: [
      [],
      [{ text: 'Use Redux' }],
      [{ text: 'Use Redux', complete: true }]
    ],
    present: [
      { text: 'Use Redux', complete: true },
      { text: 'Implement Undo' }
    ],
    future: [
      [
        { text: 'Use Redux', complete: true },
        { text: 'Implement Undo', complete: true }
      ]
    ]
  }
}

Это означает, что вам нужно получить доступ к вашему состоянию с помощью state.todos.present вместо state.todos:

containers/VisibleTodoList.js

const mapStateToProps = state => {
  return {
    todos: getVisibleTodos(state.todos.present, state.visibilityFilter)
  }
}

Добавление кнопок

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

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

containers/UndoRedo.js

import React from 'react'

/* ... */

let UndoRedo = ({ canUndo, canRedo, onUndo, onRedo }) => (
  <p>
    <button onClick={onUndo} disabled={!canUndo}>
      Undo
    </button>
    <button onClick={onRedo} disabled={!canRedo}>
      Redo
    </button>
  </p>
)

Вы будете использовать connect() из React Redux для генерации компонента контейнера. Для определения того, следует ли включать кнопки «Отменить» и «Повторить», вы можете проверить state.todos.past.length и state.todos.future.length соответственно. Вам не нужно писать создатели действий для выполнения отмены и повтора, так как Redux Undo уже предоставляет их:

containers/UndoRedo.js

/* ... */

import { ActionCreators as UndoActionCreators } from 'redux-undo'
import { connect } from 'react-redux'

/* ... */

const mapStateToProps = state => {
  return {
    canUndo: state.todos.past.length > 0,
    canRedo: state.todos.future.length > 0
  }
}

const mapDispatchToProps = dispatch => {
  return {
    onUndo: () => dispatch(UndoActionCreators.undo()),
    onRedo: () => dispatch(UndoActionCreators.redo())
  }
}

UndoRedo = connect(
  mapStateToProps,
  mapDispatchToProps
)(UndoRedo)

export default UndoRedo

Теперь вы можете добавить компонент UndoRedo в компонент App:

components/App.js

import React from 'react'
import Footer from './Footer'
import AddTodo from '../containers/AddTodo'
import VisibleTodoList from '../containers/VisibleTodoList'
import UndoRedo from '../containers/UndoRedo'

const App = () => (
  <div>
    <AddTodo />
    <VisibleTodoList />
    <Footer />
    <UndoRedo />
  </div>
)

export default App

Вот оно! Запустите npm install и npm start в папке пример и попробуйте!

© 2015–2017 Dan Abramov
Licensed under the MIT License.
http://redux.js.org/docs/recipes/ImplementingUndoHistory.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API