Skip to main content

实现撤消历史记录

¥Implementing Undo History

先决条件

传统上,将撤消和重做功能构建到应用中需要开发者有意识的努力。对于经典的 MVC 框架来说,这不是一个简单的问题,因为你需要通过克隆所有相关模型来跟踪每个过去的状态。此外,你需要注意撤消堆栈,因为用户启动的更改应该是可撤消的。

¥Building an Undo and Redo functionality into an app has traditionally required conscious effort from the developer. It is not an easy problem with classical MVC frameworks because you need to keep track of every past state by cloning all relevant models. In addition, you need to be mindful of the undo stack because the user-initiated changes should be undoable.

这意味着在 MVC 应用中实现撤消和重做通常会迫使你重写应用的某些部分以使用特定的数据突变模式(如 命令)。

¥This means that implementing Undo and Redo in an MVC application usually forces you to rewrite parts of your application to use a specific data mutation pattern like Command.

然而,使用 Redux,实现撤消历史记录变得轻而易举。原因有以下三个:

¥With Redux, however, implementing undo history is a breeze. There are three reasons for this:

  • 没有多个模型 - 只有一个你想要跟踪的状态子树。

    ¥There are no multiple models—just a state subtree that you want to keep track of.

  • 状态已经是不可变的,并且突变已经被描述为离散动作,这接近于撤消堆栈心理模型。

    ¥The state is already immutable, and mutations are already described as discrete actions, which is close to the undo stack mental model.

  • reducer (state, action) => state 签名使得实现通用的“reducer 增强器”或“高阶 reducer”变得很自然。它们是使用你的 reducer 并通过一些附加功能对其进行增强,同时保留其签名的函数。撤消历史记录正是这样的情况。

    ¥The reducer (state, action) => state signature makes it natural to implement generic “reducer enhancers” or “higher order reducers”. They are functions that take your reducer and enhance it with some additional functionality while preserving its signature. Undo history is exactly such a case.

在本教程的第一部分中,我们将解释使撤消和重做能够以通用方式实现的基本概念。

¥In the first part of this recipe, we will explain the underlying concepts that make Undo and Redo possible to implement in a generic way.

在本教程的第二部分中,我们将展示如何使用提供开箱即用功能的 还原撤消 包。

¥In the second part of this recipe, we will show how to use Redux Undo package that provides this functionality out of the box.

demo of todos-with-undo

了解撤消历史记录

¥Understanding Undo History

设计状态形状

¥Designing the State Shape

撤消历史记录也是应用状态的一部分,我们没有理由以不同的方式处理它。无论随时间变化的状态类型如何,当你实现撤消和重做时,你都希望跟踪该状态在不同时间点的历史记录。

¥Undo history is also part of your app's state, and there is no reason why we should approach it differently. Regardless of the type of the state changing over time, when you implement Undo and Redo, you want to keep track of the history of this state at different points in time.

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

¥For example, the state shape of a counter app might look like this:

{
counter: 10
}

如果我们想在这样的应用中实现撤消和重做,我们需要存储更多状态,以便我们可以回答以下问题:

¥If we wanted to implement Undo and Redo in such an app, we'd need to store more state so we can answer the following questions:

  • 还有什么需要撤消或重做吗?

    ¥Is there anything left to undo or redo?

  • 目前的状况如何?

    ¥What is the current state?

  • 撤消堆栈中过去(和未来)的状态是什么?

    ¥What are the past (and future) states in the undo stack?

有理由建议我们的状态形态应该改变来回答这些问题:

¥It is reasonable to suggest that our state shape should change to answer these questions:

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

现在,如果用户按下“撤消”,我们希望它变为过去:

¥Now, if user presses “Undo”, we want it to change to move into the past:

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

更进一步:

¥And further yet:

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

当用户按下“重做”时,我们想要向后退一步:

¥When the user presses “Redo”, we want to move one step back into the future:

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

最后,如果用户执行一个操作(例如递减计数器),而 we'在撤消堆栈的中间,我们' 将丢弃现有的 future:

¥Finally, if the user performs an action (e.g. decrement the counter) while we're in the middle of the undo stack, we're going to discard the existing future:

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

这里有趣的部分是,我们是否想要保留数字、字符串、数组或对象的撤消堆栈并不重要。结构总是相同的:

¥The interesting part here is that it does not matter whether we want to keep an undo stack of numbers, strings, arrays, or objects. The structure will always be the same:

{
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 }
]
]
}
}

一般来说,它看起来像这样:

¥In general, it looks like this:

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

是否保留单一的顶层历史也取决于我们:

¥It is also up to us whether to keep a single top-level history:

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

或者许多细粒度的历史记录,以便用户可以独立地撤消和重做其中的操作:

¥Or many granular histories so user can undo and redo actions in them independently:

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

稍后我们将看到我们采用的方法如何让我们选择撤消和重做的粒度。

¥We will see later how the approach we take lets us choose how granular Undo and Redo need to be.

设计算法

¥Designing the Algorithm

无论具体数据类型如何,撤消历史状态的形状都是相同的:

¥Regardless of the specific data type, the shape of the undo history state is the same:

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

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

¥Let's talk through the algorithm to manipulate the state shape described above. We can define two actions to operate on this state: UNDO and REDO. In our reducer, we will do the following steps to handle these actions:

处理撤消

¥Handling Undo

  • 删除 past 中的最后一个元素。

    ¥Remove the last element from the past.

  • present 设置为我们在上一步中删除的元素。

    ¥Set the present to the element we removed in the previous step.

  • future 的开头插入旧的 present 状态。

    ¥Insert the old present state at the beginning of the future.

处理重做

¥Handling Redo

  • future 中删除第一个元素。

    ¥Remove the first element from the future.

  • present 设置为我们在上一步中删除的元素。

    ¥Set the present to the element we removed in the previous step.

  • 将旧的 present 状态插入到 past 的末尾。

    ¥Insert the old present state at the end of the past.

处理其他动作

¥Handling Other Actions

  • present 插入 past 的末端。

    ¥Insert the present at the end of the past.

  • 处理操作后将 present 设置为新状态。

    ¥Set the present to the new state after handling the action.

  • 清除 future

    ¥Clear the future.

第一次尝试:编写一个 Reducer

¥First Attempt: Writing a 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
}
}

该实现不可用,因为它遗漏了三个重要问题:

¥This implementation isn't usable because it leaves out three important questions:

  • 我们从哪里获得初始 present 状态?我们似乎事先并不知道。

    ¥Where do we get the initial present state from? We don't seem to know it beforehand.

  • 我们在哪里对外部操作做出反应以将 present 保存到 past

    ¥Where do we react to the external actions to save the present to the past?

  • 我们实际上如何将对 present 状态的控制委托给自定义 reducer?

    ¥How do we actually delegate the control over the present state to a custom reducer?

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

¥It seems that reducer isn't the right abstraction, but we're very close.

认识 reducer 增强器

¥Meet Reducer Enhancers

你可能熟悉 高阶函数。如果你使用 React,你可能会熟悉 高阶分量。这是应用于 reducer 的同一模式的变体。

¥You might be familiar with higher order functions. If you use React, you might be familiar with higher order components. Here is a variation on the same pattern, applied to reducers.

reducer 增强器(或高阶 reducer)是一个函数,它接受一个 reducer,并返回一个新的 reducer,该 reducer 能够处理新操作,或保存更多状态,将控制权委托给内部 reducer 以处理它不理解的操作。这不是一个新模式 - 从技术上讲,combineReducers() 也是一个 reducer 增强器,因为它接受 reducer 并返回一个新的 reducer。

¥A reducer enhancer (or a higher order reducer) is a function that takes a reducer, and returns a new reducer that is able to handle new actions, or to hold more state, delegating control to the inner reducer for the actions it doesn't understand. This isn't a new pattern—technically, combineReducers() is also a reducer enhancer because it takes reducers and returns a new reducer.

不执行任何操作的 reducer 增强器如下所示:

¥A reducer enhancer that doesn't do anything looks like this:

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

结合其他 reducer 的 reducer 增强器可能如下所示:

¥A reducer enhancer that combines other reducers might look like this:

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 增强器

¥Second Attempt: Writing a Reducer Enhancer

现在我们对 reducer 增强器有了更好的了解,我们可以看到这正是 undoable 应该有的样子:

¥Now that we have a better understanding of reducer enhancers, we can see that this is exactly what undoable should have been:

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: []
}
}
}
}

我们现在可以将任何 reducer 封装到 undoable reducer 增强器中,以教它对 UNDOREDO 操作做出反应。

¥We can now wrap any reducer into undoable reducer enhancer to teach it to react to UNDO and REDO actions.

// 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 以确定是否分别启用或禁用“撤消”和“重做”按钮。

¥There is an important gotcha: you need to remember to append .present to the current state when you retrieve it. You may also check .past.length and .future.length to determine whether to enable or to disable the Undo and Redo buttons, respectively.

你可能听说过 Redux 受到 榆树架构 的影响。该示例与 elm-undo-redo 包 非常相似,这一点不足为奇。

¥You might have heard that Redux was influenced by Elm Architecture. It shouldn't come as a surprise that this example is very similar to elm-undo-redo package.

使用 Redux 撤消

¥Using Redux Undo

这一切都非常有用,但是我们不能直接删除一个库并使用它而不是自己实现 undoable 吗?我们当然可以!来认识一下 还原撤消,这是一个为 Redux 树的任何部分提供简单的撤消和重做功能的库。

¥This was all very informative, but can't we just drop a library and use it instead of implementing undoable ourselves? Sure, we can! Meet Redux Undo, a library that provides simple Undo and Redo functionality for any part of your Redux tree.

在本教程的这一部分中,你将学习如何使小型 "待办事项清单" 应用逻辑可撤消。你可以在 Redux 自带的 todos-with-undo 示例.txt 中找到此秘诀的完整源代码。

¥In this part of the recipe, you will learn how to make a small "todo list" app logic undoable. You can find the full source of this recipe in the todos-with-undo example that comes with Redux.

安装

¥Installation

首先,你需要运行

¥First of all, you need to run

npm install redux-undo

这将安装提供 undoable reducer 增强器的软件包。

¥This installs the package that provides the undoable reducer enhancer.

封装 Reducer

¥Wrapping the Reducer

你需要使用 undoable 功能封装你想要增强的 reducer。例如,如果你从专用文件导出了 todos reducer,你将需要更改它以导出使用你编写的 reducer 调用 undoable() 的结果:

¥You will need to wrap the reducer you wish to enhance with undoable function. For example, if you exported a todos reducer from a dedicated file, you will want to change it to export the result of calling undoable() with the reducer you wrote:

reducers/todos.js

import undoable from 'redux-undo'

/* ... */

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

const undoableTodos = undoable(todos)

export default undoableTodos

许多其他选择 来配置你的可撤消 reducer,例如设置撤消和重做操作的操作类型。

¥There are many other options to configure your undoable reducer, like setting the action type for Undo and Redo actions.

请注意,你的 combineReducers() 调用将保持原样,但 todos reducer 现在将引用通过 Redux Undo 增强的 reducer:

¥Note that your combineReducers() call will stay exactly as it was, but the todos reducer will now refer to the reducer enhanced with Redux Undo:

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 的更改不会反映在撤消历史记录中。

¥You may wrap one or more reducers in undoable at any level of the reducer composition hierarchy. We choose to wrap todos instead of the top-level combined reducer so that changes to visibilityFilter are not reflected in the undo history.

更新选择器

¥Updating the Selectors

现在状态的 todos 部分如下所示:

¥Now the todos part of the state looks like this:

{
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 来访问你的状态:

¥This means you need to access your state with state.todos.present instead of just state.todos:

containers/VisibleTodoList.js

const mapStateToProps = state => {
return {
todos: getVisibleTodos(state.todos.present, state.visibilityFilter)
}
}

添加按钮

¥Adding the Buttons

现在你需要做的就是添加撤消和重做操作的按钮。

¥Now all you need to do is add the buttons for the Undo and Redo actions.

首先,为这些按钮创建一个名为 UndoRedo 的新容器组件。我们不会费心将演示部分拆分为单独的文件,因为它非常小:

¥First, create a new container component called UndoRedo for these buttons. We won't bother to split the presentational part into a separate file because it is very small:

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

你将使用 反应还原 中的 connect() 来生成容器组件。要确定是否启用“撤消”和“重做”按钮,可以检查 state.todos.past.lengthstate.todos.future.length。你不需要编写操作创建器来执行撤消和重做,因为 Redux Undo 已经提供了它们:

¥You will use connect() from React Redux to generate a container component. To determine whether to enable Undo and Redo buttons, you can check state.todos.past.length and state.todos.future.length. You won't need to write action creators for performing undo and redo because Redux Undo already provides them:

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 组件中:

¥Now you can add UndoRedo component to the App component:

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 并尝试一下!

¥This is it! Run npm install and npm start in the example folder and try it out!