跳至主内容
非官方测试版翻译

本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →

实现撤销历史功能

前置要求

在应用中构建撤销(Undo)和重做(Redo)功能历来需要开发者付出大量精力。在传统的 MVC 框架中,这是个复杂问题,因为你需要通过克隆所有相关模型来追踪每个历史状态。此外,你还需要谨慎管理撤销栈,确保用户发起的变更都是可撤销的。

这意味着在 MVC 应用中实现撤销/重做通常需要你重写部分代码,以采用特定的数据变更模式,例如命令模式

然而在 Redux 中,实现撤销历史轻而易举。这归功于三点:

  • 不存在多个模型——只需追踪单个状态子树

  • 状态本身已不可变,变更通过离散 action 描述,这与撤销栈的心智模型高度契合

  • reducer 的 (state, action) => state 签名天然支持实现通用的"reducer 增强器"或"高阶 reducer"。这些函数接收原始 reducer 并添加额外功能,同时保持其签名不变。撤销历史正是这种模式的典型应用场景。

本教程第一部分将阐述实现通用撤销/重能功能的底层原理。

第二部分将演示如何使用开箱即用的 Redux Undo 库。

带撤销功能的待办事项演示

理解撤销历史

设计状态结构

撤销历史本质上是应用状态的一部分,我们应采用统一方式处理。无论状态随时间如何变化,实现撤销/重做时都需要追踪该状态在不同时间点的历史记录。

例如,计数器应用的状态结构可能如下:

{
counter: 10
}

若需在此应用中实现撤销/重做,我们需要扩展状态以回答下列问题:

  • 是否存在可撤销或重做的操作?

  • 当前状态是什么?

  • 撤销栈中过去(及未来)的状态有哪些?

合理的做法是调整状态结构以解决这些问题:

{
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。对于不理解的 action,它会委托内部 reducer 处理。这不是新模式——严格来说,combineReducers() 也是 reducer 增强器,因为它接收多个 reducer 并返回新 reducer

无实际功能的 reducer 增强器示例如下:

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

组合其他 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: []
}
}
}
}

现在我们可以用 undoable reducer 增强器包装任何 reducer,使其能响应 UNDOREDO 动作

// 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

你需要用 undoable 函数包装目标 reducer。例如,如果你从独立文件导出了 todos reducer,应将其改为导出调用 undoable() 后的结果:

reducers/todos.js

import undoable from 'redux-undo'

/* ... */

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

const undoableTodos = undoable(todos)

export default undoableTodos

可通过多种配置选项定制撤销功能,例如设置撤销/重做操作的 action 类型。

注意 combineReducers() 调用保持不变,但 todos 现在指向的是增强后的 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 来决定按钮的禁用状态。无需自行编写撤销/重做的 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

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 即可体验效果!