前言
为什么会有这一篇,其实是上一篇的内容有误。
上一篇,因为一个实际的问题,我发现 watchEffect 和 watch 侦听数据的不同,但是为了研究清楚为什么不同,我花费了很长时间。同时也发现,Vue 的 watch 用法相当复杂,光是看官网的 深度侦听器 就让人迷惑。再加上 watchEffect 的不同。问了 ChatGPT 很久、也看了源码,最终才真正明白。上一篇,后面部分的 watch 和 watchEffect 的区别对比来自 ChatGPT,但实际上不对,下面我再详细说明。
问题回顾
看下面一段代码:
const obj = reactive({ nested: { foo: 1 } });
watch(
() => obj.nested.foo,
(newValue, oldValue) => {
console.log("watch 触发 newValue, oldValue: ", newValue, oldValue);
}
);
watchEffect(() => {
console.log("watchEffect 触发: ", obj.nested.foo);
});
// obj.nested.foo++
obj.nested = {
foo: 1,
};
我们知道 obj.nested.foo++
,改变 foo 会触发 watch 回调的执行、以及 watchEffect 的执行,这没有什么问题。但是改变 obj.nested
,不能让 watch 执行,能让 watchEffect 执行,这是为什么?这在上一篇基本上解释清楚了,在 watch 的实现中,在 getter 的赋值部分,对于 watchEffect 是 source() 的包装。在构造 new ReactiveEffect(getter)
时,getter 会执行,触发 watchEffect 回调的执行,进行依赖收集,在 Vue 的依赖收集中使用 proxy get 拦截,对于读取到的任何属性,都会进行拦截、依赖收集。所以 obj.nested.foo
的执行,必然会将 obj.nested
作为依赖与当前副作用关联。
那么最后的问题就是:为什么 watch 的 source 执行、属性的读取,只会将最后一层的属性收集为依赖,也就是 为什么 obj.nested 不是当前 callback 的依赖,obj.nested 的变化为什么不会触发 watch callback 的执行?
解释质疑
watch
接受一个显式getter
,即() => detail.isFavorite
。当getter()
被执行时:仅执行detail.isFavorite
,这是一个属性访问操作。首先查找对象detail
,但这只是一个对象引用。
只收集显式
getter
中访问的属性。执行() => detail.isFavorite
时,仅执行了对detail.isFavorite
的属性访问,而没有读取整个对象detail
。
上一篇关于 watch 的这些说明,完全没有道理。
什么叫显式?这在你完全搞懂所有细节之前完全是一个模糊的概念。
读取 detail.isFavorite,读取了 detail,但又说这只是一个对象引用,js 对象都是引用。到底有没有读取 detail?
同样是读取 detail.isFavorite,为什么 watch 可以说没有读取 detail,watchEffect 却说读取了?
再读源码
我们还是先看一下 watch 的实现:
代码我就不贴了,看这里:github.com/vuejs/core/…
这个相当重要,可以好好研究。
getter 赋值
-
当 source 是 ref 时,
getter = () => source.value
。 -
当 source 是 reactive 时,
getter = () => reactiveGetter(source)
reactiveGetter(source) 分几种情况:- deep = true,返回
source
; - deep = false | deep = 0,
traverse(source, 1)
; - deep = undefined,
traverse(source)
;
实际上都是
traverse(source)
。关于 deep = true 的情况后面说。deep = false | 0 是不要递归监听。deep 没设置默认递归监听。执行之后,
getter = () => traverse(source)
。 - deep = true,返回
-
当 source 是数组时,其实是其他几种情况的集合。
-
当 source 是函数时,有两种情况:
- 有 cb 时,也就是 watch 调用时,
getter = source
getter = source // 相当于 getter = () => obj.nested.foo
- 无 cb,也就是 watchEffect 调用
getter = () => { activeWatcher = effect try { return source(boundCleanup) } finally { activeWatcher = currentEffect } } // 相当于 getter = () = > { try { return source() } }
- 有 cb 时,也就是 watch 调用时,
然后,对于有 cb 以及深度侦听:
if (cb && deep) {
const baseGetter = getter
const depth = deep === true ? Infinity : deep
getter = () => traverse(baseGetter(), depth)
}
getter 会进行 traverse 包装,会进行递归遍历,这是处理以上所有 watch 调用时深度监听的情况,包含 source 是 reactive、function 等。所以上面 source 是 reactive、deep 为 true 时,getter 直接赋值 source,因为后面这里会进行统一处理。
深度侦听
watch 的依赖追踪规则还是挺复杂的,虽然官网这么说:
但是,为什么是这样,要理解原理就要花费一些心思。比如为什么说对于 reactive 对象,默认隐式深度监听,而 getter 又不是?
从源码中就能理解了,是否深度监听依赖于 deep 配置,source 最终都会转为 getter 函数,根据是否 deep,进行 traverse 递归触发深层依赖收集。
想象用户使用的场景:对于传入一个 getter 函数的情况,理应当作只侦听那一个数据源;对于传入一个 reactive 数据,默认应该递归侦听它的所有属性,因为传入一个对象,
但是一个 getter 函数也是返回一个对象,为什么不默认是递归侦听它的所有属性?或者传入 reactive 对象时,为什么不也只侦听对象本身?因为此时是无法侦听的,我们侦听 reactive 对象,就是侦听它的属性的变化。
一个默认是 true,一个默认是 false,我只能说虽然符合直觉,还是有点点割裂。
设计意图
watch 就是设计成精准侦听数据源。getter 函数作为 source 的作用就是,返回一个值,只侦听最后一层那个属性。
那问题是,source 的执行一定会读取属性,又回到原来的问题,obj.nested 不是会被读取吗?不是会被收集依赖吗?这不是和 watch 精准侦听相矛盾吗?
因为我一直关注:
- 为什么 watchEffect 中属性的读取能收集为依赖,而 watch 不会,是怎么做到的?
- watch 中属性的读取到底会不会触发依赖收集?
无论如何,watch source 的执行一定会触发依赖收集。
和 watchEffect 的不同,watch 的目的是数据源变化,执行回调函数。前面陷入了误区,潜意识认为被收集了依赖就一定会触发回调的执行。这里的关键是被收集为依赖并没有关系,关键在于 cb 的执行。那么 cb 是什么时候执行呢?再回到 watch 的根本用途,只有 source 新旧值不同时,才会导致 cb 的执行。
job 实现
getter() 的返回值主导回调触发。watch 回调的执行依赖 job 函数,getter 的执行有一个返回值,const newValue = effect.run()
的返回值就是 obj.nested.foo
,当它们新旧值变化时,才会触发 cb 的执行。
const newValue = effect.run()
if (hasChanged(newValue, oldValue)) {
cb()
oldValue = newValue
}
一点总结
所以我一开始纠结在 obj.nested.foo
的执行,一定读取了 obj.nested
属性啊,为什么 obj.nested
的改变(虽然 obj.nested.foo
没变)不会引起 cb
的执行?
obj.nested = {foo: 1}
是新对象的赋值,那么 obj.nested
就被改变了,如果被收集了依赖,那么就会触发 cb
的执行。所以一开始想为什么 obj.nested
没有被收集依赖。(因为收集了依赖,那么依赖改变应该触发回调的执行)
但是却忽略了 watch cb 的执行条件是什么?所以还是要回到 watch 的用途和实现原理,watch cb 的执行,虽然也是像 watchEffect 一样,source 源变化才会执行,但 watch 对变化的比较是更精确的控制,也就是 cb 的执行是有条件的,不是被收集的依赖变化了就会执行,而是 watch 监听的 getter(watch 侦听的所有 source 形式最终都会变成 getter 函数形式)返回值变化了才会执行。
一个小 bug
发现一个小小的 bug:
const obj = reactive({ nested: { foo: 1 } });
watch(
() => obj.nested.foo,
(newValue, oldValue) => {
console.log("watch triggered newValue, oldValue: ", newValue, oldValue);
}
);
watch(
() => obj.nested.foo,
(newValue, oldValue) => {
console.log("watch deep triggered newValue, oldValue: ", newValue, oldValue);
},
{ deep: true }
);
obj.nested = {
foo: 1,
};
watch 不会执行,watch deep 会执行。但是 source getter 的返回值并没有变化。按照上面的理解,返回值没有变化不应该触发 cb 的执行。
这个问题的原因仍然是这里:
if (cb && deep) {
const baseGetter = getter
const depth = deep === true ? Infinity : deep
getter = () => traverse(baseGetter(), depth)
}
有 cb 和 deep 时,baseGetter 的执行触发了所有层级的依赖收集。
至少和 getter 的理解是冲突的。