為MobX 開啟Time-Travelling 引擎

blank

為MobX 開啟Time-Travelling 引擎

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

blank

前言

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

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

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 定義模型應該是這樣一個姿勢:

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 同等的特性呢?

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

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

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

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

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

Snapshot 需要的基本能力

上文提到,要想為MobX 治下的應用狀態提供snapshot 能力,我們需要解決以下幾個問題:

收集應用的所有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 todomvc demo (就是文章開頭貼的gif效果)和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 在我心中最後的一點優勢也就蕩然無存了。

What do you think?

Written by marketer

blank

如何監控網頁的卡頓?

blank

2018 Google 移動技術最新進展速覽