跳至主要内容

实现撤销历史记录

先决条件

在应用程序中构建撤销和重做功能传统上需要开发人员的刻意努力。对于经典的 MVC 框架来说,这不是一个容易的问题,因为您需要通过克隆所有相关的模型来跟踪每个过去的状态。此外,您需要注意撤销堆栈,因为用户发起的更改应该是可撤销的。

这意味着在 MVC 应用程序中实现撤销和重做通常会迫使您重写应用程序的某些部分以使用特定数据变异模式,例如 命令

然而,使用 Redux 实现撤销历史轻而易举。这主要有三个原因:

  • 没有多个模型,只有一个你想要跟踪的 state 子树。
  • state 已经是不可变的,并且 mutations 已经被描述为离散的动作,这与撤销堆栈的思维模型很接近。
  • reducer 的 (state, action) => state 签名使得实现通用的“reducer 增强器”或“高阶 reducer”变得自然。它们是接受你的 reducer 并用一些额外的功能增强它,同时保留其签名的函数。撤销历史正是这样的一个例子。

在本食谱的第一部分,我们将解释使撤销和重做能够以通用方式实现的底层概念。

在本食谱的第二部分,我们将展示如何使用 Redux Undo 包,该包提供了开箱即用的功能。

demo of todos-with-undo

理解撤销历史

设计 state 形状

撤销历史也是你应用程序 state 的一部分,没有理由以不同的方式处理它。无论 state 随时间变化的类型是什么,当你实现撤销和重做时,你都希望跟踪此 state 在不同时间点的历史

例如,计数器应用程序的 state 形状可能如下所示

{
counter: 10
}

如果我们想在这样的应用程序中实现撤销和重做,我们需要存储更多 state,以便我们可以回答以下问题

  • 还有撤销或重做的操作吗?
  • 当前 state 是什么?
  • 撤销堆栈中的过去(和未来)state 是什么?

可以合理地建议我们的 state 形状应该改变以回答这些问题

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

让我们讨论一下操作上述状态形状的算法。我们可以定义两个操作来操作此状态:UNDOREDO。在我们的 reducer 中,我们将执行以下步骤来处理这些操作

处理撤消

  • past 中删除最后一个元素。
  • present 设置为我们在上一步中删除的元素。
  • 将旧的 present 状态插入 future开头

处理重做

  • future 中删除第一个元素。
  • present 设置为我们在上一步中删除的元素。
  • 将旧的 present 状态插入 past末尾

处理其他操作

  • present 插入 past 的末尾。
  • present 设置为处理操作后的新状态。
  • 清除 future

第一次尝试:编写一个 Reducer

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?

似乎 reducer 不是正确的抽象,但我们非常接近。

认识 Reducer 增强器

您可能熟悉高阶函数。如果您使用 React,您可能熟悉高阶组件。这是对相同模式的变体,应用于 reducer。

reducer 增强器(或高阶 reducer)是一个函数,它接受一个 reducer,并返回一个新的 reducer,该 reducer 能够处理新的 action,或保存更多状态,并将控制权委托给它不理解的 action 的内部 reducer。这不是一个新的模式——从技术上讲,combineReducers() 也是一个 reducer 增强器,因为它接受 reducer 并返回一个新的 reducer。

一个什么也不做的 reducer 增强器看起来像这样

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

一个组合其他 reducer 的 reducer 增强器可能看起来像这样

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
}, {})
}
}

第二次尝试:编写一个 Reducer 增强器

现在我们对 reducer 增强器有了更好的理解,我们可以看到这正是 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: []
}
}
}
}

现在我们可以将任何 reducer 包装到 undoable reducer 增强器中,以教它对 UNDOREDO action 做出反应。

// 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 树的任何部分提供简单撤销和重做功能的库。

在本部分的教程中,你将学习如何使一个小的“待办事项列表”应用程序逻辑可撤销。你可以在 Redux 附带的 todos-with-undo 示例 中找到本教程的完整源代码。

安装

首先,你需要运行

npm install redux-undo

这将安装提供 undoable reducer 增强器的包。

包装 Reducer

你需要用 undoable 函数包装你想要增强的 reducer。例如,如果你从一个专用文件中导出了一个 todos reducer,你将希望将其更改为导出使用你编写的 reducer 调用 undoable() 的结果

reducers/todos.js

import undoable from 'redux-undo'

/* ... */

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

const undoableTodos = undoable(todos)

export default undoableTodos

许多其他选项 来配置你的可撤销 reducer,例如设置撤销和重做操作的动作类型。

注意,你的 combineReducers() 调用将保持不变,但 todos reducer 现在将引用使用 Redux Undo 增强的 reducer。

reducers/index.js

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

const todoApp = combineReducers({
todos,
visibilityFilter
})

export default todoApp

你可以在 reducer 组合层次结构的任何级别将一个或多个 reducer 包裹在 undoable 中。我们选择包裹 todos 而不是顶层组合 reducer,这样 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>
)

你将使用来自 React Reduxconnect() 来生成一个容器组件。为了确定是否启用撤销和重做按钮,你可以检查 state.todos.past.lengthstate.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 installnpm start 并试一试!