Skip to main content

实现历史撤销重做

预置知识

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

这意味着在 MVC 应用程序中实现 Undo 和 Redo 通常会迫使你重写应用程序的某些部分,以使用特定的数据变化的模式,如 Command.

然而,对于 Redux,实现撤销历史记录是一件轻而易举的事。原因有三:

  • 不存在多个数据模型,只有一个 state 子树需要跟踪。
  • state 已经是 immutable 的,mutation 已经被描述为离散的 action,这已经很接近于撤销堆栈的真实堆栈模型。
  • Reducer (state, action) => state 签名使得实现通用的“reducer enhancer”或“高阶 reducer”变得很自然。它们是在保留其签名的同时,使用一些附加功能来增强 reducer 的函数。历史撤销重做就是一个典型场景。

在本秘诀的第一部分,我们将说明实现撤销重做的用到的一些基本概念。

在第二部分中,我们会展示怎么使用 Redux Undo 实现撤销重做,这个包提供了现成的功能。

todos-with-undo 的 demo

理解历史撤销重做

State 形状设计

撤销历史记录也是应用 state 的一部分,处理它的时候不能搞特殊。无论 state 的类型随时间怎么变化,当实现 Undo 和 Redo 时,都希望在不同的时间点跟踪此 state 的历史

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

{
counter: 10
}

如果想在这样的应用中实现撤销和重做,我们需要存储更多的 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: []
}

或者将历史记录划分为多种粒度,以便用户可以独立撤消和重做其中的 action:

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

接下来看我们的方法是怎么选择撤销重做的粒度的

算法设计

无论特定的数据类型如何,撤消历史 state 的形状都是相同的:

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

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

处理撤销

  • past 移除最后一个元素。
  • 把上一步移出的元素赋值给 present
  • 把老的 present 状态插入到 future 开头

处理重做

  • future 中移除第一个元素。
  • 将前一步移出的那个元素赋值给 present
  • 把老的 present 状态插入到 past末尾

处理其他 action

  • present 插入到 past 的末尾。
  • 将执行 action 后的新 state 赋值给 present
  • 清空 future

第一次尝试:编写 Reducer

const initialState = {
past: [],
present: null, // (?) 怎么初始化当前的状态?
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:
// (?)怎么处理其他 action?
return state
}
}

此实现行不通,因为它忽略了三个重要问题:

  • 我们从哪里得到初始的 present state?我们似乎事先不知道。
  • 执行完外部 action 之后,在什么时候在哪里将 present 保存为 past
  • 我们如何实际将对 present state 的控制委托给自定义的 reducer?

看起来 reducer 不是正确的抽象方式,但非常接近。

了解 Reducer enhancer

你可能熟悉高阶函数。如果使用 React,可能也熟悉[高阶组件](https://medium.com/@dan_abramov/mixins-are-dead-long-live-higher-order-components-94a0d2f9e750)。下面是应用于 reducer 的同一模式的变体。

reducer enhancer (或者说高阶 reducer)作为一个函数,接收 reducer 作为参数并返回一个新的 reducer,这个新的 reducer 可以处理新的 action,或者维护更多的 state,亦或者将它无法处理的 action 委托给原始的 reducer 处理。这不是什么新模式,combineReducers()也是 reducer enhancer,因为它同样接收多个 reducer 并返回一个新的 reducer。

一个不做任何事情的 reducer enhancer 长这样:

function doNothingWith(reducer) {
return function (state, action) {
// 仅仅调用传入的 reducer
return reducer(state, action)
}
}

组合其他 reducer 的 reducer enhancer 可能长这样:

function combineReducers(reducers) {
return function (state = {}, action) {
return Object.keys(reducers).reduce((nextState, key) => {
// 调用每一个 reducer 并将其管理的部分 state 传给它
nextState[key] = reducers[key](state[key], action)
return nextState
}, {})
}
}

第二次尝试: 写一个 Reducer enhancer

现在我们对 reducer enhancer 有了更深的了解,这正是 undoable 的原因:

function undoable(reducer) {
// 使用一个空 action 调用 reducer 以填充初始状态
const initialState = {
past: [],
present: reducer(undefined, {}),
future: []
}

// 返回处理撤消和重做的 reducer
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:
// 代理传给 reducer 的 action
const newPresent = reducer(present, action)
if (present === newPresent) {
return state
}
return {
past: [...past, present],
present: newPresent,
future: []
}
}
}
}

现在,我们可以将任何 reducer 包装到 undoable reducer enhancer 中,让它对 UNDOREDO action 做出响应。

// 这是个 reducer
function todos(state = [], action) {
/* ... */
}

// 这也是个 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'
})

有一个重要的问题:记得在检索当前 state 的时候附加上 .present。你也可能分别检查 .past.length.future.length 来决定启用或禁用撤销重做的按钮。

你可能听说过 Redux 受到 Elm 架构 的影响。这个例子与 elm-undo-redo 包非常相似,这并不奇怪。

使用 Redux Undo

以上都是非常有用的信息,但是有没有一个库能帮助我们实现 undoable 功能,而不是由我们自己编写呢?当然有!去看 Redux Undo,这是一个给你的 Redux 树中任意部分提供撤销重做功能的库。

在这个部分,你将学习如何让一个小的 “todo list” 应用逻辑支持撤销重做。你可以在 Redux 附带的 todos with undo示例中找到完整源代码.

安装

首先,你要执行:

npm install redux-undo

安装的包将会提供 undoable reducer enhancer。

封装 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

也有 很多其他选择 options 用来配置 undoable reducer,比如为撤销或重做的 action 设置特殊的 action type。

注意,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 组合层任意级,在 undoable 中封装一个或多个 reducer。我们选择封装 todos 而不是顶层组合 reducer,这样对于 visibilityFilter 的修改就不会被反应在撤销的历史中。

更新 Selectors

现在 state 中关于 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,而不仅仅是 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。不需要编写 action creator 来执行撤消和重做,因为 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

现在你能在 App 组件中添加 UndoRedo 组件了:

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

就是这样!在 example 文件夹 运行 npm installnpm start 试试!