前端的狀態管理與時間旅行:San 實踐篇
本文將會從前端狀態管理的由來說起,然後簡單介紹作爲 san
的狀態管理工具 san-store
的實現思想,接着將介紹時間旅行的概念以及與狀態管理工具的關係,最後將介紹針對 san-store
的時間旅行的實現思路與關鍵技術點。
01
爲什麼需要狀態管理
組件化的思想對於前端來說是一大進步,它使得編寫高內聚,低耦合的代碼更加容易。同時隨着各個框架的出現,使得開發者不需要過多考慮底層的 DOM 操作,專注數據狀態的流轉與處理。但是組件化開發還是有其痛點所在,拋開調試與單元測試來說,對於業務功能影響最大的莫過於組件(模塊)之間的數據共享(狀態管理),因此催生出了非常多的狀態管理工具: flux
,redux
,甚至 react
在框架層面提供了 API 供用戶便捷的數據共享,但是正如 redux
作者 Mark Erikson[1] 所說,react hook
並非一個狀態管理系統:
useReducer
plususeContext
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 的根源
-
使得單元測更有意義
在上圖中,左右兩圖爲使用 store
前後的數據流示意圖,兄弟節點之間的狀態傳遞不再依靠 事件 / 回掉 /props 的方式實現,而是通過統一的 store
進行管理。這樣減少了組件之間的耦合,並且對數據部分進行單測更有意義與便捷。
下圖爲 flux
的單向數據流示意圖,action
爲一個簡單的對象,包含了新的數據以及對應的操作類型。當用於交互的時候,視圖可以產生一個 action
來修改視圖。所有的狀態數據都會流經中心樞紐 dispatcher
,接着 dispatcher
將會執行在 store
中註冊的回調函數,在這些回調函數中,store
會處理每一個 action
中傳遞的狀態數據。然後 store
將會向視圖層派發一個數據變更的事件。視圖層接收到事件之後,會向 store
獲取各自關注的數據,獲取之後執行視圖層會利用前端框架的數據響應機制更新視圖。如果是 react
則利用 setState/hook
更新數據,然後有需要更新的組件會被重新渲染;如果是 vue
則可以直接修改實例的值,通過其響應式機制更新視圖;如果是 san
,則通過 this.data.set
等方式修改數據並觸發視圖更新。
上述流程中利用到了發佈訂閱設計模式以及觀察者模式。發佈訂閱模式是視圖層作爲消息發佈者通過 dispatch
通知 store
中存儲的 action
訂閱者。而觀察者模式則是被觀察者 store
發佈內部數據變化的消息,通知所有觀察者組件進行數據的更新與後續邏輯。
02
San 中的狀態管理
在 san
應用中,我們通常使用 san-store
作爲應用的狀態管理系統。該系統遵循了 flux
的架構,實現了上述流程,下圖爲其數據流示意圖:
使用方式也非常簡單,代碼如下:
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
。上述代碼的整體流程分爲兩個階段:
-
**註冊 action 以及訂閱數據的變化:**通過
san-store
提供的addAction
註冊一個action
處理函數;組件通過connect
來訂閱san-store
中數據的變化。 -
**組件觸發 action 以及更新視圖:**組件調用
dispatch
方法,需要傳入action
的名稱以及相關的payload
。san-store
會根據action
名稱調用之前註冊的處理函數,並將payload
傳遞給該處理函數。處理函數經過計算之後得到新的state
,然後利用san-update
生成並返回一個數據更新的執行函數。san-store
獲取到該執行函數之後,將當前的state
傳遞給該執行函數,從而得到diff
數據,以及新的state
,並且san-store
會將新舊state
以及兩者之間的diff
數據存儲下來,最後發佈數據變化的消息,依次觸發訂閱了函數變化的組件的數據更新機制。
上文提到的 san-update
主要是用於確保數據不可變,有興趣的同學可以對比着 immer
來看,由於與本文的主題關係不大,因此這裏將不會介紹其原理。
03
時間旅行
上文介紹了服務於 san 應用的狀態管理工具san-store
的實現思路以及使用方式,那麼狀態管理與時間旅行之間的有什麼關係呢?其實,早在 2015 年 Dan Abramov 就展示了通過 redux-devtools 讓開發者在歷史狀態中自由穿梭,並稱之爲時間旅行。簡而言之,時間旅行的目的就是爲了方便開發者能夠輕鬆調試使用了狀態管理工具的前端應用。下文將會介紹如何針對 san-store 實現時間旅行的功能。
什麼是時間旅行
根據維基百科中所描述的:時間旅行泛指人或物體由某一時間點移至另一時間點,通俗的來講就是回退。我們這裏所說的時間旅行就是希望將應用恢復到之前某一個 action
發生時的狀態,就像回放錄像帶那樣簡單。
爲什需要時間旅行
那麼爲什麼需要時間旅行呢,很多時候我們頁面中的狀態由多個 action
共同決定,當最終的結果出現問題的時候,我們可能會需要回到某個 action
觸發的時刻,檢查頁面的狀態以及對應的數據。所以在某些時刻,時間旅行能讓我們更快速的發現問題。在調試工具 san-devtools
中我們已經實現了針對 san-store
的時間旅行的功能,下面我們簡要介紹其實現原理。
實現時間旅行思路
其實通過之前介紹的 flux
的思想,讓一切狀態可預測,那麼很容易能想到,既然狀態數據是可控可預測的,那麼我們就可以讓頁面的狀態會到之前的某個時刻的狀態。
根據上一小節,我們知道組件需要主動調用 store.dispatch
來觸發 store
的數據更新,但是時間旅行不能主動調用 dispatch
觸發 action
,而是直接將 store
的數據回退到某個時刻,然後主動觸發視圖更新。其原理圖如下:
可以通過如下幾個步驟來實現:
-
在每次
store state
變化的時候,存儲新的state
以及舊的state
,稱之爲log
數據 -
獲取某個
action
對應的log
數據 -
替換
store state
-
計算出新舊
state
的diff
數據 -
主動觸發組件視圖更新
其中第一步已經由 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
數據有三種操作類型:
-
change:修改值
-
add:添加屬性
-
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
的唯一標記,我們就能夠通過上面的個步驟,將頁面還原到之前某個時刻的頁面狀態。
04
總結
本文介紹了爲什麼需要狀態管理,簡要分析了狀態管理系統 flux
的單項數據流模型,其中簡要介紹了常用的基本概念:action
,dispatcher
,store
,view
等。接着介紹了基於 flux
模型的 san-store
。最後介紹了 san-devtools
是如何基於 san-store
實現時間旅行功能的。我們這裏所介紹的時間旅行是通過更新頁面的數據來回退到之前的頁面狀態,這種實現方式在遇到複雜的場景比如狀態本身涉及了隨機性的數據,那麼頁面狀態是無法精確還原的,目前可以通過保存頁面快照來解決這樣的問題,但是同時帶來了新的問題,頁面快照是一張頁面截圖,每次 action
觸發都需要保存圖片,回放的時候需要加載圖片,無論從內存考慮還是響應速度考慮,體驗會大打折扣。因此還需要在時間旅行的實現方案上或許還有需要做更多的思考。
參考資料:
[1] markerikson: https://changelog.com/person/markerikson
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/gNe1AEWCVk3TaFNNl8kZAg