为 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 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 在我心中最后的一点优点也就荡然无存了。

相关文章
相关标签/搜索