问题
像React这种JavaScript框架,它的一大特色是Web应用程序可以与桌面应用程序非常相似。而桌面应用程序中的一个常见特性则是能够撤销动作(action)。React应用程序中的一些本地组件自带撤销功能。如果你在文本区域编辑一些文本,然后按Cmd/Ctrl-Z,你的文本编辑操作也可以被撤销。但是如何将撤销功能扩展到自定义组件中呢?怎样在不需要编写大量代码的情况下跟踪状态的变更?
解决方案
如果用reducer函数管理组件中的状态,那么你可以使用undo-reducer来实现常见的撤销功能。
下面这段代码来自3.1节的拼图(Puzzle)示例:
这段代码使用一个reducer函数(命名为reducer)和一个初始状态来管理数字拼图游戏中的方块(如图3-6所示)。
如果用户单击SHUFFLE按钮,组件会通过给reducer发送一个shuffle动作来更新拼图状态:
(关于什么是reducer以及何时应该使用它们的更多细节,请参见3.1节。)
我们将创建一个名为useUndoReducer的新Hook,替换原先的useReducer:
图3-6:一个简单的数字拼图游戏
useUndoReducer Hook会神奇地赋予组件回到过去的能力:
如果我们将这个按钮添加到组件中,它将取消用户执行的最后一步动作,如图3-7所示。
图3-7:(1)游戏进行中;(2)移动卡片;(3)单击“UNDO”以撤销移动
但我们要怎么实现这个魔术般的功能呢?虽然useUndoReducer用起来相对容易,但理解起来也有点困难。然而,还是很有必要深入理解useUndoReducer,因为这样你就可以根据自己的需求调整代码。
所有的reducer都用相同的方式工作,这给我们带来了很大的便利,具体工作方式如下:
·action定义了你想做什么。
·reducer在每次执行action后返回一个新的状态。
·调用reducer时,不允许有任何副作用。
所以你可以了解,reducer只是接受state(状态)对象和action(动作)对象的简单JavaScript函数。
得益于reducer的设计,我们可以创建一个新的reducer(undo-reducer)来封装另一个reducer函数。我们的undo-reducer将充当中间媒介。它将把大多数action传递给底层的reducer,同时保留所有之前状态的历史记录。如果有人想撤销一个action,它将从其历史记录中找到并返回最后的状态,而不调用底层的reducer。
我们将从创建一个高阶函数开始,它接受一个reducer并返回一个新的reducer:
这个reducer是一个相当复杂的函数,所以我们有必要花些时间来了解它的功能。
它创建了一个reducer函数,用于跟踪我们传递给它的action(动作)和state(状态)。假设我们的游戏组件发送一个action来打乱方块。该reducer将首先检查此处的action是否是undo或者redo类型。答案为否(该action的类型是shuffle)。所以该reducer将其传递给管理游戏的底层reducer(如图3-8所示)。
图3-8:undo-reducer将大多数动作传递给底层的reducer
当shuffle动作被传递给底层的reducer时,undo函数代码通过将现有状态和shuffle动作添加到undoHistory和undoActions来跟踪它们。然后返回游戏底层reducer的状态,以及undoHistory和undoActions。
如果我们的游戏组件发送了一个undo动作,undo-reducer就会从undoHistory返回之前的状态,完全绕过游戏底层的reducer函数(如图3-9所示)。
图3-9:对于undo动作,undo-reducer返回最近的历史状态
现在让我们看看useUndoReducer Hook的代码:
useUndoReducer Hook的代码很简洁,它只是对React内置的useReducer Hook的调用。useUndoReducer并没有直接将reducer传递给useReducer,而是传递undo(reducer)。这样,你的组件使用的reducer就获得了撤销和重做功能。
以下是我们更新后的拼图游戏组件的代码(参见3.1节的原始版本):
唯一的变化是我们使用了useUndoReducer而不是useReducer,并且还添加了几个按钮来调用“undo”和“redo”动作。
如果现在加载组件并进行一些操作,你会发现这些操作是可以撤销的,如图3-10所示。
讨论
这里显示的undo-reducer将与接受和返回状态对象的reducer一起使用。如果reducer使用数组管理状态,则必须修改undo函数。
因为它保存了以前所有状态的历史记录,所以如果状态数据很多,或者需要做大量更改的话,不建议使用它。否则,你可能需要限制撤销步数的大小。
图3-10:使用useUndoReducer,你现在可以发送undo和redo动作
另外,请记住,撤销记录是保存在内存中的。如果用户重新加载整个页面,那么撤销记录就会丢失。这个问题可以通过在全局状态发生变化时将其转存到本地存储中来解决。
你可以从GitHub网站( https://oreil.ly/Oz27A )下载本解决方案的源代码。