实现撤销重做

:::

在应用中构建撤销和重做功能往往需要开发者刻意地付出一些精力。对于经典的 MVC 框架来说,这不是一个简单的问题,因为你需要克隆所有相关的 model 来追踪每一个历史状态。此外,你需要考虑整个撤销堆栈,因为用户的初始更改也是可撤销的。

这意味着在 MVC 应用中实现撤销和重做功能时,你不得不使用一些类似于 Command 的特殊的数据修改模式来重写你的应用代码。

然而你可以用 Redux 轻而易举地实现撤销历史,因为以下三个原因:

  • 不存在多个模型的问题,你需要关心的只是 state 的子树。
  • state 是不可变数据,所有修改被描述成独立的 action,而这些 action 与预期的撤销堆栈模型很接近了。
  • reducer 的签名 (state, action) => state 可以自然地实现 “reducer enhancers” 或者 “higher order reducers”。它们在你为 reducer 添加额外的功能时保持着这个签名。撤销历史就是一个典型的应用场景。

本文的第一部分,会解释实现撤销和重做功能所用到的基础概念。

在第二部分中,会展示如何使用 Redux Undo 库来无缝地实现撤销和重做。

demo of todos-with-undo

理解撤销历史

设计状态结构

撤销历史也是应用 state 的一部分,我们没有必要以不同的方式实现它。当你实现撤销和重做这个功能时,无论 state 如何随着时间不断变化,你都需要追踪 state 在不同时刻的历史记录

例如,一个计数器应用的 state 结构看起来可能是这样:

  1. {
  2. counter: 10
  3. }

如果我们希望在这样一个应用中实现撤销和重做的话,我们必须保存更多的 state 以解决下面几个问题:

  • 撤销或重做留下了哪些信息?
  • 当前的状态是什么?
  • 撤销堆栈中过去(和未来)的状态是什么?

为此我们对 state 结构做了以下修改以便解决上述问题:

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

现在,如果按下“撤销”,我们希望恢复到过去的状态:

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

再按一次:

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

当我们按下“重做”,我们希望往未来的状态移动一步:

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

最终,当处于撤销堆栈中时,用户发起了一个操作(例如,减少计数),那么我们将会丢弃所有未来的信息:

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

有趣的一点是,我们在撤销堆栈中保存的是数字、字符串、数组或是对象都不重要,因为整个结构始终保持一致:

  1. {
  2. counter: {
  3. past: [ 0, 1, 2 ],
  4. present: 3,
  5. future: [ 4 ]
  6. }
  7. }
  1. {
  2. todos: {
  3. past: [
  4. [],
  5. [ { text: 'Use Redux' } ],
  6. [ { text: 'Use Redux', complete: true } ]
  7. ],
  8. present: [ { text: 'Use Redux', complete: true }, { text: 'Implement Undo' } ],
  9. future: [
  10. [ { text: 'Use Redux', complete: true }, { text: 'Implement Undo', complete: true } ]
  11. ]
  12. }
  13. }

它看起来通常都是这样:

  1. {
  2. past: Array<T>,
  3. present: T,
  4. future: Array<T>
  5. }

我们可以在顶层保存单一的历史记录:

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

也可以分离历史记录,这样我们可以独立地执行撤销和重做操作:

  1. {
  2. counterA: {
  3. past: [ 1, 0 ],
  4. present: 2,
  5. future: []
  6. },
  7. counterB: {
  8. past: [ 0 ],
  9. present: 1,
  10. future: []
  11. }
  12. }

接下来我们将会看到如何合适地分离撤销和重做。

设计算法

无论何种特定的数据类型,重做历史记录的 state 结构始终一致:

  1. {
  2. past: Array<T>,
  3. present: T,
  4. future: Array<T>
  5. }

让我们讨论一下如何通过算法来操作上文所述的 state 结构。我们可以定义两个 action 来操作该 state:UNDOREDO。在 reducer 中,我们希望以如下步骤处理这两个 action:

处理 Undo

  • 移除 past 中的最后一个元素。
  • 将上一步移除的元素赋予 present
  • 将原来的 present 插入到 future最前面

处理 Redo

  • 移除 future 中的第一个元素。
  • 将上一步移除的元素赋予 present
  • 将原来的 present 追加到 past最后面

处理其他 Action

  • 将当前的 present 追加到 past最后面
  • 将处理完 action 所产生的新的 state 赋予 present
  • 清空 future

第一次尝试: 编写 Reducer

  1. const initialState = {
  2. past: [],
  3. present: null, // (?) 我们如何初始化当前状态?
  4. future: []
  5. }
  6. function undoable(state = initialState, action) {
  7. const { past, present, future } = state
  8. switch (action.type) {
  9. case 'UNDO':
  10. const previous = past[past.length - 1]
  11. const newPast = past.slice(0, past.length - 1)
  12. return {
  13. past: newPast,
  14. present: previous,
  15. future: [present, ...future]
  16. }
  17. case 'REDO':
  18. const next = future[0]
  19. const newFuture = future.slice(1)
  20. return {
  21. past: [...past, present],
  22. present: next,
  23. future: newFuture
  24. }
  25. default:
  26. // (?) 我们如何处理其他 action?
  27. return state
  28. }
  29. }

这个实现是无法使用的,因为它忽略了下面三个重要的问题:

  • 我们从何处获取初始的 present 状态?我们无法预先知道它。
  • 当处理完外部的 action 后,我们在哪里完成将 present 保存到 past 的工作?
  • 我们如何将 present 状态的控制委托给一个自定义的 reducer?

看起来 reducer 并不是正确的抽象方式,但是我们已经非常接近了。

初识 Reducer Enhancers

你可能已经熟悉 higher order function 了。如果你使用过 React,也应该熟悉 higher order component。我们把这种模式加工一下,将其运用到 reducers。

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

这是一个没有任何功能的 reducer enhancer 示例:

  1. function doNothingWith(reducer) {
  2. return function(state, action) {
  3. // 仅仅调用传入的 reducer
  4. return reducer(state, action)
  5. }
  6. }

一个组合其他 reducer 的 reducer enhancer 看起来类似于这样:

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

第二次尝试: 编写 Reducer Enhancer

现在我们对 reducer enhancer 有了更深的了解,我们可以明确所谓的可撤销到底是什么:

  1. function undoable(reducer) {
  2. // 以一个空的 action 调用 reducer 来产生初始的 state
  3. const initialState = {
  4. past: [],
  5. present: reducer(undefined, {}),
  6. future: []
  7. }
  8. // 返回一个可以执行撤销和重做的新的reducer
  9. return function(state = initialState, action) {
  10. const { past, present, future } = state
  11. switch (action.type) {
  12. case 'UNDO':
  13. const previous = past[past.length - 1]
  14. const newPast = past.slice(0, past.length - 1)
  15. return {
  16. past: newPast,
  17. present: previous,
  18. future: [present, ...future]
  19. }
  20. case 'REDO':
  21. const next = future[0]
  22. const newFuture = future.slice(1)
  23. return {
  24. past: [...past, present],
  25. present: next,
  26. future: newFuture
  27. }
  28. default:
  29. // 将其他 action 委托给原始的 reducer 处理
  30. const newPresent = reducer(present, action)
  31. if (present === newPresent) {
  32. return state
  33. }
  34. return {
  35. past: [...past, present],
  36. present: newPresent,
  37. future: []
  38. }
  39. }
  40. }
  41. }

我们现在可以将任意的 reducer 封装到可撤销的 reducer enhancer,从而处理 UNDOREDO 这两个 action。

  1. // 这是一个 reducer。
  2. function todos(state = [], action) {
  3. /* ... */
  4. }
  5. // 处理完成之后仍然是一个 reducer!
  6. const undoableTodos = undoable(todos)
  7. import { createStore } from 'redux'
  8. const store = createStore(undoableTodos)
  9. store.dispatch({
  10. type: 'ADD_TODO',
  11. text: 'Use Redux'
  12. })
  13. store.dispatch({
  14. type: 'ADD_TODO',
  15. text: 'Implement Undo'
  16. })
  17. store.dispatch({
  18. type: 'UNDO'
  19. })

还有一个重要注意点:你需要记住当你恢复一个 state 时,必须把 .present 追加到当前的 state 上。你也不能忘了通过检查 .past.length.future.length 确定撤销和重做按钮是否可用。

你可能听说过 Redux 受 Elm 架构 影响颇深,所以不必惊讶于这个示例与 elm-undo-redo package 如此相似。

使用 Redux Undo

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

在这个部分中,你会学到如何让 Todo List 拥有可撤销的功能。你可以在 todos-with-undo找到完整的源码。

安装

首先,你必须先执行

  1. npm install --save redux-undo

这一步会安装一个提供可撤销功能的 reducer enhancer 的库。

封装 Reducer

你需要通过 undoable 函数强化你的 reducer。例如,如果之前导出的是 todos reducer,那么现在你需要把这个 reducer 传给 undoable() 然后把计算结果导出:

reducers/todos.js

  1. import undoable from 'redux-undo'
  2. /* ... */
  3. const todos = (state = [], action) => {
  4. /* ... */
  5. }
  6. const undoableTodos = undoable(todos)
  7. export default undoableTodos

这里的 distinctState() 过滤器会忽略那些没有引起 state 变化的 actions,可撤销的 reducer 还可以通过其他选择进行配置,例如为撤销和重做的 action 设置 action type。

这里还有 很多其他配置 来定制 可撤销的 reducer,比如设置 Undo 和 Redo 的 action type。

值得注意的是虽然这与调用 combineReducers() 的结果别无二致,但是现在的 todos reducer 已经是 Redux Undo 增强后的 reducer。

reducers/index.js

  1. import { combineReducers } from 'redux'
  2. import todos from './todos'
  3. import visibilityFilter from './visibilityFilter'
  4. const todoApp = combineReducers({
  5. todos,
  6. visibilityFilter
  7. })
  8. export default todoApp

你可以在 reducer 合并层次中的任何层级对一个或多个 reducer 执行 undoable。我们只对 todos reducer 进行封装而不是整个顶层的 reducer,这样 visibilityFilter 引起的变化才不会影响撤销历史。

更新 Selectors

现在 todos 相关的 state 看起来应该像这样:

  1. {
  2. visibilityFilter: 'SHOW_ALL',
  3. todos: {
  4. past: [
  5. [],
  6. [{ text: 'Use Redux' }],
  7. [{ text: 'Use Redux', complete: true }]
  8. ],
  9. present: [
  10. { text: 'Use Redux', complete: true },
  11. { text: 'Implement Undo' }
  12. ],
  13. future: [
  14. [
  15. { text: 'Use Redux', complete: true },
  16. { text: 'Implement Undo', complete: true }
  17. ]
  18. ]
  19. }
  20. }

这意味着你必须通过 state.todos.present 访问 state 而不是原来的 state.todos

containers/VisibleTodoList.js

  1. const mapStateToProps = state => {
  2. return {
  3. todos: getVisibleTodos(state.todos.present, state.visibilityFilter)
  4. }
  5. }

添加按钮

现在只剩下给撤销和重做的 action 添加按钮。

首先,为这些按钮创建一个名为 UndoRedo 的容器组件。由于展示部分非常简单,我们不再需要把它们分离到单独的文件去:

containers/UndoRedo.js

  1. import React from 'react'
  2. /* ... */
  3. let UndoRedo = ({ canUndo, canRedo, onUndo, onRedo }) => (
  4. <p>
  5. <button onClick={onUndo} disabled={!canUndo}>
  6. Undo
  7. </button>
  8. <button onClick={onRedo} disabled={!canRedo}>
  9. Redo
  10. </button>
  11. </p>
  12. )

你需要使用 React Redux 的 connect 函数生成容器组件,然后检查 state.todos.past.lengthstate.todos.future.length 来判断是否启用撤销和重做按钮。你不再需要给撤销和重做编写 action creators 了,因为 Redux Undo 已经提供了这些 action creators:

containers/UndoRedo.js

  1. /* ... */
  2. import { ActionCreators as UndoActionCreators } from 'redux-undo'
  3. import { connect } from 'react-redux'
  4. /* ... */
  5. const mapStateToProps = state => {
  6. return {
  7. canUndo: state.todos.past.length > 0,
  8. canRedo: state.todos.future.length > 0
  9. }
  10. }
  11. const mapDispatchToProps = dispatch => {
  12. return {
  13. onUndo: () => dispatch(UndoActionCreators.undo()),
  14. onRedo: () => dispatch(UndoActionCreators.redo())
  15. }
  16. }
  17. UndoRedo = connect(mapStateToProps, mapDispatchToProps)(UndoRedo)
  18. export default UndoRedo

现在把这个 UndoRedo 组件添加到 App 组件:

components/App.js

  1. import React from 'react'
  2. import Footer from './Footer'
  3. import AddTodo from '../containers/AddTodo'
  4. import VisibleTodoList from '../containers/VisibleTodoList'
  5. import UndoRedo from '../containers/UndoRedo'
  6. const App = () => (
  7. <div>
  8. <AddTodo />
  9. <VisibleTodoList />
  10. <Footer />
  11. <UndoRedo />
  12. </div>
  13. )
  14. export default App

就是这样!在示例文件夹下执行 npm installnpm start 试试看吧!