基於 Vuex 的時移操作(撤回/恢復)實現

最近做了一個 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)數據結構做狀態機發散,這屬於自己牛逼同事看不懂的非工程解法,而且這個邏輯放在客戶端會很重。所以這倆都不是最優解,更好的方案暫時不寫了,因為我也沒想出來…