最近做了一個 BI 平臺的視覺化看板編輯器,專案剛做完一期,各方面的功能都還能粗糙,但該有的也都有了,比如編輯器場景下最基本的兩類時移操作-撤回(undo) 和恢復 (redo)。

用 vuex 實現的原理其實很簡單,一句話就可以概括:維護一個 state快照 的歷史記錄陣列和當前索引值, undo 和 redo 分別對應索引的回退(backward)的前移(forward)。

原理雖然簡單,但程式碼實現還是要注意一些細節。

搭配原始碼@bugonly/vuex-undo-redo閱讀口味更佳。

時間線不可逆

假設A為空白狀態,依序進行以下操作:

  1. 新增一個元件1,進入狀態B;
  2. 再次新增一個元件2,進入狀態C;
  3. 執行undo操作,回退到狀態B,元件2被清除,僅剩元件1;
  4. 新增一個元件3,進入狀態D;
  5. 再次執行undo操作,回退到狀態B,元件3被清除,僅剩一個元件1;
  6. 再次執行undo操作,元件1被清除,看板為空白狀態,即狀態A;
  7. 再次執行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. 頁籤1新增一個元件;
  2. 新增頁籤2;
  3. 頁籤2新增一個元件;
  4. 切換到頁籤1;
  5. 執行 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中執行以下步驟:

  1. 在 sheet 1 中任意編輯一次;
  2. 新建一個 sheet 2;
  3. 在 sheet 2 中任意編輯一次;
  4. 執行一次 undo,表現為 sheet 2中的編輯被還原;
  5. 再執行一次 undo,表現為 sheet 2 被整體清除;
  6. 再執行一次 undo,表現為 sheet 1中的編輯被還原。

以上步驟可以看出,excel 的 undo 行為是針對 excel 文件 scope 的,而不是每個 sheet 的 scope。

那麼假如我想實現每個 sheet 域的時移操作呢?具體表現為:

  • 每個 sheet 有單獨的操作歷史,互不影響;
  • sheet 不能被時移操作刪除,只能手動刪除。

其實有很多種解決方案,最簡單的就是每個 sheet 在 vuex store 對應一個 module,然後為每個 module 單獨維護一個操作歷史棧,這屬於暴力解法,簡單有效但很挫。也有更復雜的,比如基於圖(Graph)資料結構做狀態機發散,這屬於自己牛逼同事看不懂的非工程解法,而且這個邏輯放在客戶端會很重。所以這倆都不是最優解,更好的方案暫時不寫了,因為我也沒想出來...