Web 應用中的撤銷與重做

來自專欄餓了么前端42 人贊了文章

撤銷(Undo)與重做(Redo)是絕大部分應用中都有的功能,它給用戶提供了後悔葯,這樣用戶即便誤操作也能使損失達到最小,或者沒有損失,給用戶提供更好的使用體驗。本文介紹 Web 應用中常用的兩種撤銷與重做實現思路,並提供一個在線案例。

Web 應用中的撤銷與重做,很容易想到富文本編輯器,但本文不特指富文本編輯器,而是更具有普遍意義的 Web 交互應用。只要有用戶操作發生的地方,就有可能出錯,需要提供挽救的方法。在富文本編輯器里,用戶的操作就是編輯文本內容,document.execCommand 能實現最基本的撤銷重做,不過受限制也非常多,真正用起來也是捉襟見肘。

在其他類型的交互應用中,用戶的操作多種多樣,可以是移動一個組件的位置,也可以從小組中刪除一個成員等等。面對這些豐富的場景,我們需要抽象出一些實現撤銷重做的基本思路。

不管什麼類型的交互應用,終究是用戶對某個對象進行操作,每一步操作都會對操作對象產生副作用,使其狀態發生改變。如下圖中,系統初始化後,操作對象的初始狀態是 S1,用戶進行 A、B、C 等操作後,現在操作對象的狀態是 S4。 如果沒有撤銷重做功能,這條操作鏈只會一直往前延伸,我們無法回到以前的狀態。

用戶操作路徑

首先需要對撤銷重做功能做出功能定義:

a. 能記錄用戶的一系列操作過程,並能根據記錄恢復到當時的狀態;

b. 用戶在進行某個操作過後,能通過撤銷(Undo)回到操作之前的狀態;

撤銷

c. 用戶在撤銷之後,也能通過重做(Redo)恢復前一次撤銷的操作;

重做

d. 用戶在任意狀態下,如果有新的操作(如 S7) ,原來保存在該狀態之後的操作記錄(如 S5、S6)要廢棄掉;

廢除分叉路徑

e. 錦上添花的功能:可以跳轉到已保存的任意一次操作記錄。

根據以怎樣的形式記錄用戶的操作過程,以及實際的項目實踐,我們總結出兩種不同實現思路:命令式和快照式。

命令式

命令式實現的撤銷與重做,在歷史記錄中保存的是操作命令(或者說方法),而且是每次保存兩個命令。

針對用戶的每一次操作,都寫一個正向操作方法和逆操作方法,統一提交到一個命令執行器中。命令執行器會保存這樣一對方法,並立即執行正向操作方法。當需要撤銷這步操作時,執行對應的逆操作方法,回到當次操作之前的狀態;重做時,再執行正向操作方法。

命令式

命令執行器不關心當前應用的數據狀態,只關注執行了什麼命令。在實現時,需要有兩個數組,一個用來保存可撤銷的歷史,一個用來保存可重做的歷史。

// Command Managerclass CommandManager { constructor() { this.undoStack = [] this.redoStach = [] }}

初始狀態下,兩個數組都是空的。接下來需要實現 execute 方法來執行並記錄用戶操作。

type Record = { do: Function, undo: Function}// Command Managerclass CommandManager { // execute(payload: Record) { this.undoStack.push(payload) payload.do() this.redoStack = [] } //}

需要注意的是,execute 方法是在有新操作時執行,這時候要把 redoStack 數組清空,因為要確保歷史記錄不分叉。然後是 undo 和 redo 方法。

class CommandManager { undo() { const record = this.undoStack.pop() record.undo() this.redoStack.push(cmd) } redo() { const record = this.redoStack.pop() record.do() this.undoStack.push(record) }}

如下圖所示,如果當前應用狀態是 S3,undoStack 中保存左側的命令對,redoStack 保存右側而對命令對。

undoStack 和 redoStack

以上邏輯完成後,就可以調用了。設想我們業務有一個操作是往小組中添加一個成員,代碼如下:

// User Mutationconst commandManager = new CommandManager()const member = zconst group = [a, b, c]const addAction = { // 本次動作的正向操作方法 do: () => { group.push(member) }, // 逆操作方法 undo: () => { const index = group.indexOf(member) if (index !== -1) { group.splice(index, 1) } }}// 提交給命令執行器去執行commandManager.execute(addAction)// group: [a, b, c, z]commandManager.undo()// group: [a, b, c]commandManager.redo()// group: [a, b, c, z]

以上只是將主要邏輯展示出來,在實際使用時,還要考慮到操作棧的大小限制。

由於命令式實現的操作記錄管理方法只需要在每一次操作發生時接收兩個命令,不關注命令內部實現細節,也不關心應用的數據狀態,所以可以很好得抽出封裝成第三方庫。GitHub 上大多數實現也是這種命令式。

不過,這種做法還是有一些問題的:

一、在應用開發過程中,必須持續關注撤銷重做功能;因為每一個操作都要寫逆操作方法。當你對數據有不同的變更方式時,隨即也要寫出相應的逆操作方法;

二、有些操作的逆操作可能寫起來會比正向操作複雜而且容易出錯;

三、撤銷、重做必須在相鄰的操作記錄中進行。你如果要從 S4 狀態撤銷到 S1 狀態,中間必須經歷 S3、S2 狀態。

快照式

快照式實現的撤銷與重做,在歷史記錄中保存的是應用數據的快照。

在用戶每一步操作之後,都對應用數據中需要保存的部分保存到歷史記錄里(使用深拷貝,或者不可變數據)。在撤銷或者重做時,直接取出相應的快照,恢復到應用中。按照這種思路,只要取出相應的數據快照,可以恢復到任意一次狀態。

在實現時,可以用一個數組來保存所有的數據快照作為歷史記錄。額外的還要提供一個索引變數,來指示應用當前位於歷史記錄中的位置。

class History { constructor(){ this.snapshots = [] this.cursor = -1 }}

如下圖:

快照列表

提供一個 record 方法,當用戶有操作時,會對應用數據造成變更,此時調用 record 方法記錄當前狀態。注意記錄的位置:在這次操作之前應用的狀態保存在數組的 cursor 索引處,有新的操作時,cursor 索引處後面的歷史記錄都應當清空。然後快照應該保存在索引 cursor 後面(由於清空了 cursor 後面的數據,所以直接 push 即可)。

class History { // ... record(snapshot) { while (this.cursor < this.snapshots.length - 1) { this.snapshots.pop() } this.snapshots.push(snapshot) } // ...}

廢除分叉路徑

最後是實現關鍵的 redo 和 undo 方法。既然 cursor 表示當前狀態的快照索引,那麼撤銷、重做就是取出前後相鄰的快照,將其返回,交給應用自身去根據快照恢復應用狀態。額外地,可以指定要取出快照的索引在歷史記錄中自由穿梭,實現時光機功能。

class History { undo() { this.cursor -= 1 return this.snapshots[this.cursor] } redo() { this.cursor += 1 return this.snapshots[this.cursor] } travel(cursor) { this.cursor = cursor return this.snapshots[this.cursor] }}

然後使用的時候就需要應用自己去控制:如何生成快照,如何根據快照恢復應用狀態。在文末,提供了一個在線的示例來演示根據快照式思路實現的撤銷重做。

以上的代碼同樣只是主流程,真正實現使用還需要注意以下幾點:

  1. cursor 索引的邊界控制;
  2. 快照列表的大小控制;
  3. 記錄快照之前最好跟前一個快照比對(深度比較),不然在操作記錄中可能會出現連續的幾個快照,恢復出來都是一模一樣的應用狀態。

這裡暴露快照式實現的一個致命缺陷:內存佔用問題。每條歷史記錄都是應用數據的深拷貝,如果應用需要記錄的數據比較龐大,或者操作記錄保存數量過大,都容易佔用大量內存。可以使用一些庫來計算每個快照大小,如 object-sizeof。 所以使用這種方式千萬注意歷史記錄的條數控制。

在 Vue 應用中,如果接入 Vuex,可以使用 Vuex Plugin 通過監聽 Mutations 在應用數據有變更時記錄快照。不過正如前文所說,要格外關注快照尺寸;Vuex 官網也指出:Plugins that take state snapshots should be used only during development.

如果沒有接入 Vuex,也可以 watch 需要保存的數據,在變更時 record 數據的快照。這種情況要注意節流和快照的差異比較,避免在快照列表中存入連續相同的內容。

點擊訪問在線案例?

ioslh.github.io


推薦閱讀:

相关文章