【Vue巨坑】当 watch 和 deep: true 相遇:一场由“`newVal === oldVal`”引发的血案

发布于 1 天前  18 次阅读


在 Vue 的世界里,watch 是我们监控数据变化的得力助手。但有时,这位助手会表现出一些令人费解的“灵异行为”。今天,我们就来剖析一个经典的“巨坑”:当 watchdeep: true 结合使用时,即使 newValoldVal 严格相等,也可能引发一场无限循环的风暴,直至浏览器崩溃。

案发现场

让我们先来看看这段“犯罪代码”,它看起来非常无辜:

<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 },一切又都恢复了正常。

这到底是为什么?newValoldVal 明明相等,回调为什么会被触发?又为何会陷入无限循环?

深入调查:三位“嫌疑人”

要破解此案,我们需要分别审问三位关键的“嫌疑人”: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: falsewatch 只关心函数的最终返回值。它会比较 newValue !== oldValue。在我们的例子中,"[]" !== "[]" 的结果是 false,所以回调函数不会被执行。天下太平。

  • deep: truewatch 的侦察范围扩大了!它不仅关心最终返回值,更会深入到函数内部,去侦听其依赖的所有响应式数据源的“一举一动”。

现在,让我们来采纳一下你的个人理解,因为它已经非常接近真相了:

开了 deep: true 后,感觉上就像是对 () => xxx 中的 xxx 进行了 watchEffect 自动收集响应式依赖,只要有响应式依赖改变,就会触发回调函数执行,即使最终箭头函数返回值相同。

这个理解非常精准!deep: true 赋予了 watch 一种类似 watchEffect 的“穿透”能力。它的触发逻辑变成了:

“只要我的源依赖(val3.value)发生了任何形式的深层变化,我就必须执行回调,哪怕我最终的计算结果("[]")和上次一样!”

在本案中,val3.value 的引用地址被替换了,这在 deep 模式下被视为一次“深层变化”。因此,watch 毅然决然地执行了回调,完全无视了 newValoldVal 严格相等的事实。

最终谜题:无限循环的成因

好了,我们已经解释了回调为何会被触发一次。但为何是无限次呢?

这暴露了 Vue 内部调度的一个微妙的边缘情况。当一个 deep 侦听器的回调因为其“深层依赖”的变化而被强制触发时,这个执行过程本身可能会让 Vue 的调度器认为“侦听器刚刚活动过,可能还有些事情没处理完”,从而错误地将它再次放回微任务队列中等待下一次执行。

于是,一个致命的循环诞生了:

  1. setInterval 替换了 val3.value
  2. deep: true 检测到深层依赖变化,强制执行回调。
  3. 回调的执行行为,让调度器再次将该 watch 任务加入队列
  4. 进入下个 tick,watch 任务被取出并执行,回到第 2 步。
  5. 循环往复,直到栈溢出。

如何逃离“巨坑”:解决方案

理解了原理后,我们就能轻松地化解这个危机。

方案一:对症下药,移除 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没有响应性,没有被收集作为响应式依赖)


A Student on the way to full stack of Web3.