banner
沈青川

旧巷馆子

愿我如长风,渡君行万里。
twitter
jike

❗多图预警 · Vue 是怎么解决 Glitching avoidance 的?

什么是 Glitching avoidance?#

当今已经有很多基于 Reactivity 机制监听和处理数据变化的 Web 前端库了,在响应式系统里会有一个需要注意的问题,话不多说,我们看一下以下这段代码:

const var1 = ref(1)
const var2 = computed(() => var1.value * 2)
const var3 = computed(() => var1.value + var2.value)

// 分别用两个不同的 effect 函数执行,查看控制台输出
effect(() => {
  console.log('@vue/reactiviy effect: var3 =', var3.value)
})
watchEffect(() => {
  console.log('@vue/runtime-core watchEffect: var3 = ', var3.value)
})

// 然后我们更改一下 var1
var1.value = 2

我给上面这段代码做了一个在线的 Playground:

https://stackblitz.com/edit/typescript-y4kx5e?file=index.ts

我们看一下输出:

@vue/reactiviy effect: var3 = 3
@vue/reactiviy effect: var3 = 4
@vue/reactiviy effect: var3 = 6

@vue/runtime-core watchEffect: var3 = 3
@vue/runtime-core watchEffect: var3 = 6

由于我们知道 effectwatchEffect 都会首先执行一次来获取所运行函数中相关的依赖,那么可以得出结论:effect 执行了 2 次,watchEffect 只执行了 1 次

为什么 effect 多了 1 次呢?

这是因为对于 var3 来说,它有 2 个依赖 var1var2 ,这俩依赖中任意一个变化,effect 的回调都会重新执行一次,而 var2 也会因为 var1 的变化而更新。

但是我们来看一下这两次运行时 var1var2 实际上的值:

  • var1 = 2, var2 = 2 (旧值) 这一次运行是 var1 刚更新触发的
  • var1 = 2, var2 = 4 (新值) 这一次运行是 var2 被更新触发的

显然,这个 var2 还是旧值的情况是不符合预期的。这种中间态的过程,就被称为 “闪烁”(Glitching)

有没有想起写页面时某个元素受初始状态控制,状态会在开始时立刻更新一次,结果看到了闪烁?

但是我们看到如果使用 watchEffect 就没有这个问题了。

watchEffect 相比 effect 多做了些什么呢?

源码调试之旅#

调试前你需要准备...#

对优秀的库代码进行源码调试,最好的方式就是找到它的单元测试部分,然后安装好环境就可以愉快地进入测试场打断点玩耍了!

可以看到我打开了 Vue 仓库里 runtime-coreapiWatch.spec.ts 添加了一个单元测试。

左边的测试 Panel 是怎么拥有的?它竟然还支持根据测试 Case 的标题进行搜索!太棒了 Amazing!其实用的就是 Vitest 的官方插件 ZixuanChen.vitest-explorer

image

由于我们需要调试查看的是 var1.value = 2 这个 “值被更新” 之后的一系列副作用被触发的过程,因此需要先在相应位置打好断点。(如上图所示)

当你打好断点之后,可以在左侧这里点击 启动调试

image

第 1 站・触发副作用#

当走入调试伊始,我们就看到它即将触发订阅了 var1 的副作用:

图 1 :对 Ref 的值更新被 set 拦截,去触发依赖该 ref 的副作用

图 2 :从 ref 所带的数据中读出依赖 dep,去触发这些依赖

图 3 :最终开始触发副作用的位置

运行到这里,我们先刹个车停下来分析分析:

我们这里触发的是所有 “订阅了 var1” 的副作用,即 var2var3 这两个 computed 的计算函数。那我们要怎么确定这一轮 for 循环里的是哪一个 effect 呢?我们可以先看看 ReactiveEffect 的定义:

image

ReactiveEffect 有一个 public 的属性 fn,这实际上就是相应的副作用函数,我们在调试控制台把 effect.fn 打印出来就可以看到这个函数的原始字符串,根据内容也就能判定属于谁了:

image

可以看到这是 var3 的计算函数,意味着我们现在正要触发这个计算函数重新执行,为 var3 更新其值。

分析差不多就先到这里了!🚄 继续开车!

image

正式走入 triggerEffect 触发时,由于我们触发的是 var3 这个 computed,而这里源码中写道:若一个副作用有 scheduler 则需要优先调用它。

走进去后我们发现:Computed 在创建时就为自己所绑定的副作用添加了这样一个 scheduler

image

这个调度器所做的是:标记当前计算属性已经 “脏了”😜,然后因为计算属性本身也是响应式变量,也可能被别的地方订阅,因此 “重新计算” 这个更新过程也会触发它的副作用。(见上图代码 47 行)

毫无疑问,我们的单元测试代码里,订阅了 var3 的地方只有 watchEffect 里的那个函数。那么我们继续跟随调度器执行 triggerRefValue,会又重新走很多刚才讲过的步骤,因此不再赘述。

而一个关键的 “检查站” 就是 triggerEffects 的两个 for 循环那里,因为你可以在那时看到 数组 形式的副作用列表。

image

这幅图里信息比较多,我们总结一下:

  • 调试源码时,一定要学会看左侧的函数调用栈帧。因为在很多复杂的库执行时,可能有部分逻辑会递归、重复走到,而此时需要知道自己在哪里、当前执行的是为了做什么,不要迷失方向。当前我们仍然在 var1.value = 2 这个 “set 过程触发副作用” 的同步任务上。
  • 我们再次打印了 effect.fn 想确认是不是我们写在单测 watchEffect 里的函数,结果发现是这么大一坨?这是什么?大致看一下,好像是被一个带错误处理的 call... 给包装了一层。我们要带着这个疑问继续往下走。
  • 走入了第 2 个 for 循环,意味着这次的 effect 不是 computed。其实也算侧面证明了它就是 watchEffect 里那个函数。

第 2 站・了解 watchEffect#

进入 triggerEffect 我们再一次发现 这个副作用有 scheduler,走入调度器我们看到进入了一个叫 doWatch 的函数:

doWatch 函数纵览

到这里我们要进行本趟旅程的第 2 次刹车啦!这个函数挺复杂的,看上去有很多逻辑,不过不用害怕。

🫵 阅读源码时,我们需要的是抓住主干。我在上图中折叠了对我们本次研究不重要的部分,截图直到当前调试正在执行的那一行,我们主要需要关注的就是 310 行开始的这个 job

在 345 - 347 这部分,源码的注释明确指明了 watchEffect 会走这条路径。

除此之外,为了控制图片长度,我还要特别展开上图的 230 行。因为 209 - 252 这一部分 if-else 语句所判断的 source 顾名思义就是 watch 的目标,也就是我们传入 watchEffect 的那个函数,因此走入的一定是 isFunction 这个分支:

展开后你会眼前一亮 👀🌟:

image

这个 getter 中的代码好像很眼熟?这不就是我们刚才打印 effect.fn 时不知道从何而来的那段代码吗!

doWatch 函数的 367 行(紧接着上述 “doWatch 函数纵览” 一图之下)创建了一个副作用,可以看到构造函数的两个参数分别是刚才的 getter 和调试执行到的、高亮出的这一行中创建的 scheduler

image

而 ReactiveEffect 的构造函数 2 个参数分别代表着:

  1. 副作用函数本身
  2. 对副作用的调度器

是不是到这里还是感觉一团糨糊?我们得到了这么多信息却好像却好像还是没法理解这和 watchEffect 有什么关系,反而可能你心里的疑问越来越多... 别急,深呼吸,让我们看看这个复杂的 doWatchwatchEffect 有什么关联:

image

原来 doWatch 就是对 watchEffect 的实现!只是在 apiWatch.ts 这个文件里,doWatch 不只用于这里,还有我们熟知的 watch 这个 API 也是用它实现,区分只是 watchEffect 不需要回调 cb (见上图,第二个参数传的是 null

所以理一理其实不难得出结论:我们打印的那个 effect.fn 之所以不是我们传入函数本身,是因为在 doWatch 这里被包裹了一些额外的处理,疑问解决!我们可以继续启程了~🏄🏻‍♂️

第 3 站・柳暗花明#

现在我们大概能猜到,传入 watchEffect 的副作用函数似乎是 queueJob 推入了一个队列?那么我们走进去看看:

image

细读这段源码后,可以得出以下结论:若队列为空、或者队列不包含当前要添加的 job,则推入队列。

然后我们继续看将要执行的 queueFlush()

image

这里有一堆不知道哪里定义出来的变量,还有上面的 queue ,往文件上面翻了翻,可以看到它们都是直接定义在顶层的:

image

为了研究过程更加专注核心目标,我们不用一个一个去探究清楚这每一个变量的作用都是什么,还是那句话,以主干为线索,不然就要脱轨啦 😱!

调试当前执行到的 105 行是个很有趣的操作,它将一个名为 flushJobs 的函数放到了一个 Promise.then 中。

熟悉事件循环原理的同学们应该能够明白,这是将这个函数放入了 微任务队列。在当前一个 Tick 中每个宏任务完成后,会开始执行微任务队列中的任务。如果你对这一部分知识还有疑问,可以先来这里补下基础:https://zh.javascript.info/event-loop。Vue 这样设计的目的可能是为了让监听对象所引发的副作用不会阻塞主渲染过程。

flushJobs 大家可以自行查阅 Vue 源码,其内容也没什么好说的,就是将队列里的函数顺序执行罢了,这里不再赘述。

到这里我们可以说:“var1 变化引发 var3 需要重新计算” 这个副作用已经被推入了队列,但在当前用户代码的同步过程中还没有执行。接下去我们看的应该就是 “var1 变化引发 var2 需要重新计算” 了。

第 2 个 effect:var1 变化引发 var2 需要重新计算

之后的过程又重复了我们刚才所见过的步骤:Computed 被标记 “脏了”、触发自身副作用。var2 也被 var3 依赖,因此往下走又会触发到 var3scheduler

image

但由于 var3 已经被标记过 “脏了”,所以没有再触发 var3 的相关副作用函数。然后继续执行你将会看到函数栈帧开始上退,即结束了 var1 更新为 2 所引发的这一轮副作用触发过程。

为了在测试模拟浏览器事件循环,即当前 Tick 宏任务结束到下一 Tick 宏任务这之间的过程(也就是为了执行微任务队列),Vue 的单测中使用了大量的 await nextTick() ,那我们这里也依葫芦画瓢:

image

走入微任务队列的执行时,可以看到函数栈帧是有一个 Promise.then 分隔开的,同时你也已经没有办法再去查看之前同步任务中各栈帧内的变量值:

image

此时我们的队列中只有一个 job,因此加上最开始的一次,watchEffect 中的副作用一共只执行了 2 次而不是 3 次。

所以结论是:Vue 避免副作用响应造成值闪烁的方式是添加调度器,将副作用函数收集成一个队列,而执行放到微任务阶段。

Something more ...#

其实 “闪烁避免” 是 push-based 的响应式数据系统都会面临和需要解决的问题。如果你对这部分响应式数据系统更深入的知识有兴趣,这里有几篇文献供你参考:

计算属性的这种延迟的 Effect + Lazy Computed 时,就实现了 Glitch Avoidance。为什么两者结合就能实现 Glitch Avoidance 呢?

这是因为前端响应式框架中,只会存在 2 种对响应式数据的监听:

  1. Reactions,副作⽤执⾏。
  2. Derivations,衍⽣新的响应式数据 。

其中 Reactions 之间不能互相依赖,当 Reactions 延迟执⾏时,所有 Derivations 的都已被标记为 “需要更新”。重新计算 Derivations 值时,只需要沿着依赖树,先将上游的最新值计算完毕后,就总是能拿到最新上游值,实现 Glitch Avoidance。

计算属性的 lazy 执⾏,正是借鉴了 pull 模型,即可以理解为值是在最终需要时 “拉取” 下来的。

回到我们的例子中:实际上 var3 = 6var2 = 4 是在 watchEffect 的副作用函数第二次执行时因下图中的 self.effect.run() 才重新被计算的,我们来看 ComputedRefImpl 对于 get value() 的实现:

image

以上就是关于 Glitching Avoidance 的全部内容了。感谢你能看到这里,如果感觉有收获,不妨动动小手点个赞吧!

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.