实现撤消历史记录
¥Implementing Undo History
¥Completion of the "Redux Fundamentals" tutorial
对 "reducer 组合物" 的理解
¥Understanding of "reducer composition"
传统上,将撤消和重做功能构建到应用中需要开发者有意识的努力。对于经典的 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.
了解撤消历史记录
¥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>
}
让我们讨论一下操纵上述状态形状的算法。我们可以定义两个操作来操作此状态:UNDO
和 REDO
。在我们的 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 thefuture
.
处理重做
¥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 thepast
.
处理其他动作
¥Handling Other Actions
将
present
插入past
的末端。¥Insert the
present
at the end of thepast
.处理操作后将
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 thepast
?我们实际上如何将对
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 增强器中,以教它对 UNDO
和 REDO
操作做出反应。
¥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.length
和 state.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 install
和 npm start
并尝试一下!
¥This is it! Run npm install
and npm start
in the example folder and try it out!