35. 精读《dob - 框架实现》

2022-03-14 15:58:32 浏览数 (2)

本系列分三部曲:《框架实现》 《框架使用》 与 《跳出框架看哲学》,这三篇是我对数据流阶段性的总结,正好补充之前过时的文章。

本篇是 《框架实现》。

本周精读的文章是 dob文档,如果不熟悉 API,可以简单读一读,文中有些地方会提到一些函数。

1 引言

我觉得数据流与框架的关系,有点像网络与人的关系。

在网络诞生前,人与人之间连接点较少,大部分消息都是通过人与人之间传递,虽然信息整体性不强,但信息在局部非常完备:当你想开一家门面,找到经验丰富的经理人,可以一手包办完。

网络诞生后,如果想通过纯网络的方式,学习如何开门面,如果不是对网络很熟悉,一时半会也难以学习到全套流程。

数据流对框架来说,就像网络对人一样,总是存在着模块功能的完备性与项目整体性的博弈。

全局性强了,对整体性强要求的项目(频繁交互数据)友好,顺便利于测试,因为不利于测试的 UI 与数据关系被抽离开了。

局部性强了,对弱关联的项目友好,这样任何模块都能不依赖全局数据,自己完成所有功能。

对数据流的研究,大多集中于 “优化在某些框架的用法” “基于场景改良” “优化全局与局部数据流间关系” “函数式与面向对象之争” “对输入抽象” “数据格式转换” 这几方面。这里面参杂着统一与分离,类比到网络与人,也许最终只有人脑搬到网络中,才可以达到最终状态。

虚的就说这么多,本篇讲的是 《框架实现》,我们先钻到细节里。

2 精读 dob 框架实现

dob 是个类似 mobx 的框架,实现思路都很类似,如果难以读懂 mobx 的源码,可以先参考 dob 的实现原理。

抽丝剥茧,实现依赖追踪

MVVM 思路中,依赖追踪是核心。 dob 中 observe 类似 mobx 的 autorun,是使用频率最高的依赖监听工具。

写作时,已经有许多文章将 vue 源码翻来覆去研究过了,因此这里就不长篇大论 MVVM 原理了。

依赖追踪分为两部分,分别是 依赖收集 与 触发回调,如果把这两个功能合起来,就是 observe 函数,分开的话,就是较为底层的 Reaction

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 完成,但触发时,却无法定位触发代码位于哪个函数中,所以为了依赖追踪(即变量与函数绑定),需要定义一个全局的变量标示当前执行函数,当各依赖收集函数执行没有交叉时,可以正常运作:

上图右侧白色方块是函数体,getter 表示其中访问到某个变量的 getter,经由依赖收集后,变量被修改时,左侧控制器会重新调用其所在的函数。

但是,当函数嵌套函数时,就会出现异常:

由于采用全局变量标记法,当回调函数嵌套起来时,当内层函数执行完后,实际作用域已回到了外层,但依赖收集无法获取这个堆栈改变事件,导致后续 getter 都会误绑定到内层函数。

异步(回调)也是同理,虽然写在一个函数体内,但执行的堆栈却不同,因此无法实现正确的依赖收集。

所以需要一些办法,将嵌套的函数放在外层函数执行完毕后,再执行:

换成代码描述如下:

代码语言:javascript复制
observe(()=>{
  console.log(1)
  observe(()=>{
  	console.log(2)
  })
  console.log(3)
})
// 需要输出 1,3,2

当然这不是简单 setTimeout 异步控制就可以,因为依赖收集是同步的,我们要在同步基础上,实现函数执行顺序的变换。

我们可以逐层分解,在每一层执行时,子元素如果是 observe,就会临时放到队列里并跳过,在父 observe 执行完毕后,检查并执行队列,两层嵌套时执行逻辑如下图所示:

这些努力,就是为了保证在同步执行时,所有 getter 都能绑定到正确的回调函数。

如何结合 React

observe 如何到 render

observe 可以类比到 React 的 render,它们都具有相同的特征:是同步函数,同时 observe 的运行机制也符合了 render 函数的需求,不是吗?

如果将 observe 用到 react render 函数,当任何 render 函数使用到的变量发生改动,对应的 render 函数就会重新执行,实现 UI 刷新。

要实现结合,用到两个小技巧:聚合生命周期、替换 render 函数,用图才能解释清楚:

以上是简化版,正式版本使用 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,就像一面镜子一样映射:

通过 Debug UI,将 debug 信息与 UI 一一对应,实现 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 源码。

如希望详细了解依赖注入实现流程,请看 从零开始用 proxy 实现 mobx。

下一篇是 《框架使用》,会站在使用者的角度思考数据流。当然不是下一篇精读,因为要换换胃口,也给我一些缓冲时间去整理。

0 人点赞