基於 Immutable.js 實現撤銷重做功能
瀏覽器的功能越來越強大,許多原來由其他客戶端提供的功能漸漸轉移到了前端,前端應用也越來越複雜。許多前端應用,尤其是一些在線編輯軟體,運行時需要不斷處理用戶的交互,提供了撤消重做功能來保證交互的流暢性。不過為一個應用實現撤銷重做功能並不是一件容易的事情。Redux官方文檔中 介紹了如何在 redux 應用中實現撤銷重做功能。基於 redux 的撤銷功能是一個自頂向下的方案:引入 redux-undo 之後所有的操作都變為了「可撤銷的」,然後我們不斷修改其配置使得撤銷功能變得越來越好用(這也是 redux-undo 有那麼多配置項 的原因)。
本文將採用自底向上的思路,以一個簡易的在線畫圖工具為例子,使用 TypeScript、Immutable.js 實現一個實用的「撤消重做」功能。大致效果如下圖所示:
上圖看不清的話,可以看這裡。
第一步:確定哪些狀態需要歷史記錄,創建自定義的 State 類
並非所有的狀態都需要歷史記錄。許多狀態是非常瑣碎的,尤其是一些與滑鼠或者鍵盤交互相關的狀態,例如在畫圖工具中拖拽一個圖形時我們需要設置一個「正在進行拖拽」的標記,頁面會根據該標記顯示對應的拖拽提示,顯然該拖拽標記不應該出現在歷史記錄中;而另一些狀態無法被撤銷或是不需要被撤銷,例如網頁窗口大小,向後台發送過的請求列表等。
排除那些不需要歷史記錄的狀態,我們將剩下的狀態用 Immutable Record 封裝起來,並定義 State 類:
// State.tsimport { Record, List, Set } from immutableconst 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.tsexport 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.tsexport 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.tsconst 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 redoconst 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 的環境下,那麼像下面這樣使用 reducerconst action$: Stream<HybridAction> = generatedFromUserInteractionconst appHistory$: Stream<AppHistory> = action$.fold(reducer, new AppHistory())const state$ = appHistory$.map(h => h.state)// 如果是用回調函數的話,大概像這樣使用reduceronActionHappen = 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.tsexport 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 一旦進入記錄列表就是不可變的了。
總結
以上就是實現一個實用的撤銷重做功能的所有步驟了。不同的前端項目有不同的需求和技術方案,有可能上面的代碼在你的項目中一行也用不上;不過撤銷重做的思路應該是相同的,希望本文能夠給你帶來一些啟發。
推薦閱讀:
※為什麼選用Typescript
※【RPU-A】TypeScript 引入了 Plugin 支持
※TypeScript入門
※Typescript玩轉設計模式 之 對象行為型模式(下)
※vscode編輯器打開大項目能夠快速預覽,這是如何做到的?軟體演算法比atom做的好?
TAG:前端開發 | TypeScript |