爲 MobX 開啓 Time-Travelling 引擎

原文連接html

注意:本文並不是 mobx-state-tree 使用指南,事實上全篇都與 MST(mobx-state-tree) 無關。前端

前言

瞭解 mobx-state-tree 的同窗應該知道,做爲 MobX 官方提供的狀態模型構建庫,MST 提供了不少諸如 time travel、hot reload 及 redux-devtools支持 等頗有用的特性。但 MST 的問題在於過於 opinioned,使用它們以前必須接受它們的一整套的價值觀(就跟 redux 同樣)。vue

咱們先來簡單看一下 MST 中如何定義 Model 的:react

import { types } from "mobx-state-tree"

const Todo = types.model("Todo", {
    title: types.string,
    done: false
}).actions(self => ({
    toggle() {
        self.done = !self.done
    }
}))

const Store = types.model("Store", {
    todos: types.array(Todo)
})
複製代碼

老實講我第一次看到這段代碼時心裏是拒絕的,主觀實在是太強了,最重要的是,這一頓操做太反直覺了。直覺上咱們使用 MobX 定義模型應該是這樣一個姿式:git

import { observable, action } from 'mobx'
class Todo {
    title: string;
	@observable	done = false;

	@action
	toggle() {
    	this.done = !this.done;
	}
}

class Store {
    todos: Todo[]
}
複製代碼

用 class-based 的方式定義 Model 對開發者而言顯然更直觀更純粹,而 MST 這種「主觀」的方式則有些反直覺,這對於項目的可維護性並不友好(class-based 方式只要瞭解最基本的 OOP 的人就能看懂)。可是相應的,MST 提供的諸如 time travel 等能力確實又很吸引人,那有沒有一種方式能夠實現既能舒服的用常規方式寫 MobX 又能享受 MST 同等的特性呢?github

相對於 MobX 的多 store 和 class-method-based action 這種序列化不友好的範式而言,Redux 對 time travel/action replay 這類特性支持起來顯然要容易的多(但相應的應用代碼也要繁瑣的多)。可是隻要咱們解決了兩個問題,MobX 的 time travel/action replay 支持問題就會迎刃而解:vuex

  1. 收集到應用的全部 store 並對其作 reactive 激活,在變化時手動序列化(snapshot)。完成 store -> reactive store collection -> snapshot(json) 過程。
  2. 將收集到的 store 實例及各種 mutation(action) 作標識並作好關係映射。完成 snapshot(json) -> class-based store 的逆向過程。

針對這兩個問題,mmlpx 給出了相應的解決方案:json

  1. DI + reactive container + snapshot (收集 store 並響應 store 變化,生成序列化 snapshot)
  2. ts-plugin-mmlpx + hydrate (給 store 及 aciton 作標識,將序列化數據注水成帶狀態的 store 實例)

下面咱們具體介紹一下 mmlpx 是如何基於 snapshot 給出了這兩個解決方案。redux

Snapshot 須要的基本能力

上文提到,要想爲 MobX 治下的應用狀態提供 snapshot 能力,咱們須要解決如下幾個問題:api

收集應用的全部 store

MobX 自己在應用組織上是弱主張的,並不限制應用如何組織狀態 store、遵循單一 store(如redux) 仍是多 store 範式,但因爲 MobX 自己是 OOP 向,在實踐中咱們一般是採用 MVVM 模式 中的行爲準則定義咱們的 Domain Model 和 UI-Related Model(如何區別這兩類的模型能夠看 MVVM 相關的文章或 MobX 官方最佳實踐,這裏再也不贅述)。這就致使在使用 MobX 的過程當中,咱們默認是遵循多 store 範式的。那麼若是咱們想把應用的全部的 store 管理起來應該這麼作呢?

在 OOP 世界觀裏,想管理全部 class 的實例,咱們天然須要一個集中存儲容器,而這個容器一般很容易就會聯想到 IOC Container (控制反轉容器)。DI(依賴注入) 做爲最多見的一種 IOC 實現,能很好的替代以前手動實例化 MobX Store 的方式。有了 DI 以後咱們引用一個 store 的方式就變成這樣了:

import { inject } from 'mmlpx'
import UserStore from './UserStore'

class AppViewModel {
    @inject() userStore: UserStore
    
    loadUsers() {
        this.userStore.loadUser()
    }
}
複製代碼

以後,咱們能很容易地從 IOC 容器中獲取經過依賴注入方式實例化的全部 store 實例。這樣收集應用全部 store 的問題就解決了。

更多 DI 用法看這裏 mmlpx di system

響應全部 store 的狀態變化

獲取到全部 store 實例後,下一步就是如何監聽這些 store 中定義的狀態的變化。

若是在應用初始化完成後,應用內的全部 store 都已實例完成,那麼咱們監聽整個應用的變化就會相對容易。但一般在一個 DI 系統中,這種實例化動做是 lazy 的,即只有當某一 Store 被真正使用時纔會被實例化,其狀態纔會被初始化。這就意味着,在咱們開啓快照功能的那一刻起,IOC 容器就應該被轉換成 reactive 的,從而能對新加入管理的 store 及 store 裏定義的狀態實行自動綁定監聽行爲。

這時咱們能夠經過在 onSnapshot 時獲取到當前 IOC Container,將當前收集的 stores 所有 dump 出來,而後基於 MobX ObservableMap 構建一個新的 Container,同時 load 進以前的全部的 store,最後對 store 裏定義的數據作遞歸遍歷同時使用 reaction 作 track dependencies,這樣咱們就能對容器自己(Store 加入/銷燬)及 store 的狀態變化作出響應了。若是當變化觸發 reaction 時,咱們對當前應用狀態作手動序列化便可獲得當前應用快照。

具體實現能夠看這裏:mmlpx onSnapshot

從 Snapshot 中喚醒應用

一般咱們拿到應用的快照數據後會作持久化,以確保應用在下次進入時能直接恢復到退出時的狀態 ── 或者咱們要實現一個常見的 redo/undo 功能。

在 Redux 體系下這個事情作起來相對容易,由於自己狀態在定義階段就是 plain object 且序列化友好的。但這並不意味着在序列化不友好的 MobX 體系裏不能實現從 Snapshot 中喚醒應用。

想要順利地 resume from snapshot,咱們得先達成這兩個條件:

給每一個 Store 加上惟一標識

若是咱們想讓序列化以後的快照數據順利恢復到各自的 Store 上,咱們必須給每個 Store 一個惟一標識,這樣 IOC 容器才能經過這個 id 將每一層數據與其原始 Store 關聯起來。

mmlpx 方案下,咱們能夠經過 @Store@ViewModel 裝飾器將應用的 global state 和 local state 標記起來,同時給對應的模型 class 一個 id:

@Store('UserStore')
class UserStore {}
複製代碼

可是很顯然,手動給 Store 命名的作法很愚蠢且易出錯,你必須確保各自的命名空間不重疊(沒錯 redux 就是這麼作的[攤手])。

好在這個事情有 ts-plugin-mmlpx 來幫你自動完成。咱們在定義 Store 的時候只須要這麼寫:

@Store
class UserStore {}
複製代碼

通過插件轉換後就變成:

@Store('UserStore.ts/UserStore')
class UserStore {}
複製代碼

經過 fileName + className 的組合一般就能夠確保 Store 命名空間的惟一性。更多插件使用信息請關注 ts-plugin-mmlpx 項目主頁 .

Hyration

從序列化的快照狀態中激活應用的 reactive 系統,從靜態恢復到動態這個逆向過程,跟 SSR 中的 hydration 很是類似。實際上這也是在 MobX 中實現 Time Travelling 最難處理的一步。不一樣於 redux 和 vuex 這類 Flux-inspired 庫,MobX 中狀態一般是基於 class 這種充血模型定義的,咱們在給模型脫水再從新注水以後,還必須確保沒法被序列化的那些行爲定義(action method)依然能正確的與模型上下文綁定起來。單單從新綁定行爲還沒完,咱們還得確保反序列化以後數據的 mobx 定義也是跟原來保持一致的。好比我以前用 observable.refobservable.shallowObservableMap 這類有特殊行爲的數據在重注水以後能保持原始的能力不變,尤爲是 ObservableMap 這類非 object Array 的不可直接序列化的數據,咱們都得想辦法能讓他們從新激活回覆原狀。

好在咱們整個方案的基石是 DI 系統,這就給咱們在調用方請求獲取依賴時提供了「作手腳」的可能。咱們只須要在依賴被 get 時判斷其是否由從序列化數據填充而來的,即 IOC 容器中保存的 Store 實例並不是原始類型的實例,這時候便開啓 hydrate 動做,而後給調用方返回注水以後的 hydration 對象。激活的過程也很簡單,因爲咱們 inject 時上下文中是有 store 的類型(Constructor)的,因此咱們只要從新初始化一個新的空白 store 實例以後,使用序列化數據對其進行填充便可。好在 MobX 只有三種數據類型,object、array 和 map,咱們只須要簡單的對不一樣類型作一下處理就能完成 hydrate:

if (!(instance instanceof Host)) {

    const real: any = new Host(...args);

    // awake the reactive system of the model
    Object.keys(instance).forEach((key: string) => {
        if (real[key] instanceof ObservableMap) {
            const { name, enhancer } = real[key];
            runInAction(() => real[key] = new ObservableMap((instance as any)[key], enhancer, name));
        } else {
            runInAction(() => real[key] = (instance as any)[key]);
        }
    });

    return real as T;
}
複製代碼

hydrate 完整代碼能夠看這裏:hyrate

應用場景

相較於 MST 的快照能力(MST 只能對某一 Store 作快照,而不能對整個應用快照),基於 mmlpx 方案在實現基於 Snapshot 衍生的功能時變得更加簡單:

Time Travelling

Time Travelling 功能在實際開發中有兩種應用場景,一種是 redo/undo,一種是 redux-devtools 之類提供的應用 replay 功能。

在搭載 mmlpx 以後 MobX 實現 redo/undo 就變得很簡單,這裏再也不貼代碼(其實就是 onSnapshotapplySnapshot 兩個 api),直接貼個效果圖好了,具體用法能夠看 mmlpx 項目主頁

相似 redux-devtools 的功能實現起來相對麻煩一點(其實也很簡單),由於咱們要想實現對每個 action 作 replay,前提條件是每一個 action 都有一個惟一標識。redux 裏的作法是經過手動編寫具有不一樣命名空間的 action_types 來實現,這太繁瑣了(參考Redux數據流管理架構有什麼致命缺陷,將來會如何改進?)。好在咱們有 ts-plugin-mmlpx 能夠幫咱們自動的幫咱們給 action 起名(原理同自動給 store 起名)。解決掉這個麻煩以後,咱們只須要在 onSnapshot 的同時記錄每一個 action,就能在 mobx 裏面輕鬆的使用 redux-devtool 的功能了。

SSR

咱們知道,React 或 Vue 在作 SSR 時,都是經過在 window 上掛載全局變量的方式將預取數據傳遞到客戶端的,但一般官方示例都是基於 Redux 或 Vuex 來作的,MobX 在此以前想實現客戶端激活仍是有些事情要解決的。如今有了 mmlpx 的幫助,咱們只須要在應用啓動以前,使用傳遞過來的預取數據在客戶端應用快照便可基於 MobX 實現客戶端狀態激活:

import { applySnapshot } from 'mmlpx'

if (window.__PRELOADED_STATE__) {
    applySnapshot(window.__PRELOADED_STATE__)
}
複製代碼

應用 crash 監控

這個只要使用的狀態管理庫具有對任一時間作完整的應用快照,同時能從快照數據激活狀態關係的能力就能實現。即檢查到應用 crash 時按下快門,將快照數據上傳雲端,最後在雲端平臺經過快照數據還原現場便可。若是咱們上傳的快照數據還包括用戶前幾回的操做棧,那麼在監控平臺對用戶操做作 replay 也不成問題。

最後

做爲一個「多 store」範式的信徒,MobX 在一出現便取代了我心中 Redux 在前端狀態管理領域的地位。但苦於以前 MobX 多 store 架構下缺少集中管理 store 的手段,其在 time travelling 等系列功能的開發體驗上一直有所欠缺。如今在 mmlpx 的幫助下,MobX 也能開啓 Time Travelling 功能了,Redux 在我心中最後的一點優點也就蕩然無存了。

相關文章
相關標籤/搜索