基於Immutable.js 實現撤銷重做功能

blank

基於Immutable.js 實現撤銷重做功能

瀏覽器的功能越來越強大,許多原來由其他客戶端提供的功能漸漸轉移到了前端,前端應用也越來越複雜。許多前端應用,尤其是一些在線編輯軟件,運行時需要不斷處理用戶的交互,提供了撤消重做功能來保證交互的流暢性。不過為一個應用實現撤銷重做功能並不是一件容易的事情。 Redux官方文檔中介紹瞭如何在redux應用中實現撤銷重做功能。基於redux 的撤銷功能是一個自頂向下的方案:引入redux-undo 之後所有的操作都變為了「可撤銷的」,然後我們不斷修改其配置使得撤銷功能變得越來越好用(這也是redux-undo有那麼多配置項的原因)。

本文將採用自底向上的思路,以一個簡易的在線畫圖工具為例子,使用TypeScriptImmutable.js實現一個實用的「撤消重做」功能。大致效果如下圖所示:

blank
撤銷重做功能預覽

上圖看不清的話,可以看這裡

第一步:確定哪些狀態需要歷史記錄,創建自定義的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.tsexportdefaultclassAddItemActionextendsAction{newItem:ItemprevSelection:numberconstructor(newItem:Item){super()this.newItem=newItem}prepare(history:AppHistory){//創建新的圖形後會自動選中該圖形,為了使得撤銷該操作時state.selection變為原來的值// prepare方法中讀取了「添加圖形之前selection的值」並保存到this.prevSelectionthis.prevSelection=history.state.selectionreturnhistory}next(state:State){returnstate.setIn(['items',this.newItem.id],this.newItem).set('selection',this.newItemId)}prev(state:State){returnstate.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.tsconstemptyAction=Symbol('empty-action')exportconstundo=Symbol('undo')exporttypeundo=typeofundo// TypeScript2.7之後對symbol的支持大大增強exportconstredo=Symbol('redo')exporttyperedo=typeofredoconstAppHistoryRecord=Record({//當前應用狀態state:newState(),// action列表list:List<Action>(),// index表示最後一個applied-action在list中的下標。 -1表示沒有任何applied-actionindex:-1,})exportdefaultclassAppHistoryextendsAppHistoryRecord{pop() {//移除最後一項操作記錄returnthis.update('list',list=>list.splice(this.index,1)).update('index',x=>x-1)}getLastAction() {returnthis.index===-1?emptyAction:this.list.get(this.index)}getNextAction() {returnthis.list.get(this.index+1,emptyAction)}apply(action:Action){if(action===emptyAction)returnthisreturnthis.merge({list:this.list.setSize(this.index+1).push(action),index:this.index+1,state:action.next(this.state),})}redo() {constaction=this.getNextAction()if(action===emptyAction)returnthisreturnthis.merge({list:this.list,index:this.index+1,state:action.next(this.state),})}undo() {constaction=this.getLastAction()if(action===emptyAction)returnthisreturnthis.merge({list:this.list,index:this.index-1,state:action.prev(this.state),})}}

第四步:添加「撤銷重做」功能

假設應用中的其他代碼已經將網頁上的交互轉換為了一系列的Action 對象,那麼給應用添上「撤銷重做」功能的大致代碼如下:

typeHybridAction=undo|redo|Action//如果用Redux來管理狀態,那麼使用下面的reudcer來管理那些「需要歷史記錄的狀態」//然後將該reducer放在應用狀態樹中合適的位置functionreducer(history:AppHistory,action:HybridAction):AppHistory{if(action===undo){returnhistory.undo()}elseif(action===redo){returnhistory.redo()}else{//常規的Action//注意這裡需要調用prepare方法,好讓該action「準備好」returnaction.prepare(history).apply(action)}}//如果是在Stream/Observable的環境下,那麼像下面這樣使用reducerconstaction$:Stream<HybridAction>=generatedFromUserInteractionconstappHistory$:Stream<AppHistory>=action$.fold(reducer,newAppHistory())conststate$=appHistory$.map(h=>h.state)//如果是用回調函數的話,大概像這樣使用reduceronActionHappen=function(action:HybridAction){constnextHistory=reducer(getLastHistory(),action)updateAppHistory(nextHistory)updateState(nextHistory.state)}

第五步:合併Action,完善用戶交互體驗

通過上面這四個步驟,畫圖工具擁有了撤消重做功能,但是該功能用戶體驗並不好。在畫圖工具中拖動一個圖形時,MoveItemAction 的產生頻率和mousemove 事件的發生頻率相同,如果我們不對該情況進行處理,MoveItemAction 馬上會污染整個歷史記錄。我們需要合併那些頻率過高的action,使得每個被記錄下來的action 有合理的撤銷粒度。

每個Action 在被應用之前,其prepare 方法都會被調用,我們可以在prepare 方法中對歷史記錄進行修改。例如,對於MoveItemAction,我們判斷上一個action 是否和當前action 屬於同一次移動操作,然後來決定在應用當前action 之前是否移除上一個action。代碼如下:

// actions/MoveItemAction.tsexportdefaultclassMoveItemActionextendsAction{prevItem:Item//一次圖形拖動操作可以由以下三個變量來進行描述://拖動開始時鼠標的位置(startPos),拖動過程中鼠標的位置(movingPos),以及拖動的圖形的IDconstructor(readonlystartPos:Point,readonlymovingPos:Point,readonlyitemId:number){//上一行中readonly startPos: Point相當於下面兩步:// 1.在MoveItemAction中定義startPos只讀字段// 2.在構造函數中執行this.startPos = startPossuper()}prepare(history:AppHistory){constlastAction=history.getLastAction()if(lastActioninstanceofMoveItemAction&&lastAction.startPos==this.startPos){//如果上一個action也是MoveItemAction,且拖動操作的鼠標起點和當前action相同//則我們認為這兩個action在同一次移動操作中this.prevItem=lastAction.prevItemreturnhistory.pop()//調用pop方法來移除最近一個action}else{//記錄圖形被移動之前的狀態,用於撤銷this.prevItem=history.state.items.get(this.itemId)returnhistory}}next(state:State):State{constdx=this.movingPos.x-this.startPos.xconstdy=this.movingPos.y-this.startPos.yconstmoved=this.prevItem.move(dx,dy)returnstate.setIn(['items',this.itemId],moved)}prev(state:State){//撤銷的時候我們直接使用已經保存的prevItem即可returnstate.setIn(['items',this.itemId],this.prevItem)}getMessage() {/* ... */}}

從上面的代碼中可以看到,prepare 方法除了使action 自身準備好之外,它還可以讓歷史記錄準備好。不同的Action 類型有不同的合併規則,為每種Action 實現合理的prepare 函數之後,撤消重做功能的用戶體驗能夠大大提升。

一些其他需要注意的地方

撤銷重做功能是非常依賴於不可變性的,一個Action 對像在放入AppHistory.list 之後,其所引用的對像都應該是不可變的。如果action 所引用的對象發生了變化,那麼在後續撤銷時可能發生錯誤。本方案中,為了方便記錄操作發生時的一些必要訊息,Action 對象的prepare 方法中允許出現原地修改操作,但是prepare 方法只會在action 被放入歷史記錄之前調用一次,action 一旦進入記錄列表就是不可變的了。

總結

以上就是實現一個實用的撤銷重做功能的所有步驟了。不同的前端項目有不同的需求和技術方案,有可能上面的代碼在你的項目中一行也用不上;不過撤銷重做的思路應該是相同的,希望本文能夠給你帶來一些啟發。

What do you think?

Written by marketer

blank

我眼中的async/await

blank

Rematch: 重新設計Redux