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
中做的那些 autorun
与 reaction
,实例本身的 computed
字段导致的监听会自动被释放)。这样的好处是,不仅是 store
,承载 viewModel
及相关逻辑的 controller
也变得框架无关了,理论上,我们可以在完全复用 controller
实现的前提下,将 Angular 1.x 替换为 Angular 2.x / React / Vue 或是别的界面库。
当你写的逻辑变得复杂,对 MobX 的使用深入后,你还会遇到一些坑,这里只说由二者共同造成的那些:
max $digest() iterations reached
MobX 并不是一直会将对 observable
数据的读取记做依赖的 —— 存在一个当前正在进行中的推导(derivation
,autorun
、reaction
、computed
这些都是)时才会,否则依赖不知道该记到谁的头上...所以,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
行为里,调用 reaction
,reaction
的第一个参数是用于收集依赖的方法,实现该方法遍历当前实例上的(可变数据)字段即可。这样这些字段会被记为这个 reaction
的依赖,也就避免了上面说到的问题。
更合理的做法是,与提供给 React Component 的 observer
方法类似,只在进行类似 render
的行为时才去收集依赖,这样依赖关系会更精确,也更加经济。但是 Angular 1.x 没有基于 Virtual DOM,也没有每次更新全量渲染的逻辑,似乎没有与这个 render
对应的行为,可能不好实现。
useStrict 与 autorun
MobX 提供了 useStrict
方法来对我们操作 store
的行为进行进一步限制 —— 开启后,只允许在 action
中对 observable
数据进行修改。然而,Angular,作为一个 MVVM 框架,它的双向绑定机制会在用户交互时由框架去直接修改你定义在 controller
上的可变数据,而不会学会调用对应的 action
来做这件事(即便你为每个数据都实现了对应的修改方法)。对应地,这一限制可以带来的好处自然成了镜花水月。
虽无从触及,不妨了解下好处:
action
被调用的行为本身可以被记录并报告。
action
都是方法,有方法名,有参数,打印的信息有助于 question 应用的行为。
- 修改可变数据的行为被限制在
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 框架)的配合使用,并不普适。
除此之外,我们还做了:
-
自定义的 action 行为 logger 工具,
spy
注册回调,在回调中选择信息打印即可。 -
Store
类的实例方法snapshot
与resume
,及基于这两个方法做的刷新页面后页面状态恢复。Time Traveling 也是可以做的,只是暂时没这需求。这些事 MobX 官方的项目 mobx-state-tree 也在做,mobx-state-tree 的特点是:基于 MobX、Opinionated(注意 MobX 本身是与应用状态的组织方式无关的,即前面说到的 Unopinionated)以及事务性(transactional),所以如果要跟 Redux 对比的话,也许它更合适。 -
使用 ES6 module 替代了 Angular 1.x 的依赖注入,几乎完全避免了业务代码中对依赖注入的使用。
以上,Angular 1.x 已是历史,MobX 方兴未艾。