前端的狀態管理與時間旅行:San實踐篇

本文將會從前端狀態管理的由來說起,然後簡單介紹作爲 san 的狀態管理工具 san-store 的實現思想,接着將介紹時間旅行的概念以及與狀態管理工具的關係,最後將介紹針對 san-store 的時間旅行的實現思路與關鍵技術點。

爲什麼需要狀態管理

組件化的思想對於前端來說是一大進步,它使得編寫高內聚,低耦合的代碼更加容易。同時隨着各個框架的出現,使得開發者不需要過多考慮底層的 DOM 操作,專注數據狀態的流轉與處理。但是組件化開發還是有其痛點所在,拋開調試與單元測試來說,對於業務功能影響最大的莫過於組件(模塊)之間的數據共享(狀態管理),因此催生出了非常多的狀態管理工具: flux,redux,甚至 react 在框架層面提供了 API 供用戶便捷的數據共享,但是正如 redux 作者 Mark Erikson[1] 所說,react hook 並非一個狀態管理系統:

useReducer plus useContext together kind of make up a state management system. And that one is more equivalent to what Redux does with React, but Context by itself is not a state management system.

無論是 flux 還是 redux 強調的都是單向數據流 (unidirectional data flow),目的有三個:

  • 提高數據的一致性,讓狀態變化可控
  • 更容易找出 BUG 的根源
  • 使得單元測更有意義
    圖片.jpg
    在上圖中,左右兩圖爲使用 store 前後的數據流示意圖,兄弟節點之間的狀態傳遞不再依靠 事件 / 回掉 /props 的方式實現,而是通過統一的 store 進行管理。這樣減少了組件之間的耦合,並且對數據部分進行單測更有意義與便捷。

下圖爲 flux 的單向數據流示意圖,action 爲一個簡單的對象,包含了新的數據以及對應的操作類型。當用於交互的時候,視圖可以產生一個 action 來修改視圖。所有的狀態數據都會流經中心樞紐 dispatcher,接着 dispatcher 將會執行在 store 中註冊的回調函數,在這些回調函數中,store 會處理每一個 action 中傳遞的狀態數據。然後 store 將會向視圖層派發一個數據變更的事件。視圖層接收到事件之後,會向 store 獲取各自關注的數據,獲取之後執行視圖層會利用前端框架的數據響應機制更新視圖。如果是 react 則利用 setState/hook 更新數據,然後有需要更新的組件會被重新渲染;如果是 vue 則可以直接修改實例的值,通過其響應式機制更新視圖;如果是 san,則通過 this.data.set 等方式修改數據並觸發視圖更新。

圖片.jpg
上述流程中利用到了發佈訂閱設計模式以及觀察者模式。發佈訂閱模式是視圖層作爲消息發佈者通過 dispatch 通知 store 中存儲的 action 訂閱者。而觀察者模式則是被觀察者 store 發佈內部數據變化的消息,通知所有觀察者組件進行數據的更新與後續邏輯。

San 中的狀態管理

在 san 應用中,我們通常使用 san-store 作爲應用的狀態管理系統。該系統遵循了 flux 的架構,實現了上述流程,下圖爲其數據流示意圖:

圖片.jpg
使用方式也非常簡單,代碼如下:


import {store, connect} from 'san-store';
import {builder} from 'san-update';

// 註冊 action
store.addAction('changeUserName', function (name) {
    return builder().set('user.name', name);
});

// 訂閱數據變化
let UserNameEditor = connect.san({
    name: 'user.name'
})(san.defineComponent({
    template: '<div>{{name}}</div>',
    submit() {   
         // 觸發 action        
         store.dispatch('changeUserName', this.data.get('name'));
}}));

我們對 san 的語法做一個簡單的介紹,san.defineComponent 用於生成一個組件,該函數接收的對象中 template 是組件模板,用於渲染來自 san-store 中的狀態數據 name。上述代碼的整體流程分爲兩個階段:

  1. 註冊 action 以及訂閱數據的變化:通過 san-store 提供的 addAction 註冊一個 action 處理函數;組件通過 connect 來訂閱 san-store 中數據的變化。
  2. 組件觸發 action 以及更新視圖:組件調用 dispatch 方法,需要傳入 action 的名稱以及相關的 payload。 san-store 會根據 action 名稱調用之前註冊的處理函數,並將 payload 傳遞給該處理函數。處理函數經過計算之後得到新的 state ,然後利用 san-update 生成並返回一個數據更新的執行函數。san-store 獲取到該執行函數之後,將當前的 state 傳遞給該執行函數,從而得到 diff 數據,以及新的 state,並且 san-store 會將新舊 state 以及兩者之間的 diff 數據存儲下來,最後發佈數據變化的消息,依次觸發訂閱了函數變化的組件的數據更新機制。
    上文提到的 san-update 主要是用於確保數據不可變,有興趣的同學可以對比着 immer 來看,由於與本文的主題關係不大,因此這裏將不會介紹其原理。

時間旅行

上文介紹了服務於 san 應用的狀態管理工具san-store 的實現思路以及使用方式,那麼狀態管理與時間旅行之間的有什麼關係呢?其實,早在 2015 年 Dan Abramov 就展示了通過redux-devtools 讓開發者在歷史狀態中自由穿梭,並稱之爲時間旅行。簡而言之,時間旅行的目的就是爲了方便開發者能夠輕鬆調試使用了狀態管理工具的前端應用。下文將會介紹如何針對 san-store 實現時間旅行的功能。

什麼是時間旅行

根據維基百科中所描述的:時間旅行泛指人或物體由某一時間點移至另一時間點,通俗的來講就是回退。我們這裏所說的時間旅行就是希望將應用恢復到之前某一個 action 發生時的狀態,就像回放錄像帶那樣簡單。

爲什需要時間旅行

那麼爲什麼需要時間旅行呢,很多時候我們頁面中的狀態由多個 action 共同決定,當最終的結果出現問題的時候,我們可能會需要回到某個 action 觸發的時刻,檢查頁面的狀態以及對應的數據。所以在某些時刻,時間旅行能讓我們更快速的發現問題。在調試工具 san-devtools 中我們已經實現了針對 san-store 的時間旅行的功能,下面我們簡要介紹其實現原理。

實現時間旅行思路

其實通過之前介紹的 flux 的思想,讓一切狀態可預測,那麼很容易能想到,既然狀態數據是可控可預測的,那麼我們就可以讓頁面的狀態會到之前的某個時刻的狀態。

根據上一小節,我們知道組件需要主動調用 store.dispatch 來觸發 store 的數據更新,但是時間旅行不能主動調用 dispatch 觸發 action,而是直接將 store 的數據回退到某個時刻,然後主動觸發視圖更新。其原理圖如下:

圖片.jpg
可以通過如下幾個步驟來實現:

  1. 在每次 store state 變化的時候,存儲新的 state 以及舊的 state,稱之爲 log 數據
  2. 獲取某個 action 對應的 log 數據
  3. 替換 store state
  4. 計算出新舊 state 的 diff 數據
  5. 主動觸發組件視圖更新
    其中第一步已經由 san-store 完成了,我們後續只需要關注後面的四個步驟。整個過程最簡單的實現方式就是 利用 monkey patch 來替換掉 san-store 中的原型方法與屬性。其中第 4 步的處理方式非常關鍵,對兩棵樹進行精確 diff 的時間複雜度在 O(n^3),顯然是不可取的。那麼我們應該如何處理呢?如果我們換個角度思考,如果我們只關心組件中需要從 store 中獲取哪些字段的數據,那麼 n 個字段的 diff,時間複雜度爲 O(n)。在上一節例子中組件只在 user.name 數據發生變化的時候更新視圖。因爲第四步的關鍵不是新舊 state 的完整 diff,而是收集所有涉及視圖更新的 store 中的字段。那麼下面,如果你對這部分的代碼感興趣,那麼請接着下面的閱讀。否則可以直接跳到總結與展望。

獲取 log 數據

當閱讀到這裏的時候,確保你已經閱讀了解了 san-store 的代碼,下文代碼涉及的關鍵變量的含義如下:

  • store:san-store 實例

  • store.stateChangeLogs :保存的狀態快照數據

  • store.raw:當前應用的狀態數據

  • paths:存儲了狀態樹中某個屬性的路徑

當我們獲取到需要回退的 actionId 之後,首先需要獲取對應的 log 數據,getStateFromStateLogs 的實現如下:


private getStateFromStateLogs(id) {
    const logs = store && store.stateChangeLogs;
    if (!Array.isArray(logs)) {
            return null;
    }
    return logs.find(item => id === item.id);
}

替換 state

由於 store.raw 存儲了 state 數據,因此我們可以直接用目標 state 進行賦值即可,但是頁面狀態如果在已經處於某個回退的狀態,那麼新觸發的 action 應該基於非回退狀態,所以我們需要將回退的狀態單獨存儲。下面的代碼會在 san-store 發送 store-default-inited 消息的時候會執行。

private decorateStore() {
  if ('sanDevtoolsRaw' in store) {
    return;
  }
  const storeProto = Object.getPrototypeOf(store);
  const oldProtoFn = storeProto.dispatch;
  storeProto.dispatch = function (...args: any) {
    this.traveledState = null;
    return oldProtoFn.call(this, ...args);
  };
  store.sanDevtoolsRaw = store.raw;
  Object.defineProperty(store, 'raw', {
    get() {
      if (store.traveledState) {
        return store.traveledState;
      }
      return this.sanDevtoolsRaw;
    },
    set(state) {
      this.sanDevtoolsRaw = state;
    }
  });
}

接着,我們通過下面的方式替換 san-store 中的 state:


private replaceState(state) {
     store.traveledState = state;
}

計算 diff 數據

從 diff 算法的時間複雜度來看,全量 diff 新舊 state 顯然是不可取的,因此我們只需要關心那些被訂閱的數據,由於在組件訂閱數據變化的時候,會顯示的申明數據的來源,比如上面例子中的 user.name,所以當 san-store 發送 store-listened 消息的時候,我們需要調用 collectMapStatePath 將 mapStates 的數據收集起來,代碼如下:


collectMapStatePath(mapStates) {
  if (Object.prototype.toString.call(mapStates).toLocaleLowerCase() !== '[object object]') {
    return;
  }
  Object.values(mapStates).reduce((prev, cur) => {
    const key = cur;
    const value = cur.split('.');
    prev[key] = value;
    return prev;
  }, paths);
}

當需要計算兩個 state 的 diff 數據的時候,只需要按照 this.paths 中存儲的 mapStates 來計算,getDiff 的代碼如下:


getDiff(newValue, oldValue, mapStatesPaths) {
    const diffs = [];
    for (let stateName in mapStatesPaths) {
        if (mapStatesPaths.hasOwnProperty(stateName)) {
            const path = mapStatesPaths[stateName];
            const newData = getValueByPath(newValue, path);
            const oldData = getValueByPath(oldValue, path);
            let diff;
            if (oldData !== undefined && newData !== undefined && newData !== oldData) {
                diff = {$change: 'change',newValue: newData,oldValue: oldData,target: pat};
            } else if (oldData === undefined && newData !== undefined) {
                diff = {$change: 'add',newValue: newData,oldValue: oldData,target: path};
            } else if (oldData !== undefined && newData === undefined) {
                diff = {$change: 'remove',newValue: newData,oldValue: oldData,target: path};
            }
            diff && diffs.push(diff);
        }
    }
    return diffs;
}

其中省略的 getValueByPath 函數用於從一個對象中,按照指定的路徑獲取對應的屬性值。 diff 數據有三種操作類型:

  1. change:修改值
  2. add:添加屬性
  3. remove:刪除屬性
    san-store 會按照這幾種類型調用 san 組件的不同類型的數據操作指令,對組件中的 state 進行增刪改查。

觸發試圖更新

當 diff 數據計算完成之後,需要主動調用 san-store 提供的_fire 方法通知所有訂閱了數據變化的組件,進行相應的更新操作。當 diff 數據的操作類型是 change 的時候,會通過 this.data.set 修改屬性值,當 diff 數據的操作類型是 add 或者 remove 的時候,會通過 this.data.splice 添加或者刪除對應的屬性。

最後 travelTo 的代碼如下:


travelTo(id) {
  if (!store || !store.stateChangeLogs || !paths) {
    return;
  }
  // 根據 actionId 獲取 state
  const state = getStateFromStateLogs(id);
  if (!state) {
    return;
  }
  // 替換 state
  replaceState(state.newValue);
  // 根據 mapStates 計算數據 diff
  const diffs = getDiff(state.newValue, store.traveledState, paths);
  // 觸發視圖更新
  store._fire(diffs);
  return;
}

在 san-devtools 中,我們只需要主動調用 travelTo,並傳入某個 action 的唯一標記,我們就能夠通過上面的個步驟,將頁面還原到之前某個時刻的頁面狀態。

總結

本文介紹了爲什麼需要狀態管理,簡要分析了狀態管理系統 flux 的單項數據流模型,其中簡要介紹了常用的基本概念:action,dispatcher,store,view 等。接着介紹了基於 flux 模型的 san-store。最後介紹了 san-devtools 是如何基於 san-store 實現時間旅行功能的。我們這裏所介紹的時間旅行是通過更新頁面的數據來回退到之前的頁面狀態,這種實現方式在遇到複雜的場景比如狀態本身涉及了隨機性的數據,那麼頁面狀態是無法精確還原的,目前可以通過保存頁面快照來解決這樣的問題,但是同時帶來了新的問題,頁面快照是一張頁面截圖,每次 action 觸發都需要保存圖片,回放的時候需要加載圖片,無論從內存考慮還是響應速度考慮,體驗會大打折扣。因此還需要在時間旅行的實現方案上或許還有需要做更多的思考。

參考資料

[1] markerikson: https://changelog.com/person/markerikson

點擊進入獲得更多技術信息~~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章