为 MobX 开启 Time-Travelling 引擎

原文链接

注意:本文并非 mobx-state-tree 使用指南,事实上全篇都与 MST(mobx-state-tree) 无关。

前言

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

你可能感兴趣的:(mobx,javascript)