在 Vue 的世界里,watch
是我们监控数据变化的得力助手。但有时,这位助手会表现出一些令人费解的“灵异行为”。今天,我们就来剖析一个经典的“巨坑”:当 watch
与 deep: true
结合使用时,即使 newVal
和 oldVal
严格相等,也可能引发一场无限循环的风暴,直至浏览器崩溃。
案发现场
让我们先来看看这段“犯罪代码”,它看起来非常无辜:
<script setup lang="ts">
import { ref, watch } from "vue";
// 一个响应式的空数组
const val3 = ref([]);
// 每隔500毫秒,用一个新的空数组替换它
setInterval(() => {
val3.value = [];
}, 500);
// 侦听这个数组的 stringify 之后的值
watch(
() => JSON.stringify(val3.value),
(newVal, oldVal) => {
// 打印新旧值,以及它们的比较结果
console.info(">>> newVal: ", newVal, "oldVal: ", oldVal, newVal === oldVal);
},
{
deep: true, // 深度侦听
immediate: true, // 立即执行
}
);
</script>
诡异现象:
当你运行这段代码,打开控制台,你会看到 console.info
被疯狂打印,每一条都显示 newVal: "[]"
, oldVal: "[]"
, true
。很快,浏览器就会因为调用栈溢出(Maximum call stack size exceeded)而崩溃。
然而,如果你把 { deep: true }
改为 { deep: false }
,一切又都恢复了正常。
这到底是为什么?newVal
和 oldVal
明明相等,回调为什么会被触发?又为何会陷入无限循环?
深入调查:三位“嫌疑人”
要破解此案,我们需要分别审问三位关键的“嫌疑人”:setInterval
中的赋值操作、作为侦听源的箭头函数,以及最关键的 deep: true
选项。
1. val3.value = []
:这不是清空,是替换!
这是本案的导火索。val3.value = []
并非在原地修改数组,而是创建了一个全新的、内存地址不同的空数组,然后将 val3
这个 ref
的 .value
指向了这个新数组。
每次 setInterval
执行,都会发生一次引用替换。这是 Vue 响应式系统能够侦测到变化的前提。
2. () => JSON.stringify(val3.value)
:派生值的“不在场证明”
我们的侦听源是一个箭头函数,它返回的是 val3.value
经过 JSON.stringify
后的派生值——一个字符串。无论 val3.value
指向哪个空数组,这个函数的返回值始终是字符串 "[]"
。
从表面上看,这个返回值恒定不变,似乎有着完美的“不在场证明”。
3. deep: true
:揭开真相的“幕后黑手”
现在,轮到我们的主角 deep: true
登场了。
当 watch
的侦听源是一个函数时,deep: false
(默认行为)和 deep: true
的工作模式有着天壤之别:
-
deep: false
:watch
只关心函数的最终返回值。它会比较newValue !== oldValue
。在我们的例子中,"[]" !== "[]"
的结果是false
,所以回调函数不会被执行。天下太平。 -
deep: true
:watch
的侦察范围扩大了!它不仅关心最终返回值,更会深入到函数内部,去侦听其依赖的所有响应式数据源的“一举一动”。
现在,让我们来采纳一下你的个人理解,因为它已经非常接近真相了:
开了
deep: true
后,感觉上就像是对() => xxx
中的xxx
进行了watchEffect
自动收集响应式依赖,只要有响应式依赖改变,就会触发回调函数执行,即使最终箭头函数返回值相同。
这个理解非常精准!deep: true
赋予了 watch
一种类似 watchEffect
的“穿透”能力。它的触发逻辑变成了:
“只要我的源依赖(val3.value
)发生了任何形式的深层变化,我就必须执行回调,哪怕我最终的计算结果("[]"
)和上次一样!”
在本案中,val3.value
的引用地址被替换了,这在 deep
模式下被视为一次“深层变化”。因此,watch
毅然决然地执行了回调,完全无视了 newVal
和 oldVal
严格相等的事实。
最终谜题:无限循环的成因
好了,我们已经解释了回调为何会被触发一次。但为何是无限次呢?
这暴露了 Vue 内部调度的一个微妙的边缘情况。当一个 deep
侦听器的回调因为其“深层依赖”的变化而被强制触发时,这个执行过程本身可能会让 Vue 的调度器认为“侦听器刚刚活动过,可能还有些事情没处理完”,从而错误地将它再次放回微任务队列中等待下一次执行。
于是,一个致命的循环诞生了:
setInterval
替换了val3.value
。deep: true
检测到深层依赖变化,强制执行回调。- 回调的执行行为,让调度器再次将该
watch
任务加入队列。 - 进入下个 tick,
watch
任务被取出并执行,回到第 2 步。 - 循环往复,直到栈溢出。
如何逃离“巨坑”:解决方案
理解了原理后,我们就能轻松地化解这个危机。
方案一:对症下药,移除 deep: true
最简单的办法。问问自己:我真的需要深度侦听吗?如果我只关心 JSON.stringify
后的值是否变化,那么 deep: false
就足够了。
方案二:改变习惯,原地修改而非替换(推荐)
这是更优雅、性能也更好的做法。与其创建一个新数组,不如在原地清空它,保持引用不变。
setInterval(() => {
// val3.value = []; // 替换,引用改变 (Bad)
val3.value.length = 0; // 原地清空,引用不变 (Good)
}, 500);
这样一来,val3.value
的引用从未改变,watch
的源依赖也就稳定了,问题迎刃而解。
方案三:引入“中间人”,使用 computed
我们可以创建一个 computed
属性来做“缓冲”。computed
是惰性的,并且会缓存结果。只有当它的计算结果真正发生变化时,它才会更新。
import { ref, watch, computed } from "vue";
const val3 = ref([]);
const stringifiedVal = computed(() => JSON.stringify(val3.value));
setInterval(() => {
val3.value = [];
}, 500);
// 现在侦听这个 computed ref
watch(
stringifiedVal,
(newVal, oldVal) => {
// stringifiedVal 的值会稳定在 "[]",所以这里不会再触发
console.info(">>> newVal: ", newVal, "oldVal: ", oldVal);
},
{ immediate: true } // deep 在这里已无用武之地
);
案件总结
这次的“巨坑”探险让我们学到了宝贵的一课:
watch
+ getter 函数
+ deep: true
是一个非常强大的组合,但它改变了 watch
的核心触发逻辑。它不再仅仅是一个“结果观察者”,更像一个深入依赖内部的“过程探员”。
当你使用这个组合时,请务必警惕那些会被替换而不是修改的源数据(如数组和对象),否则,你可能会在不经意间,开启一扇通往“栈溢出地狱”的大门。
个人理解
开了deep: true后,感觉上就像是对() => xxx中的xxx进行了watchEffect自动收集响应式依赖,只要有响应式依赖改变,就会触发回调函数执行,及时最终箭头函数返回值相同。
一般来说箭头函数返回一个JSON.stringify包裹起来的对象,就不需要开deep了,否则会有反直觉的现象。开deep的时候一般用来监听refA.subObjectB这样的响应式对象的属性(子对象)。
一个容易误解的场景:
<script setup>
import { watch } from 'vue';
import { ref } from 'vue'
const msg = ref('Hello World!')
const val = ref({
a: {b: []}
})
const a = {
b: 0
}
watch(() => a, () => {
console.log('---------',val.value.a.b);
}, {immediate: true, deep: true})
setInterval(() => {
a.b = a.b++
}, 500)
</script>
<template>
<h1>{{ msg }}</h1>
<input v-model="msg" />
</template>
上面这段代码只会输出一个log,是immediate导致的一次执行,并不能监听到a.b的变化。(因为a不是一个Vue3的响应式Proxy,a.b没有响应性,没有被收集作为响应式依赖)
Comments NOTHING