实现历史撤销重做
- 完成了 “Redux 基础” 教程
- 理解了 “reducer 组合”
以往,在应用程序中实现撤销和重做功能需要开发人员有意设计。对于经典的 MVC 框架来说,这不是一个容易的问题,因为你需要通过克隆所有相关的模型来跟踪每个过去的状态。此外,你需要注意撤消堆栈,因为用户发起的更改应该是可撤消的。
这意味着在 MVC 应用程序中实现 Undo 和 Redo 通常会迫使你重写应用程序的某些部分,以使用特定的数据变化的模式,如 Command.
然而,对于 Redux,实现撤销历史记录是一件轻而易举的事。原因有三:
- 不存在多个数据模型,只有一个 state 子树需要跟踪。
- state 已经是 immutable 的,mutation 已经被描述为离散的 action,这已经很接近于撤销堆栈的真实堆栈模型。
- Reducer
(state, action) => state
签名使得实现通用的“reducer enhancer”或“高阶 reducer”变得很自然。它们是在保留其签名的同时,使用一些附加功能来增强 reducer 的函数。历史撤销重做就是一个典型场景。
在本秘诀的第一部分,我们将说明实现撤销重做的用到的一些基本概念。
在第二部分中,我们会展示怎么使用 Redux Undo 实现撤销重做,这个包提供了现成的功能。
理解历史撤销重做
State 形状设计
撤销历史记录也是应用 state 的一部分,处理它的时候不能搞特殊。无论 state 的类型随时间怎么变化,当实现 Undo 和 Redo 时,都希望在不同的时间点跟踪此 state 的历史。
例如,计数器应用程序的 state 形状可能如下所示:
{
counter: 10
}
如果想在这样的应用中实现撤销和重做,我们需要存储更多的 state 来解决以下问题:
- 还有什么要撤消或重做的吗?
- 当前 state 是怎样的?
- 撤销重做堆栈中的过去(和未来)状态是什么?
我们可以改变 state 来回答这些问题:
{
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: []
}
或者将历史记录划分为多种粒度,以便用户可以独立撤消和重做其中的 action:
{
counterA: {
past: [1, 0],
present: 2,
future: []
},
counterB: {
past: [0],
present: 1,
future: []
}
}
接下来看我们的方法是怎么选择撤销重做的粒度的
算法设计
无论特定的数据类型如何,撤消历史 state 的形状都是相同的:
{
past: Array<T>,
present: T,
future: Array<T>
}
让我们讨论一下操作上述 state 形状的算法。我们可以定义两个 action 来操作此状态:UNDO
和 REDO
。在我们的 reducer 中,我们将执行以下步骤来处理这些操作:
处理撤销
- 从
past
移除最后一个元素。 - 把上一步移出的元素赋值给
present
。 - 把老的
present
状态插入到future
开头。
处理重做
- 从
future
中移除第一个元素。 - 将前一步移出的那个元素赋值给
present
。 - 把老的
present
状态插入到past
的末尾。
处理其他 action
- 把
present
插入到past
的末尾。 - 将执行 action 后的新 state 赋值给
present
。 - 清空
future
。
第一次尝试:编写 Reducer
const initialState = {
past: [],
present: null, // (?) 怎么初始化当前的状态?
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:
// (?)怎么处理其他 action?
return state
}
}
此实现行不通,因为它忽略了三个重要问题:
- 我们从哪里得到初始的
present
state?我们似乎事先不知道。 - 执行完外部 action 之后,在什么时候在哪里将
present
保存为past
? - 我们如何实际将对
present
state 的控制委托给自定义的 reducer?
看起来 reducer 不是正确的抽象方式,但非常接近。
了解 Reducer enhancer
你可能熟悉高阶函数。如果使用 React,可能也熟悉[高阶组件](https://medium.com/@dan_abramov/mixins-are-dead-long-live-higher-order-components-94a0d2f9e750)。下面是应用于 reducer 的同一模式的变体。
reducer enhancer (或者说高阶 reducer)作为一个函数,接收 reducer 作为参数并返回一个新的 reducer,这个新的 reducer 可以处理新的 action,或者维护更多的 state,亦或者将它无法处理的 action 委托给原始的 reducer 处理。这不是什么新模式,combineReducers()
也是 reducer enhancer,因为它同样接收多个 reducer 并返回一个新的 reducer。
一个不做任何事情的 reducer enhancer 长这样:
function doNothingWith(reducer) {
return function (state, action) {
// 仅仅调用传入的 reducer
return reducer(state, action)
}
}
组合其他 reducer 的 reducer enhancer 可能长这样:
function combineReducers(reducers) {
return function (state = {}, action) {
return Object.keys(reducers).reduce((nextState, key) => {
// 调用每一个 reducer 并将其管理的部分 state 传给它
nextState[key] = reducers[key](state[key], action)
return nextState
}, {})
}
}
第二次尝试: 写一个 Reducer enhancer
现在我们对 reducer enhancer 有了更深的了解,这正是 undoable
的原因:
function undoable(reducer) {
// 使用一个空 action 调用 reducer 以填充初始状态
const initialState = {
past: [],
present: reducer(undefined, {}),
future: []
}
// 返回处理撤消和重做的 reducer
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:
// 代理传给 reducer 的 action
const newPresent = reducer(present, action)
if (present === newPresent) {
return state
}
return {
past: [...past, present],
present: newPresent,
future: []
}
}
}
}
现在,我们可以将任何 reducer 包装到 undoable
reducer enhancer 中,让它对 UNDO
和 REDO
action 做出响应。
// 这是个 reducer
function todos(state = [], action) {
/* ... */
}
// 这也是个 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'
})
有一个重要的问题:记得在检索当前 state 的时候附加上 .present
。你也可能分别检查 .past.length
和 .future.length
来决定启用或禁用撤销重做的按钮。
你可能听说过 Redux 受到 Elm 架构 的影响。这个例子与 elm-undo-redo 包非常相似,这并不奇怪。
使用 Redux Undo
以上都是非常有用的信息,但是有没有一个库能帮助我们实现 undoable
功能,而不是由我们自己编写呢?当然有!去看 Redux Undo,这是一个给你的 Redux 树中任意部分提供撤销重做功能的库。
在这个部分,你将学习如何让一个小的 “todo list” 应用逻辑支持撤销重做。你可以在 Redux 附带的 todos with undo
示例中找到完整源代码.
安装
首先,你要执行:
npm install redux-undo
安装的包将会提供 undoable
reducer enhancer。
封装 Reducer
你需要使用 undoable
函数封装想要增强的 reducer。例如,如果从对应文件中导出了一个 todos
reducer,则需要更改它以导出使用你编写的 reducer 调用 undoable()
的结果:
reducers/todos.js
import undoable from 'redux-undo'
/* ... */
const todos = (state = [], action) => {
/* ... */
}
const undoableTodos = undoable(todos)
export default undoableTodos
也有 很多其他选择 options 用来配置 undoable reducer,比如为撤销或重做的 action 设置特殊的 action type。
注意,combineReducers()
调用将保持原样,但 todos
reducer 现在将引用被 Redux Undo 增强的 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 组合层任意级,在 undoable
中封装一个或多个 reducer。我们选择封装 todos
而不是顶层组合 reducer,这样对于 visibilityFilter
的修改就不会被反应在撤销的历史中。
更新 Selectors
现在 state 中关于 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,而不仅仅是 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
现在你能在 App
组件中添加 UndoRedo
组件了:
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
就是这样!在 example 文件夹 运行 npm install
和 npm start
试试!