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