基于 Immutable.js 实现撤销重做功能的实例代码
浏览器的功能越来越强大,许多原来由其他客户端提供的功能渐渐转移到了前端,前端应用也越来越复杂。许多前端应用,尤其是一些在线编辑软件,运行时需要不断处理用户的交互,提供了撤消重做功能来保证交互的流畅性。不过为一个应用实现撤销重做功能并不是一件容易的事情。 redux官方文档中 介绍了如何在 redux 应用中实现撤销重做功能。基于 redux 的撤销功能是一个自顶向下的方案:引入 redux-undo
之后所有的操作都变为了「可撤销的」,然后我们不断修改其配置使得撤销功能变得越来越好用(这也是 )。
本文将采用自底向上的思路,以一个简易的在线画图工具为例子,使用typescript 、 immutable.js 实现一个实用的「撤消重做」功能。大致效果如下图所示:
第一步:确定哪些状态需要历史记录,创建自定义的 state 类
并非所有的状态都需要历史记录。许多状态是非常琐碎的,尤其是一些与鼠标或者键盘交互相关的状态,例如在画图工具中拖拽一个图形时我们需要设置一个「正在进行拖拽」的标记,页面会根据该标记显示对应的拖拽提示,显然该拖拽标记不应该出现在历史记录中;而另一些状态无法被撤销或是不需要被撤销,例如网页窗口大小,向后台发送过的请求列表等。
排除那些不需要历史记录的状态,我们将剩下的状态用 immutable record 封装起来,并定义 state 类:
// state.ts import { record, list, set } from 'immutable' const staterecord = record({ items: list<item> transform: d3.zoomtransform selection: number }) // 用类封装,便于书写 typescript,注意这里最好使用immutable 4.0 以上的版本 export default class state extends staterecord {}
这里我们的例子是一个简易的在线画图工具,所以上面的 state 类中包含了三个字段,items 用来记录已经绘制的图形,transform 用来记录画板的平移和缩放状态,selection 则表示目前选中的图形的 id。而画图工具中的其他状态,例如图形绘制预览,自动对齐配置,操作提示文本等,则没有放在 state 类中。
第二步:定义 action 基类,并为每种不同的操作创建对应的 action 子类
与 redux-undo 不同的是,我们仍然采用 命令模式 :定义基类 action,所有对 state 的操作都被封装为一个 action 的实例;定义若干 action 的子类,对应于不同类型的操作。
在 typescript 中,action 基类用 abstract class 来定义比较方便。
// actions/index.ts export default abstract class action { abstract next(state: state): state abstract prev(state: state): state prepare(apphistory: apphistory): apphistory { return apphistory } getmessage() { return this.constructor.name } }
action 对象的 next 方法用来计算「下一个状态」,prev 方法用来计算「上一个状态」。getmessage 方法用来获取 action 对象的简短描述。通过 getmessage 方法,我们可以将用户的操作记录显示在页面上,让用户更方便地了解最近发生了什么。prepare 方法用来在 action 第一次被应用之前,使其「准备好」,apphistory 的定义在本文后面会给出。
action 子类举例
下面的 additemaction 是一个典型的 action 子类,用于表达「添加一个新的图形」。
// actions/additemaction.ts export default class additemaction extends action { newitem: item prevselection: number constructor(newitem: item) { super() this.newitem = newitem } prepare(history: apphistory) { // 创建新的图形后会自动选中该图形,为了使得撤销该操作时 state.selection 变为原来的值 // prepare 方法中读取了「添加图形之前 selection 的值」并保存到 this.prevselection this.prevselection = history.state.selection return history } next(state: state) { return state .setin(['items', this.newitem.id], this.newitem) .set('selection', this.newitemid) } prev(state: state) { return state .deletein(['items', this.newitem.id]) .set('selection', this.prevselection) } getmessage() { return `add item ${this.newitem.id}` } }
运行时行为
应用运行时,用户交互产生一个 action 流,每次产生 action 对象时,我们调用该对象的 next 方法来计算后一个状态,然后将该 action 保存到一个列表中以备后用;用户进行撤销操作时,我们从 action 列表中取出最近一个 action 并调用其 prev 方法。应用运行时,next/prev 方法被调用的情况大致如下:
// initstate 是一开始就给定的应用初始状态 // 某一时刻,用户交互产生了 action1 ... state1 = action1.next(initstate) // 又一个时刻,用户交互产生了 action2 ... state2 = action2.next(state1) // 同样的,action3也出现了 ... state3 = action3.next(state2) // 用户进行撤销,此时我们需要调用最近一个action的prev方法 state4 = action3.prev(state3) // 如果再次进行撤销,我们从action列表中取出对应的action,调用其prev方法 state5 = action2.prev(state4) // 重做的时候,取出最近一个被撤销的action,调用其next方法 state6 = action2.next(state5) applied-action
为了方便后面的说明,我们对 applied-action 进行一个简单的定义:applied-action 是指那些操作结果已经反映在当前应用状态中的 action;当 action 的 next 方法执行时,该 action 变为 applied;当 prev 方法被执行时,该 action 变为 unapplied。
第三步:创建历史记录容器 apphistory
前面的 state 类用于表示某个时刻应用的状态,接下来我们定义 apphistory 类用来表示应用的历史记录。同样的,我们仍然使用 immutable record 来定义历史记录。其中 state 字段用来表达当前的应用状态,list 字段用来存放所有的 action,而 index 字段用来记录最近的 applied-action 的下标。应用的历史状态可以通过 undo/redo 方法计算得到。apply 方法用来向 apphistory 中添加并执行具体的 action。具体代码如下:
// apphistory.ts const emptyaction = symbol('empty-action') export const undo = symbol('undo') export type undo = typeof undo // typescript2.7之后对symbol的支持大大增强 export const redo = symbol('redo') export type redo = typeof redo const apphistoryrecord = record({ // 当前应用状态 state: new state(), // action 列表 list: list<action>(), // index 表示最后一个applied-action在list中的下标。-1 表示没有任何applied-action index: -1, }) export default class apphistory extends apphistoryrecord { pop() { // 移除最后一项操作记录 return this .update('list', list => list.splice(this.index, 1)) .update('index', x => x - 1) } getlastaction() { return this.index === -1 ? emptyaction : this.list.get(this.index) } getnextaction() { return this.list.get(this.index + 1, emptyaction) } apply(action: action) { if (action === emptyaction) return this return this.merge({ list: this.list.setsize(this.index + 1).push(action), index: this.index + 1, state: action.next(this.state), }) } redo() { const action = this.getnextaction() if (action === emptyaction) return this return this.merge({ list: this.list, index: this.index + 1, state: action.next(this.state), }) } undo() { const action = this.getlastaction() if (action === emptyaction) return this return this.merge({ list: this.list, index: this.index - 1, state: action.prev(this.state), }) } }
第四步:添加「撤销重做」功能
假设应用中的其他代码已经将网页上的交互转换为了一系列的 action 对象,那么给应用添上「撤销重做」功能的大致代码如下:
type hybridaction = undo | redo | action // 如果用redux来管理状态,那么使用下面的reudcer来管理那些「需要历史记录的状态」 // 然后将该reducer放在应用状态树中合适的位置 function reducer(history: apphistory, action: hybridaction): apphistory { if (action === undo) { return history.undo() } else if (action === redo) { return history.redo() } else { // 常规的 action // 注意这里需要调用prepare方法,好让该action「准备好」 return action.prepare(history).apply(action) } } // 如果是在 stream/observable 的环境下,那么像下面这样使用 reducer const action$: stream<hybridaction> = generatedfromuserinteraction const apphistory$: stream<apphistory> = action$.fold(reducer, new apphistory()) const state$ = apphistory$.map(h => h.state) // 如果是用回调函数的话,大概像这样使用reducer onactionhappen = function (action: hybridaction) { const nexthistory = reducer(getlasthistory(), action) updateapphistory(nexthistory) updatestate(nexthistory.state) }
第五步:合并 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 一旦进入纪录列表就是不可变的了。
总结
以上就是实现一个实用的撤销重做功能的所有步骤了。不同的前端项目有不同的需求和技术方案,有可能上面的代码在你的项目中一行也用不上;不过撤销重做的思路应该是相同的,希望本文能够给你带来一些启发。
以上所述是小编给大家介绍的基于 immutable.js 实现撤销重做功能的实例代码,希望对大家有所帮助