本系列分三部曲:《框架实现》 《框架使用》 与 《跳出框架看哲学》,这三篇是我对数据流阶段性的总结,正好补充之前过时的文章。
本篇是 《框架实现》。
本周精读的文章是 dob 文档,如果不熟悉 API,可以简单读一读,文中有些地方会提到一些函数。
我觉得数据流与框架的关系,有点像网络与人的关系。
在网络诞生前,人与人之间连接点较少,大部分消息都是通过人与人之间传递,虽然信息整体性不强,但信息在局部非常完备:当你想开一家门面,找到经验丰富的经理人,可以一手包办完。
网络诞生后,如果想通过纯网络的方式,学习如何开门面,如果不是对网络很熟悉,一时半会也难以学习到全套流程。
数据流对框架来说,就像网络对人一样,总是存在着模块功能的完备性与项目整体性的博弈。
全局性强了,对整体性强要求的项目(频繁交互数据)友好,顺便利于测试,因为不利于测试的 UI 与数据关系被抽离开了。
局部性强了,对弱关联的项目友好,这样任何模块都能不依赖全局数据,自己完成所有功能。
对数据流的研究,大多集中于 “优化在某些框架的用法” “基于场景改良” “优化全局与局部数据流间关系” “函数式与面向对象之争” “对输入抽象” “数据格式转换” 这几方面。这里面参杂着统一与分离,类比到网络与人,也许最终只有人脑搬到网络中,才可以达到最终状态。
虚的就说这么多,本篇讲的是 《框架实现》,我们先钻到细节里。
dob 是个类似 mobx 的框架,实现思路都很类似,如果难以读懂 mobx 的源码,可以先参考 dob 的实现原理。
MVVM 思路中,依赖追踪是核心。
dob 中
observe
类似 mobx 的autorun
,是使用频率最高的依赖监听工具。
写作时,已经有许多文章将 vue 源码翻来覆去研究过了,因此这里就不长篇大论 MVVM 原理了。
依赖追踪分为两部分,分别是 依赖收集 与 触发回调,如果把这两个功能合起来,就是 observe
函数,分开的话,就是较为底层的 Reaction
:
Reaction
双管齐下,一边监听用到了哪些变量,另一边在这些变量改变后,执行回调函数。Observe
利用 Reaction
实现(简化版):
function observe(callback) {
const reaction = new Reaction(() => {
reaction.track(callback)
})
reaction.run()
}
reaction.run()
在初始化就执行 new Reaction
的回调,而这个回调又恰好执行 reaction.track(callback)
。所以 callback
函数中用到的变量被记录了下来,当变量更改时,会触发 new Reaction
的回调,又重新收集一轮依赖,同时执行了 callback
。
这样就实现了回调函数用到的变量被改变后,重新执行这个回调函数,这就是 observe
。
依赖收集无法得到触发时的环境信息。
依赖收集由 getter、setter 完成,但触发时,却无法定位触发代码位于哪个函数中,所以为了依赖追踪(即变量与函数绑定),需要定义一个全局的变量标示当前执行函数,当各依赖收集函数执行没有交叉时,可以正常运作:
上图右侧白色方块是函数体,getter
表示其中访问到某个变量的 getter
,经由依赖收集后,变量被修改时,左侧控制器会重新调用其所在的函数。
但是,当函数嵌套函数时,就会出现异常:
由于采用全局变量标记法,当回调函数嵌套起来时,当内层函数执行完后,实际作用域已回到了外层,但依赖收集无法获取这个堆栈改变事件,导致后续 getter
都会误绑定到内层函数。
异步(回调)也是同理,虽然写在一个函数体内,但执行的堆栈却不同,因此无法实现正确的依赖收集。
所以需要一些办法,将嵌套的函数放在外层函数执行完毕后,再执行:
换成代码描述如下:
observe(()=>{
console.log(1)
observe(()=>{
console.log(2)
})
console.log(3)
})
// 需要输出 1,3,2
当然这不是简单 setTimeout 异步控制就可以,因为依赖收集是同步的,我们要在同步基础上,实现函数执行顺序的变换。
我们可以逐层分解,在每一层执行时,子元素如果是 observe
,就会临时放到队列里并跳过,在父 observe
执行完毕后,检查并执行队列,两层嵌套时执行逻辑如下图所示:
这些努力,就是为了保证在同步执行时,所有 getter
都能绑定到正确的回调函数。
observe
如何到render
observe 可以类比到 React 的 render,它们都具有相同的特征:是同步函数,同时 observe 的运行机制也符合了 render 函数的需求,不是吗?
如果将 observe 用到 react render 函数,当任何 render 函数使用到的变量发生改动,对应的 render 函数就会重新执行,实现 UI 刷新。
要实现结合,用到两个小技巧:聚合生命周期、替换 render 函数,用图才能解释清楚:
以上是简化版,正式版本使用 reaction
实现,可以更清晰的区分依赖收集与 rerender 阶段。
为了使用起来具有更好的可维护性,需要限制依赖追踪的功能,使值不能再随意的修改。可见,强大的功能,不代表在数据流场景的高可用性,恰当的约束反而会更好。
因此引入 Action
概念,在 Action
中执行的变量修改,不仅会将多次修改聚合成一次 render,而且不在 Action
中的变量修改会抛出异常。
Action
类似进栈出栈,当栈深度不为 0 时,进行的任何的变量修改,拦截到后就可以抛出异常了。
一层一层功能逐渐冒泡。
调试功能,在依赖追踪、与 react 结合这一层都需要做,怎样分工配合才能保证功能不冗余,且具有良好的拓展性呢?
数据流框架的 Debug 分为数据层和 UI 层,顺序是 dob 核心记录 debug 信息 -> dob-devtools 读取再加工,强化 UI 信息。
在 UI 层不止可以简单的将对象友好展示出来,更可以通过额外信息采集,将 Action 与 UI 元素绑定,让用户找到任意一次 Action 触发时,rerender 了哪些 UI 元素,以及每个 UI 元素分别与哪些 Action 绑定。
由于数据流需要一个 Provider
提供数据源,与 Connect
注入数据,所以可以将所有与数据流绑定的 UI 元素一一映射到 Debug UI,就像一面镜子一样映射:
通过 Debug UI,将 debug 信息与 UI 一一对应,实现 dob-react-devtools 的效果。
解耦还能方便许多功能拓展,比如支持 redux。
我得答案是事件。通过精心定义的一系列事件,制造出一个具有生命周期的工具库!
在所有 getter
setter
节点抛出相关信息,Debug 端订阅这些事件,找到对自己有用的,记录下来。例如:
event.on("get", info => {
// 不在调试模式
if (!globalState.useDebug) {
return
}
// 记录调用堆栈..
})
Dob 目前支持这几种事件钩子:
- get: 任何数据发生了 getter。
- set: 任何数据发生了 setter。
- deleteProperty: 任何数据的 key 被移除时。
- runInAction: 调用了 Action。
- startBatch: 任意 Action 入栈。
- endBatch: 任意 Action 出栈。
并且在关键生命周期节点,还要遵守调用顺序,比如以下是 Action
触发后,到触发 observe 的顺序:
startBatch
-> debugInAction
-> ...multiple nested startBatch and endBatch
-> debugOutAction
-> reaction
-> observe
如果未开启 debug,执行顺序简化为:
startBatch
-> ...multiple nested startBatch and endBatch
-> reaction
-> observe
订阅了这些事件,可以完成类似 redux-dev-tools 的功能。
由于篇幅有限,本文介绍的《框架实现》均是一些上层设计,很少有代码讲解。因为我觉得一篇引发思考的文章不应该贴太多的代码,况且人脑处理图形的效率远远高于文字、略高于代码,所以通过一些图来展示。如果想看代码实现,可以读 dob 源码。
如希望详细了解依赖注入实现流程,请看 从零开始用 proxy 实现 mobx。
下一篇是 《框架使用》,会站在使用者的角度思考数据流。当然不是下一篇精读,因为要换换胃口,也给我一些缓冲时间去整理。
如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。