浏览器的功能越来越强大。很多原本由其他客户端提供的功能逐渐转移到前端,前端应用越来越复杂。很多前端应用,尤其是一些在线编辑软件,在运行时需要不断处理用户交互,并提供撤销和重做功能,保证交互的流畅性。然而,实现应用程序的撤销和重做功能并不是一件容易的事情。redux官方文档描述了如何在Redux应用程序中实现撤销和重做功能。基于Redux的撤销功能是一种自上而下的方案:引入redux-undo后,所有操作都变成了“可撤销”,然后我们不断修改其配置,让撤销功能越来越有用(这也是redux-undo有这么多配置项的原因)。
本文以一个简单的在线绘图工具为例,利用TypeScript和不可变. js实现了一个实用的“撤销和重做”功能,一般效果如下图所示:
步骤1:确定哪些州需要历史记录,并创建一个自定义的State类
不是所有的州都需要历史。许多状态都非常琐碎,尤其是那些与鼠标或键盘交互相关的状态。比如在绘图工具中拖动图形时,我们需要设置一个“拖动”标记,页面会根据标记显示相应的拖动提示。显然,拖痕不应该出现在历史中;其他状态不能撤销或者不需要撤销,比如网页窗口的大小,发送到后台的请求列表等等。
排除那些不需要历史记录的状态,我们用不可变记录封装剩余的状态,并定义状态类:
//state . t从“不可变”常量state Record=Record({ items : List item transform 3360d 3)导入{ Record,List,Set}。zoom transform selection : number })//由类封装。TypeScript很容易写。注意,最好使用4.0以上的不可变版本,导出默认类State扩展staterecord {}。我们这里的例子是一个简单的在线绘图工具,所以上面的state类包含三个字段。Items用于记录绘制的图形,transform用于记录画板的平移和缩放状态,selection表示当前选中图形的ID。绘图工具中的其他状态,如绘图预览、自动对齐配置、操作提示文本等。不在国家类别中。
步骤2:定义动作基类,并为每个不同的操作创建相应的动作子类
不同于redux-undo,我们还是采用命令模式:定义基类Action,State上的所有操作都封装为Action的一个实例;定义动作的几个子类,对应不同类型的操作。
在TypeScript中,通过抽象类定义动作基类是很方便的。
//actions/index . t export默认抽象类Action {抽象next(State : State): State抽象prev(State : State): State的下一个方法准备(app history : app history): app history { return app history } getmessage(){ return this。constructor.name}} action对象用于计算“下一个状态”,prev方法用于计算“上一个状态”。getMessage方法用于获取操作对象的简短描述。使用getMessage方法,我们可以在页面上显示用户的操作记录,这让用户更容易知道最近发生了什么。准备方法用于在第一次应用之前“准备”动作。AppHistory的定义将在本文后面给出。
动作子类示例
下面的AddItemAction是Action的一个典型子类,用来表示“新增一个人物”。
//actions/AddItemAction . t export默认类additemcaction扩展了Action { new Item : Item prev selection : number构造函数(newItem: Item) {super () this。new item=new item } prepare(history : app history){//新建图形后,会自动选中。以便在操作取消时使state.selection成为初始值//。在准备方法中,“添加图形前选择的值”被读取并保存到此。预先选择这个。prevselection=历史记录。国家。选择返回历史记录}下一个(状态:状态){返回状态。setIn(['items ',this.newItem.id],this.newItem)。设置('选择',这。newitemid)} prev(state : state){ return state。删除(['items ',this。newitem.id])。设置('选择',这个。prev selection)} getmessage(){ return ` add item $ { this。newitem.id} `}
当应用程序运行时,用户交互会生成一个Action流。每次生成一个动作对象,我们就调用该对象的next方法计算下一个状态,然后将动作保存在一个列表中以备后用;当用户取消时,我们从动作列表中取出最新的动作,并调用其prev方法。当应用程序运行时,调用next/prev方法如下:
//initState是开始时给定的初始应用程序状态。//在某个时刻,用户交互产生了action1.状态1=操作1。next(initstate)//在另一个时刻,用户交互产生了action2.状态2=操作2。next(state 1)//同样,Action3也会出现.state3=action3.next(state2)//当用户取消时,我们需要调用prev方法state4=action3.prev(state3)//如果我们再次取消,我们将从动作列表中取出相应的动作。当调用其prev方法state5=action2.prev(state4)//进行重做时,取出最近撤销的操作并调用其下一个方法state6=action2。下一步(状态5)已应用-操作。为了方便下面的描述,我们对applied-action有一个简单的定义:Applied-Action是指那些操作结果已经反映在当前应用状态中的动作;该动作被应用;当动作的下一个方法被执行时;当执行prev方法时,该操作变为未应用。
步骤3:创建一个历史容器应用历史
前一个State类用来表示某个时间的应用程序状态,然后我们定义AppHistory类来表示应用程序历史。同样,我们仍然使用不可变记录来定义历史记录。状态字段用于表示当前应用程序状态,列表字段用于存储所有操作,索引字段用于记录最近应用的操作的下标。应用历史可以通过撤销/重做方法计算。apply方法用于向AppHistory添加和执行特定的操作。具体代码如下:
//apphistory。tsconst空操作=符号(“空操作”)导出常量撤销=符号(“撤销”)导出类型撤销=撤销的类型//TypeScript2.7之后对标志的支持大大增强导出常量重做=符号('重做)导出类型重做=类型重做常量应用历史记录=记录({ //当前应用状态状态:新状态(),//操作列表list: ListAction(),//索引表示最后一个应用动作在目录中的下标。-1 表示没有任何applied-action index: -1,})导出默认类AppHistory扩展了AppHistoryRecord { pop() { //移除最后一项操作记录归还这个更新(' list ',list=list.splice(this.index,1)).update('index ',x=x-1)} GetLastAction(){返回此。index====-1?清空动作:名单。得到(这个。index)} getnext Action(){返回此。名单。得到(这个。索引1,空Action)}应用(Action : Action){ if(Action===空Action)返回这个。合并({ list : this。名单。setsize(这个。索引1 ).push(action),index: this.index 1,state : action。下一个(这个。state)、})} redo(){ const action=this。getnext action()if(action===空操作)返回this.merge({ list: this.list,index: this.index 1,state : action。下一个(这个。state)、})} undo(){ const action=this。getlast action()if(action===空操作)返回这第四步:添加「撤销重做」功能
假设应用中的其他代码已经将网页上的交互转换为了一系列的行动对象,那么给应用添上「撤销重做」功能的大致代码如下:
类型杂交操作=撤消|重做|操作/如果用Redux来管理状态,那么使用下面的reudcer来管理那些「需要历史记录的状态」//然后将该还原剂放在应用状态树中合适的位置函数reduce(history : AppHistory,action : hybrid action): AppHistory { if(action===undo){ return history。undo()} else if(action===重做){ return history。redo()} else {//常规的Action //注意这里需要调用准备方法,好让该行动[准备好返回行动。准备(历史)。apply(action) }}//如果是在流/可观察的环境下,那么像下面这样使用reduce rconst action $ : StreamHybridAction=generated rousseinteractionconst appHistory $ : StreamAppHistory=action $ .fold(reduce,new AppHistory())const state $=AppHistory $ .map(h=h.state)//如果是用回调函数的话,大概像这样使用reduce ronactionoceap=function(action :混合操作){ const next history=reduce(getlastshistory(),action)updateAppHistory(next history)updateState(next history。状态)}第五步:合并行动,完善用户交互体验
通过上面这四个步骤,画图工具拥有了撤消重做功能,但是该功能用户体验并不好。在画图工具中拖动一个图形时,MoveItemAction的产生频率和鼠标移动事件的发生频率相同,如果我们不对该情况进行处理,MoveItemAction马上会污染整个历史记录。我们需要合并那些频率过高的行动,使得每个被记录下来的行为有合理的撤销粒度。
每个行动在被应用之前,其准备方法都会被调用,我们可以在准备方法中对历史记录进行修改。例如,对于MoveItemAction,我们判断上一个行为是否和当前行为属于同一次移动操作,然后来决定在应用当前行为之前是否移除上一个行动。代码如下:
//actions/move item action . t export默认类moveitem actions扩展了action { previous item : item//一个图形拖动操作可以用以下三个变量来描述://拖动开始时的鼠标位置(startPos)、拖动过程中的鼠标位置(movingPos)以及被拖动图形的id构造函数(readonly startpos :点、Readonly move pos :点、Readonly itemId:号){//上一行中的readonly startpos :点在moveitem//2中定义startPos只读字段。执行这个。startpos=startpossuper ()}准备(history : app history){ const last action=history。构造函数If(move action last action的最后一个操作实例)中的getlastaction()。startpos==这个。startpos){//如果最后一个动作也是moveaction,并且鼠标拖动操作的起点与当前动作//相同,那么我们认为这两个动作在同一个move操作中。这个。previtem=lastaction。previtemreturn history . pop()//调用pop方法移除最后一个动作} else {//记录图形移动前的状态,用于撤销此操作。previtem=历史。state.items.get (this。itemid)返回历史记录} } next(State : State): State { const dx=this . moving pos . x-this . start pos . x const dy=this . moving pos . y-this . start pos . y const moved=this . previitem . move(dx,Dy)返回状态. setIn(['items ',this。itemid],moved)} prev(状态:状态){//撤销时,我们可以直接使用保存的prevItem返回状态。setin (['items ',This。itemid],这个。previtem)} getmessage () {/*.*/}}从上面的代码可以看出,prepare方法除了动作本身,还可以准备历史。不同的操作类型有不同的合并规则。在为每个动作实现合理的准备功能后,撤销重做功能的用户体验可以得到极大的提升。
其他一些需要注意的事情
撤销重做功能非常依赖于不变性。在一个Action对象被放入AppHistory.list之后,它所引用的对象应该是不可变的。如果操作引用的对象发生更改,则在后续撤消过程中可能会出现错误。在该方案中,为了在操作发生时记录一些必要的信息,在Action对象的准备方法中允许进行原位修改操作,但是在将操作放入历史记录之前,准备方法只会被调用一次,一旦操作进入记录列表,则是不可变的。
摘要
这些都是实现实用的撤销和重做功能的步骤。不同的前端项目有不同的需求和技术解决方案,有可能上面的代码不能用在你项目的一行;不过撤销重做的想法应该是一样的,希望这篇文章能给大家一些启发。
以上是边肖介绍的基于不可变. js的撤销和重做函数的示例代码。希望对大家有帮助。如果你有任何问题,请给我留言,边肖会及时回复你。非常感谢您对我们网站的支持!