什么是 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
由于我们知道 effect
和 watchEffect
都会首先执行一次来获取所运行函数中相关的依赖,那么可以得出结论:effect
执行了 2 次,watchEffect
只执行了 1 次
为什么 effect
多了 1 次呢?
这是因为对于 var3
来说,它有 2 个依赖 var1
和 var2
,这俩依赖中任意一个变化,effect
的回调都会重新执行一次,而 var2
也会因为 var1
的变化而更新。
但是我们来看一下这两次运行时 var1
和 var2
实际上的值:
var1
= 2,var2
= 2 (旧值) 这一次运行是 var1 刚更新触发的var1
= 2,var2
= 4 (新值) 这一次运行是 var2 被更新触发的
显然,这个 var2
还是旧值的情况是不符合预期的。这种中间态的过程,就被称为 “闪烁”(Glitching)
有没有想起写页面时某个元素受初始状态控制,状态会在开始时立刻更新一次,结果看到了闪烁?
但是我们看到如果使用 watchEffect
就没有这个问题了。
watchEffect
相比 effect
多做了些什么呢?
源码调试之旅#
调试前你需要准备...#
对优秀的库代码进行源码调试,最好的方式就是找到它的单元测试部分,然后安装好环境就可以愉快地进入测试场打断点玩耍了!
可以看到我打开了 Vue 仓库里 runtime-core
的 apiWatch.spec.ts
添加了一个单元测试。
左边的测试 Panel 是怎么拥有的?它竟然还支持根据测试 Case 的标题进行搜索!太棒了 Amazing!其实用的就是 Vitest 的官方插件 ZixuanChen.vitest-explorer
由于我们需要调试查看的是 var1.value = 2
这个 “值被更新” 之后的一系列副作用被触发的过程,因此需要先在相应位置打好断点。(如上图所示)
当你打好断点之后,可以在左侧这里点击 启动调试:
第 1 站・触发副作用#
当走入调试伊始,我们就看到它即将触发订阅了 var1
的副作用:
运行到这里,我们先刹个车停下来分析分析:
我们这里触发的是所有 “订阅了 var1
” 的副作用,即 var2
和 var3
这两个 computed 的计算函数。那我们要怎么确定这一轮 for 循环里的是哪一个 effect 呢?我们可以先看看 ReactiveEffect
的定义:
ReactiveEffect
有一个 public
的属性 fn
,这实际上就是相应的副作用函数,我们在调试控制台把 effect.fn
打印出来就可以看到这个函数的原始字符串,根据内容也就能判定属于谁了:
可以看到这是 var3
的计算函数,意味着我们现在正要触发这个计算函数重新执行,为 var3
更新其值。
分析差不多就先到这里了!🚄 继续开车!
正式走入 triggerEffect
触发时,由于我们触发的是 var3
这个 computed,而这里源码中写道:若一个副作用有 scheduler
则需要优先调用它。
走进去后我们发现:Computed 在创建时就为自己所绑定的副作用添加了这样一个 scheduler
:
这个调度器所做的是:标记当前计算属性已经 “脏了”😜,然后因为计算属性本身也是响应式变量,也可能被别的地方订阅,因此 “重新计算” 这个更新过程也会触发它的副作用。(见上图代码 47 行)
毫无疑问,我们的单元测试代码里,订阅了 var3
的地方只有 watchEffect
里的那个函数。那么我们继续跟随调度器执行 triggerRefValue
,会又重新走很多刚才讲过的步骤,因此不再赘述。
而一个关键的 “检查站” 就是 triggerEffects
的两个 for 循环那里,因为你可以在那时看到 数组 形式的副作用列表。
这幅图里信息比较多,我们总结一下:
- 调试源码时,一定要学会看左侧的函数调用栈帧。因为在很多复杂的库执行时,可能有部分逻辑会递归、重复走到,而此时需要知道自己在哪里、当前执行的是为了做什么,不要迷失方向。当前我们仍然在
var1.value = 2
这个 “set 过程触发副作用” 的同步任务上。 - 我们再次打印了
effect.fn
想确认是不是我们写在单测watchEffect
里的函数,结果发现是这么大一坨?这是什么?大致看一下,好像是被一个带错误处理的call...
给包装了一层。我们要带着这个疑问继续往下走。 - 走入了第 2 个 for 循环,意味着这次的
effect
不是 computed。其实也算侧面证明了它就是watchEffect
里那个函数。
第 2 站・了解 watchEffect#
进入 triggerEffect
我们再一次发现 这个副作用有 scheduler,走入调度器我们看到进入了一个叫 doWatch
的函数:
到这里我们要进行本趟旅程的第 2 次刹车啦!这个函数挺复杂的,看上去有很多逻辑,不过不用害怕。
🫵 阅读源码时,我们需要的是抓住主干。我在上图中折叠了对我们本次研究不重要的部分,截图直到当前调试正在执行的那一行,我们主要需要关注的就是 310 行开始的这个 job
。
在 345 - 347 这部分,源码的注释明确指明了 watchEffect
会走这条路径。
除此之外,为了控制图片长度,我还要特别展开上图的 230 行。因为 209 - 252 这一部分 if-else
语句所判断的 source 顾名思义就是 watch 的目标,也就是我们传入 watchEffect
的那个函数,因此走入的一定是 isFunction
这个分支:
展开后你会眼前一亮 👀🌟:
这个 getter
中的代码好像很眼熟?这不就是我们刚才打印 effect.fn
时不知道从何而来的那段代码吗!
在 doWatch
函数的 367 行(紧接着上述 “doWatch 函数纵览” 一图之下)创建了一个副作用,可以看到构造函数的两个参数分别是刚才的 getter
和调试执行到的、高亮出的这一行中创建的 scheduler
:
而 ReactiveEffect 的构造函数 2 个参数分别代表着:
- 副作用函数本身
- 对副作用的调度器
是不是到这里还是感觉一团糨糊?我们得到了这么多信息却好像却好像还是没法理解这和 watchEffect
有什么关系,反而可能你心里的疑问越来越多... 别急,深呼吸,让我们看看这个复杂的 doWatch
和 watchEffect
有什么关联:
原来 doWatch
就是对 watchEffect
的实现!只是在 apiWatch.ts
这个文件里,doWatch
不只用于这里,还有我们熟知的 watch
这个 API 也是用它实现,区分只是 watchEffect
不需要回调 cb
(见上图,第二个参数传的是 null
)
所以理一理其实不难得出结论:我们打印的那个 effect.fn
之所以不是我们传入函数本身,是因为在 doWatch
这里被包裹了一些额外的处理,疑问解决!我们可以继续启程了~🏄🏻♂️
第 3 站・柳暗花明#
现在我们大概能猜到,传入 watchEffect
的副作用函数似乎是 queueJob
推入了一个队列?那么我们走进去看看:
细读这段源码后,可以得出以下结论:若队列为空、或者队列不包含当前要添加的 job
,则推入队列。
然后我们继续看将要执行的 queueFlush()
:
这里有一堆不知道哪里定义出来的变量,还有上面的 queue
,往文件上面翻了翻,可以看到它们都是直接定义在顶层的:
为了研究过程更加专注核心目标,我们不用一个一个去探究清楚这每一个变量的作用都是什么,还是那句话,以主干为线索,不然就要脱轨啦 😱!
调试当前执行到的 105 行是个很有趣的操作,它将一个名为 flushJobs
的函数放到了一个 Promise.then
中。
熟悉事件循环原理的同学们应该能够明白,这是将这个函数放入了 微任务队列。在当前一个 Tick 中每个宏任务完成后,会开始执行微任务队列中的任务。如果你对这一部分知识还有疑问,可以先来这里补下基础:https://zh.javascript.info/event-loop。Vue 这样设计的目的可能是为了让监听对象所引发的副作用不会阻塞主渲染过程。
flushJobs
大家可以自行查阅 Vue 源码,其内容也没什么好说的,就是将队列里的函数顺序执行罢了,这里不再赘述。
到这里我们可以说:“var1
变化引发 var3
需要重新计算” 这个副作用已经被推入了队列,但在当前用户代码的同步过程中还没有执行。接下去我们看的应该就是 “var1
变化引发 var2
需要重新计算” 了。
之后的过程又重复了我们刚才所见过的步骤:Computed 被标记 “脏了”、触发自身副作用。var2
也被 var3
依赖,因此往下走又会触发到 var3
的 scheduler
:
但由于 var3
已经被标记过 “脏了”,所以没有再触发 var3
的相关副作用函数。然后继续执行你将会看到函数栈帧开始上退,即结束了 var1
更新为 2 所引发的这一轮副作用触发过程。
为了在测试模拟浏览器事件循环,即当前 Tick 宏任务结束到下一 Tick 宏任务这之间的过程(也就是为了执行微任务队列),Vue 的单测中使用了大量的 await nextTick()
,那我们这里也依葫芦画瓢:
走入微任务队列的执行时,可以看到函数栈帧是有一个 Promise.then
分隔开的,同时你也已经没有办法再去查看之前同步任务中各栈帧内的变量值:
此时我们的队列中只有一个 job
,因此加上最开始的一次,watchEffect
中的副作用一共只执行了 2 次而不是 3 次。
所以结论是:Vue 避免副作用响应造成值闪烁的方式是添加调度器,将副作用函数收集成一个队列,而执行放到微任务阶段。
Something more ...#
其实 “闪烁避免” 是 push-based 的响应式数据系统都会面临和需要解决的问题。如果你对这部分响应式数据系统更深入的知识有兴趣,这里有几篇文献供你参考:
- A Survey on Reactive Programming - https://blog.acolyer.org/2015/12/08/a-survey-on-reactive-programming
- 响应式编程的背压介绍 —— Pull 与 Push 模式的结合 - https://juejin.cn/post/7138241196744736799
计算属性的这种延迟的 Effect + Lazy Computed 时,就实现了 Glitch Avoidance。为什么两者结合就能实现 Glitch Avoidance 呢?
这是因为前端响应式框架中,只会存在 2 种对响应式数据的监听:
- Reactions,副作⽤执⾏。
- Derivations,衍⽣新的响应式数据 。
其中 Reactions 之间不能互相依赖,当 Reactions 延迟执⾏时,所有 Derivations 的都已被标记为 “需要更新”。重新计算 Derivations 值时,只需要沿着依赖树,先将上游的最新值计算完毕后,就总是能拿到最新上游值,实现 Glitch Avoidance。
计算属性的 lazy 执⾏,正是借鉴了 pull 模型,即可以理解为值是在最终需要时 “拉取” 下来的。
回到我们的例子中:实际上 var3 = 6
、var2 = 4
是在 watchEffect
的副作用函数第二次执行时因下图中的 self.effect.run()
才重新被计算的,我们来看 ComputedRefImpl 对于 get value()
的实现:
以上就是关于 Glitching Avoidance 的全部内容了。感谢你能看到这里,如果感觉有收获,不妨动动小手点个赞吧!