基于 Immutable.js 实现撤销重做功能的实例代码(4)
第五步:合并 Action,完善用户交互体验
通过上面这四个步骤,画图工具拥有了撤消重做功能,但是该功能用户体验并不好。在画图工具中拖动一个图形时,MoveItemAction 的产生频率和 mousemove 事件的发生频率相同,如果我们不对该情况进行处理,MoveItemAction 马上会污染整个历史记录。我们需要合并那些频率过高的 action,使得每个被记录下来的 action 有合理的撤销粒度。
每个 Action 在被应用之前,其 prepare 方法都会被调用,我们可以在 prepare 方法中对历史记录进行修改。例如,对于 MoveItemAction,我们判断上一个 action 是否和当前 action 属于同一次移动操作,然后来决定在应用当前 action 之前是否移除上一个 action。代码如下:
// actions/MoveItemAction.ts export default class MoveItemAction extends Action { prevItem: Item // 一次图形拖动操作可以由以下三个变量来进行描述: // 拖动开始时鼠标的位置(startPos),拖动过程中鼠标的位置(movingPos),以及拖动的图形的 ID constructor(readonly startPos: Point, readonly movingPos: Point, readonly itemId: number) { // 上一行中 readonly startPos: Point 相当于下面两步: // 1. 在MoveItemAction中定义startPos只读字段 // 2. 在构造函数中执行 this.startPos = startPos super() } prepare(history: AppHistory) { const lastAction = history.getLastAction() if (lastAction instanceof MoveItemAction && lastAction.startPos == this.startPos) { // 如果上一个action也是MoveItemAction,且拖动操作的鼠标起点和当前action相同 // 则我们认为这两个action在同一次移动操作中 this.prevItem = lastAction.prevItem return history.pop() // 调用pop方法来移除最近一个action } else { // 记录图形被移动之前的状态,用于撤销 this.prevItem = history.state.items.get(this.itemId) return history } } next(state: State): State { const dx = this.movingPos.x - this.startPos.x const dy = this.movingPos.y - this.startPos.y const moved = this.prevItem.move(dx, dy) return state.setIn(['items', this.itemId], moved) } prev(state: State) { // 撤销的时候我们直接使用已经保存的prevItem即可 return state.setIn(['items', this.itemId], this.prevItem) } getMessage() { /* ... */ } }
从上面的代码中可以看到,prepare 方法除了使 action 自身准备好之外,它还可以让历史记录准备好。不同的 Action 类型有不同的合并规则,为每种 Action 实现合理的 prepare 函数之后,撤消重做功能的用户体验能够大大提升。
一些其他需要注意的地方
撤销重做功能是非常依赖于不可变性的,一个 Action 对象在放入 AppHistory.list 之后,其所引用的对象都应该是不可变的。如果 action 所引用的对象发生了变化,那么在后续撤销时可能发生错误。本方案中,为了方便记录操作发生时的一些必要信息,Action 对象的 prepare 方法中允许出现原地修改操作,但是 prepare 方法只会在 action 被放入历史记录之前调用一次,action 一旦进入纪录列表就是不可变的了。