最近做了一個 BI 平台的可視化看闆編輯器,項目剛做完一期,各方面的功能都還能粗糙,但該有的也都有了,比如編輯器場景下最基本的兩類時移操作-撤回(undo) 和恢複 (redo)。
用 vuex 實作的原理其實很簡單,一句話就可以概括:維護一個 state快照 的曆史記錄數組和目前索引值, undo 和 redo 分别對應索引的回退(backward)的前移(forward)。

搭配源碼@bugonly/vuex-undo-redo閱讀口味更佳。
時間線不可逆
假設A為空白狀态,依序進行以下操作:
- 新增一個元件1,進入狀态B;
- 再次新增一個元件2,進入狀态C;
- 執行undo操作,回退到狀态B,元件2被清除,僅剩元件1;
- 新增一個元件3,進入狀态D;
- 再次執行undo操作,回退到狀态B,元件3被清除,僅剩一個元件1;
- 再次執行undo操作,元件1被清除,看闆為空白狀态,即狀态A;
- 再次執行undo操作,提示無曆史記錄。
以上操作流程如下視訊:
上述步驟中有争議的是步驟6,在測試過程中測試同僚提出步驟6的表現應該是恢複到狀态C,即元件2被恢複到看闆中。如果是這樣的話會發生以下問題:
- 狀态B的 undo 操作結果會有兩種:狀态 A 和狀态 C;
- 如何判斷該什麼時候回退到 A?什麼時候回退到 C?
- 從狀态B undo 回退到 C,再次 undo 應該回退到哪個狀态?按時間線的話應該是回退到 B,那麼再次 undo 呢?死循環?
之是以對步驟6的結果有争議,根本原因是混淆了編輯行為和時移行為。時移行為 undo/redo 恢複的是上一步/下一步的編輯行為,而時移行為本身是不被記錄在操作曆史棧中的,也就是說, undo 行為本身不能被 undo ,redo 行為本身不能被 redo。否則就會造成時間線混亂,難以管理。
時間線不可逆這條規則在所有類型的可視化編輯器中都是統一的,比如線上文檔、IDE等等,大家有興趣可以親自去驗證一下。
行為分類
并不是所有行為都是可以撤回的,理論上應該隻有編輯行為可撤回,其他的比如頁簽之間的切換等簡單互動的行為雖然也是狀态機驅動(此處留個扣子,下文細聊),但并沒有支援撤回的必要性,如果所有狀态都能撤回反而令編輯器不好用。
是以在設計技術方案時,需要對使用者行為進行歸類,最基本要有三類:
- 支援撤回的行為;
- 不支援撤回的行為;
- 不支援撤回但是需要覆寫目前狀态機快照的行為。
最後一種非常有必要,有些行為雖然本身不能撤回,但是在它之後的一些行為需要支援撤回,為了保持狀态機的完整性,這類行為也必須記錄下來,但是并不會作為一個獨立的快照,而是覆寫目前快照。
舉個例子。
- 頁簽1新增一個元件;
- 新增頁簽2;
- 頁簽2新增一個元件;
- 切換到頁簽1;
- 執行 undo,此時的表現是自動切換至頁簽2并且清除了頁簽2中的元件。
上述步驟中頁簽之間的切換行為就屬于「不支援撤回但是需要覆寫目前狀态機快照的行為」之一。在絕大多數互動場景中,頁簽之間的切換是沒有必要使用 store 驅動的,往往是元件内部的狀态機,上面示例之是以将它加入 store 就是為了實作視訊中展示的 undo 自動切換頁簽效果。
這種方案比較簡單有效,當然也有其他解決方案實作。
時移操作的作用域
這一點就很簡單了,編輯器是應用的一個子產品,在 vuex 中是 store 的一個 module,是以時移操作的插件函數在訂閱 mutations 時需要判斷 mutation-type,過濾非編輯器子產品的 mutation。
const moduleFilterReg = new RegExp(`^${module}\/([a-zA-Z0-9\_]+)$`);
store.subscribe((mutation, state: Record<string, any>) => {
let mutationType = mutation.type;
if (moduleFilterReg){
const match = moduleFilterReg.exec(mutation.type);
// 過濾非指定子產品的mutation
if (!match) {
return;
}
mutationType = match[1];
}
// ...其他邏輯
});
插件函數完整源碼連結。
總結以上内容,時移操作插件的完整配置項如下:
interface IUndoRedoConfig {
/**
* 子產品名稱
* 如果指定子產品則過濾此子產品之外的所有 mutation
*/
module?: string;
/**
* 不跟蹤的 mutation-type 清單
*/
noTraceMutationTypes?: string[];
/**
* 此清單中的 mutation-type 行為不跟蹤,但是會覆寫目前曆史記錄
*/
needReplaceMutationTypes?: string[];
/**
* 過濾器,傳回 false 時不執行後續邏輯
* 使用 filter 可以編寫更複雜的過濾邏輯
* @param mutation
* @param state
*/
filter?: (mutation: MutationPayload, state:Record<string, any>) => boolean;
/**
* 曆史記錄容量,最小值1
*/
historyCapcity?: number;
}
頁簽域的時移操作如何實作?
最後留一個問題,這個問題我也暫時沒想通最優解。目前市面上幾乎所有的可視化編輯器都是這樣的邏輯:時移操作的作用域的編輯器全局。
如何了解這句話呢?比如上文提到的報告編輯器,undo/redo 操作是針對報告 scope的,而不是頁簽 scope。報告編輯器可能有些人比較陌生,類比一種更普遍的編輯器:Excel。
Excel 的每個工作表(sheet)相當于報告中的頁簽,你試着在excel中執行以下步驟:
- 在 sheet 1 中任意編輯一次;
- 建立一個 sheet 2;
- 在 sheet 2 中任意編輯一次;
- 執行一次 undo,表現為 sheet 2中的編輯被還原;
- 再執行一次 undo,表現為 sheet 2 被整體清除;
- 再執行一次 undo,表現為 sheet 1中的編輯被還原。
以上步驟可以看出,excel 的 undo 行為是針對 excel 文檔 scope 的,而不是每個 sheet 的 scope。
那麼假如我想實作每個 sheet 域的時移操作呢?具體表現為:
- 每個 sheet 有單獨的操作曆史,互不影響;
- sheet 不能被時移操作删除,隻能手動删除。
其實有很多種解決方案,最簡單的就是每個 sheet 在 vuex store 對應一個 module,然後為每個 module 單獨維護一個操作曆史棧,這屬于暴力解法,簡單有效但很挫。也有更複雜的,比如基于圖(Graph)資料結構做狀态機發散,這屬于自己牛逼同僚看不懂的非工程解法,而且這個邏輯放在用戶端會很重。是以這倆都不是最優解,更好的方案暫時不寫了,因為我也沒想出來...