Перейти к основному содержимому
Неофициальный Бета-перевод

Эта страница переведена PageTurner AI (бета). Не одобрена официально проектом. Нашли ошибку? Сообщить о проблеме →

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

Необходимые знания

Реализация функциональности Undo (отмена) и Redo (повтор) традиционно требовала значительных усилий от разработчика. В классических MVC-фреймворках это непростая задача, поскольку необходимо отслеживать все предыдущие состояния путём клонирования соответствующих моделей. Кроме того, нужно учитывать стек отмены, так как изменения, инициированные пользователем, должны быть обратимыми.

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

В Redux же реализация истории отмены невероятно проста. На это есть три причины:

  • Отсутствие множества моделей — только поддерево состояния, которое нужно отслеживать.

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

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

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

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

Демонстрация todos-with-undo

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

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

История отмены — часть состояния вашего приложения, и нет причин подходить к ней иначе. Независимо от типа состояния, которое изменяется со временем, при реализации Undo/Redo необходимо отслеживать историю этого состояния в разные моменты времени.

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

{
counter: 10
}

Для реализации Undo/Redo в таком приложении потребуется хранить больше состояния, чтобы отвечать на вопросы:

  • Остались ли действия для отмены или повтора?

  • Какое текущее состояние?

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

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

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

Теперь при нажатии «Undo» состояние должно измениться, перемещаясь в прошлое:

{
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]
}
}

При нажатии «Redo» мы перемещаемся на один шаг в будущее:

{
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. В редьюсере выполним следующие шаги для обработки:

Обработка отмены (UNDO)

  • Удалить последний элемент из past.

  • Установить present в значение удалённого элемента.

  • Поместить старое состояние present в начало future.

Обработка повтора (REDO)

  • Удалить первый элемент из 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 пользовательскому редьюсеру?

Редьюсер кажется неподходящей абстракцией, но мы близки к решению.

Знакомьтесь: усилители редьюсеров (Reducer Enhancers)

Вы могли слышать о функциях высшего порядка. Для 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 Architecture. Неудивительно, что этот пример очень похож на пакет elm-undo-redo.

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

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

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

Установка

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

npm install redux-undo

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

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

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

reducers/todos.js

import undoable from 'redux-undo'

/* ... */

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

const undoableTodos = undoable(todos)

export default undoableTodos

Существует множество других опций для настройки вашего редюсера с поддержкой отмены, например, установка типа действия для действий Undo и Redo.

Обратите внимание, что вызов 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)
}
}

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

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

Сначала создайте новый контейнерный компонент 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 для создания контейнерного компонента. Чтобы определить, следует ли активировать кнопки Undo и Redo, вы можете проверить state.todos.past.length и state.todos.future.length. Вам не нужно писать создателей действий для выполнения undo и redo, потому что 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 в папке примера и попробуйте!