本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →
实现撤销历史功能
- 已完成 "Redux 基础教程"
- 理解 "reducer 组合"
在应用中构建撤销(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>
}
现在探讨操作上述状态结构的算法。我们可以定义两个操作此状态的动作:UNDO 和 REDO。在 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,使其能响应 UNDO 和 REDO 动作
// 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 Redux 的 connect() 生成容器组件。通过检查 state.todos.past.length 和 state.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 install 和 npm start 即可体验效果!
