• 实现撤销历史
    • 理解撤销历史
      • 设计状态结构
      • 设计算法
        • 处理 Undo
        • 处理 Redo
        • 处理其他 Action
      • 第一次尝试: 编写 Reducer
      • 初识 Reducer Enhancers
      • 第二次尝试: 编写 Reducer Enhancer
    • 使用 Redux Undo
      • 安装
      • 封装 Reducer
        • reducers/todos.js
        • reducers/index.js
      • 更新 Selectors
        • containers/VisibleTodoList.js
      • 添加按钮
        • containers/UndoRedo.js
        • containers/UndoRedo.js
        • components/App.js

    实现撤销历史

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

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

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

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

    在动手之前,确认你已经阅读过基础教程并且良好掌握了 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,也应该熟悉 @dan_abramov/mixins-are-dead-long-live-higher-order-components-94a0d2f9e750">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, { distinctState } from 'redux-undo'
    2. /* ... */
    3. const todos = (state = [], action) => {
    4. /* ... */
    5. }
    6. const undoableTodos = undoable(todos, {
    7. filter: distinctState()
    8. })
    9. export default undoableTodos

    这里的 distinctState() 过滤器会忽略那些没有引起 state 变化的 actions,可撤销的 reducer 还可以通过其他选择进行配置,例如为撤销和重做的 action 设置 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: [ { text: 'Use Redux', complete: true }, { text: 'Implement Undo' } ],
    10. future: [
    11. [ { text: 'Use Redux', complete: true }, { text: 'Implement Undo', complete: true } ]
    12. ]
    13. }
    14. }

    这意味着你必须通过 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(
    18. mapStateToProps,
    19. mapDispatchToProps
    20. )(UndoRedo)
    21. 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 试试看吧!