MobX 与 Angular 1.x

MobX 是个好东西,熟悉 Vue 的同学应该会很亲切,Vue 中有这样一个机制:数据(data)的内容是 observable 的(借助 getter/setter),在组件 render 或计算 computed 的值时,被使用到的 observable 数据会被作为本次推导过程(render / compute)的依赖被记录下来,当这些依赖发生改变(被 set)时,对应的推导过程被自动触发(组件重新 render 或计算属性的值被重新计算)。借助这一机制,Vue 可以不要求显式的 setState,按需地更新界面。而 MobX 可以看成是将这一机制单独抽出,并做完善,提供更多 API 的库。不难发现,它主要是提供了一种能力,而不会限制(unopinionated)应用的组织方式(与 Redux 很重要的不同)。

官方提供了 MobX 与 React 的绑定:一个取名 observer 的 HoC,做的事情很简单:对传入该 HoC 的 Component 实现,在执行 render 方法时进行依赖收集,在依赖的值发生变更时触发渲染并更新界面。

对于 MobX 与 Angular 1.x 的搭配,实践的人不多,更少有相关资料,磕磕绊绊踩了一路坑。

基本思路是,对于模板中需要用到的可变数据,都通过 MobX 提供的装饰器函数 @observable@computed 定义为 controller 实例上的字段,在模板中通过 $ctrl.xxx 进行访问。在有数据发生改变时(可以通过 spy 注册),执行 $rootScope.$applyAsync()

这样其实就能跑起来了。不过还有这些事值得做:

纯数据相关逻辑抽取为 store

store 实现为类(也是使用 MobX 最方便的形式),实例化后使用。要注意,逻辑在类里,数据在实例里。对于如全局状态这样的 store,可能整个应用里使用的都是它的同一个实例。对于 store 的实现,通过具名导出类本身,并实例化一个实例作为 default 导出(不需要承担共享数据角色的 store 则不需要),这是实践后比较好的做法。

框架无关的 controller 实现

对于存放界面数据(viewModel)与相关逻辑的 controller(我们的 Controller 基类直接继承了 Store 基类),将其与 Angular 1.x 的 API 隔离,即,自己规范其接口:在我们的实践中,一个有 init 方法的 class 即可满足需求,然后在框架层面把这样一个 Angular 无关的类实现包装成为可用的 Angular controller。幸而,要做的事情也很有限:init 方法映射为 $onInit,在 $onDestroy 中对实例进行销毁(主要工作是清理在 init 中做的那些 autorunreaction,实例本身的 computed 字段导致的监听会自动被释放)。这样的好处是,不仅是 store,承载 viewModel 及相关逻辑的 controller 也变得框架无关了,理论上,我们可以在完全复用 controller 实现的前提下,将 Angular 1.x 替换为 Angular 2.x / React / Vue 或是别的界面库。

当你写的逻辑变得复杂,对 MobX 的使用深入后,你还会遇到一些坑,这里只说由二者共同造成的那些:

max $digest() iterations reached

MobX 并不是一直会将对 observable 数据的读取记做依赖的 —— 存在一个当前正在进行中的推导(derivationautorunreactioncomputed 这些都是)时才会,否则依赖不知道该记到谁的头上...所以,Angular 的脏检查不会让 MobX 认为 obervable 的属性被观察/依赖了。

这会导致的最典型的问题是:通过 @computed 定义的计算属性,在认为没有被观察/依赖的情况下,其对计算结果的缓存能力会被禁用,即,每次读取这个值,都会触发一次 getter 的执行。一方面,这有性能问题;另外一方面,如果 getter 每次计算的结果都是引用不一样的(如每次都 return [1, 2, 3]),脏检查会认为每次得到的值都不相等,即值无法稳定,接着它会反复计算并比较,从而达到计算次数上限,触发报错,最后停止同步机制。错误形如:

Error: [$rootScope:infdig] 10 $digest() iterations reached. Aborting!
Watchers fired in the last 5 iterations: ...

对于这个问题,有一个很粗暴的解决方法:在 controller(基类)的 init 行为里,调用 reactionreaction 的第一个参数是用于收集依赖的方法,实现该方法遍历当前实例上的(可变数据)字段即可。这样这些字段会被记为这个 reaction 的依赖,也就避免了上面说到的问题。

更合理的做法是,与提供给 React Component 的 observer 方法类似,只在进行类似 render 的行为时才去收集依赖,这样依赖关系会更精确,也更加经济。但是 Angular 1.x 没有基于 Virtual DOM,也没有每次更新全量渲染的逻辑,似乎没有与这个 render 对应的行为,可能不好实现。

useStrict 与 autorun

MobX 提供了 useStrict 方法来对我们操作 store 的行为进行进一步限制 —— 开启后,只允许在 action 中对 observable 数据进行修改。然而,Angular,作为一个 MVVM 框架,它的双向绑定机制会在用户交互时由框架去直接修改你定义在 controller 上的可变数据,而不会学会调用对应的 action 来做这件事(即便你为每个数据都实现了对应的修改方法)。对应地,这一限制可以带来的好处自然成了镜花水月。

虽无从触及,不妨了解下好处:

  1. action 被调用的行为本身可以被记录并报告。

action 都是方法,有方法名,有参数,打印的信息有助于 question 应用的行为。

  1. 修改可变数据的行为被限制在 action 中是科学使用 autorun 的前提。

autorun 是与普通的 watch 相比更好用的监听并触发副作用的方式。普通的 watch 是:

watch(deps, prepareDataAndInvokeSideEffect)

与 MobX 提供的 reaction 类似:

reaction(prepareData, invokeSideEffect)

reaction 本身其实比上面的 watch 更好用,它基于 MobX 的能力,它可以在 prepareData 执行的时候自动记录下其依赖。而 autorun 是这样的:

autorun(prepareDataAndInvokeSideEffect)

它完全避免了对依赖的手动维护,甚至不需要你仔细地拆开依赖收集与产生副作用这两部分。之所以产生副作用的行为里对可变数据的读/写不会被误认为是依赖,在于 action 本身有 untracked 的特点:在 action 中对数据的操作不会被计入当前 derivation 的依赖。设想一下没有这一点的保证 —— 在 autorun 中修改数据 A 会导致再次执行 autorun —— 因为 A 也被认为是该 autorun 的依赖了,这只是一个简化的情况,在实际中确会遇到更复杂,但经常表现为错误地计入依赖、甚至导致无限循环的问题。而正如前面说到的,在与 Angular 配合的使用中,这一保证荡然无存。

所以,解决方法也很简单,使用 reaction 而不是 autorun。再次强调,这一结论针对于 Angular(也许也包括其他 MVVM 框架)的配合使用,并不普适。

除此之外,我们还做了:

  1. 自定义的 action 行为 logger 工具,spy 注册回调,在回调中选择信息打印即可。

  2. Store 类的实例方法 snapshotresume,及基于这两个方法做的刷新页面后页面状态恢复。Time Traveling 也是可以做的,只是暂时没这需求。这些事 MobX 官方的项目 mobx-state-tree 也在做,mobx-state-tree 的特点是:基于 MobX、Opinionated(注意 MobX 本身是与应用状态的组织方式无关的,即前面说到的 Unopinionated)以及事务性(transactional),所以如果要跟 Redux 对比的话,也许它更合适。

  3. 使用 ES6 module 替代了 Angular 1.x 的依赖注入,几乎完全避免了业务代码中对依赖注入的使用。

以上,Angular 1.x 已是历史,MobX 方兴未艾。