在 Vue 3 的 Composition API 中,watch 是我们处理异步操作、数据联动时最常打交道的 API 之一。然而,很多开发者在实际使用中经常碰到一些让人困惑的现象:
-
为什么直接传
person.name监听不到变化? -
为什么监听
reactive对象时,oldValue和newValue打印出来是一模一样的? -
为什么有时候配置
{ deep: false }完全不起作用,有时候却又必须配置{ deep: true }?
本文将从 语法用法 渐进到 JS 内存机制(栈与堆) ,为你彻底厘清 Vue 3 watch 的工作原理。
一、 Vue 3 watch 的 5 种典型使用场景
Vue 3 的 watch 能够接收多种类型的响应式数据源,常见使用场景可以归纳为以下几种,这里我们主要讨论情况三四五。
JavaScript
javascript
import { ref, reactive, watch } from 'vue'
const sum = ref(0)
const msg = ref('Hello')
const person = reactive({
name: '张三',
age: 18,
job: { title: '前端', salary: 20000 }
})
// 情况一:监视单个 ref
watch(sum, (newValue, oldValue) => {
console.log('sum变了', newValue, oldValue)
})
// 情况二:监视多个 ref
watch([sum, msg], (newValue, oldValue) => {
console.log('sum或msg变了', newValue, oldValue) // newValue 为数组 [newSum, newMsg]
})
// 情况三:监视 reactive 定义的完整对象
watch(person, (newValue, oldValue) => {
console.log('person属性变了', newValue, oldValue)
})
// 情况四:监视 reactive 对象中的基本类型属性(使用 Getter 函数)
watch(() => person.name, (newValue, oldValue) => {
console.log('person.name变了', newValue, oldValue)
})
// 情况五:监视 reactive 对象中的对象类型属性
watch(() => person.job, (newValue, oldValue) => {
console.log('person.job变了', newValue, oldValue)
}, { deep: true })
二、 核心坑点 1:情况四为什么不能直接写 person.name?
初学者最容易犯的一个错误是写成这样:
JavaScript
javascript
// ❌ 错误示范:这样写监听是完全失效的!
watch(person.name, (newValue, oldValue) => {
console.log(newValue, oldValue)
})
解释:JavaScript 的参数传递机制
当代码执行到 watch(person.name, ...) 时,JavaScript 在将参数传给 watch 函数之前,会先计算表达式的值:
-
person.name被求值,得到具体的字符串'张三'。 -
实际传给
watch的代码等同于:watch('张三', callback)。 -
'张三'只是一个普通的静态字符串(基本数据类型),它既没有 Proxy 代理,也没有响应式能力 。Vue 根本无法得知这个'张三'来自哪个对象的哪个属性,依赖收集(Track)直接失败。
解决方案:使用 Getter 函数 () => person.name
当你传入一个函数 () => person.name 时:
-
传给
watch的是一个可执行的函数。 -
Vue 在初始化
watch时会主动执行一次这个函数。 -
在执行
person.name的瞬间,触发了person(Proxy 对象)的getter拦截器。 -
Vue 成功感知到了依赖关系:"原来这个
watch依赖了person对象的name属性!"
三、 核心坑点 2:深度监视 deep 的"双重标准"
在监听 reactive 响应式对象时,deep 参数的行为非常特殊,需要区分以下两种情况:
1. 直接监听整个 reactive 对象,比如情况三
JavaScript
javascript
watch(person, callback, { deep: false }) // 这里的 deep: false 是无效的!
-
行为 :Vue 默认对其强制开启深度监视。
-
现象 :无论修改
person.name还是修改深层的person.job.salary,都会触发监听。即使你显式设置{ deep: false },Vue 也会忽略该配置。
2. 通过 Getter 函数监听对象的某个"对象属性",比如情况五
JavaScript
javascript
watch(() => person.job, callback, { deep: true }) // 这里的 deep: true 是必须的!
-
行为 :Getter 函数
() => person.job返回的是job对象的内存地址。 -
现象:
-
如果修改内部属性 (如
person.job.salary = 25000),job的内存地址并没有变。默认情况下,Vue 认为 Getter 返回值没变,不会触发回调! -
解决 :必须手动加上配置
{ deep: true },强制 Vue 深入递归遍历job内部的每个属性。 -
那刚刚不是说deep:true是默认开启的吗?现在怎么又要打开?原因如下:
-
当你直接把
reactive对象传给watch时:JavaScript
javascript// 写法 1:直接传 reactive 对象 watch(person, (newValue, oldValue) => { ... }) -
Vue 的处理逻辑 :Vue 识别出你传进来的是一个由
reactive创建的 Proxy 代理对象。 Vue 认为:"既然你把一整个响应式大对象都给我了,那你肯定是想关心里面任何属性的变化。" -
结果 :Vue 在底层强制开启了
deep: true。
2. 为什么写 Getter 函数
watch(() => person.job)就默认不开启了?当你使用箭头函数返回某个属性时:
JavaScript
*javascript// 写法 2:传 Getter 函数 watch(() => person.job, (newValue, oldValue) => { ... })-
Vue 的处理逻辑 : Vue 此时只把你传进来的
() => person.job当作一个普通函数 。Vue 在初始化时执行了一次这个函数,拿到返回值------也就是job对象的内存地址(引用)。 -
Vue 的比对机制 :默认情况下,Vue 只比对这个函数的返回值(即内存地址)是否发生了变化。
-
当你执行
person.job = { title: 'CEO' }时 :整个job对象被换掉了,内存地址改变,Getter 的返回值变了 \\rightarrow 触发监听。 -
当你执行
person.job.salary = 30000时 :你只是修改了job内部的属性,job对象的内存地址根本没变 。Getter 函数返回的依然是原来的那个内存地址 \\rightarrow Vue 认为"返回值没变",不触发监听。
-
-
四、 底层解密:为什么 newValue 和 oldValue 会一模一样?
在监听 reactive 对象(或开启 deep: true 的对象属性)时,你会发现控制台打印的 newValue 和 oldValue 完全相同,都是修改后的最新值:
JavaScript
javascript
person.age = 19
// 控制台输出:
// person变化了 { name: '张三', age: 19 } { name: '张三', age: 19 }
要理解这个现象,我们需要深入 JavaScript 的内存分区机制(Stack 与 Heap)。
1. 内存分区:栈(Stack)与堆(Heap)
JavaScript 的内存主要分为两个区域:
-
栈内存(Stack) :空间小、读取快,存放基本数据类型 以及引用对象的内存地址指针。
-
堆内存(Heap) :空间大,存放引用数据类型 (如
Object、Array)的实际内容。
当你定义 const person = reactive({ name: '张三', age: 18 }) 时,内存布局如下:
Plaintext
【栈内存 (Stack)】 【堆内存 (Heap)】
+-----------------------+ +------------------------+
| 变量名 | 内存地址 | | 内存地址: 0x001122 |
+----------+------------+ +------------------------+
| person | 0x001122 --|---------->| { |
+-----------------------+ | name: '张三', |
| age: 18 |
| } |
+------------------------+
2. 修改属性时的内存变化
当执行 person.age = 19 时:
Plaintext
【栈内存 (Stack)】 【堆内存 (Heap)】
+-----------------------+ +------------------------+
| 变量名 | 内存地址 | | 内存地址: 0x001122 |
+----------+------------+ +------------------------+
| person | 0x001122 --|---------->| { |
+-----------------------+ | name: '张三', |
| age: 19 <-- (原位修改)
| } |
+------------------------+
-
栈内存完全没有改变 :变量
person存储的依然是地址0x001122。 -
堆内存发生变更 :系统顺着地址
0x001122找到堆内存中的对象,直接在原位将age修改为19。
3. watch 参数传递与打印逻辑
当 watch 触发时,Vue 需要将 newValue 和 oldValue 传递给回调函数:
-
Vue 传入的
newValue是person在栈内存里的指针:0x001122。 -
Vue 传入的
oldValue也是person在栈内存里的指针:0x001122。 -
浏览器执行
console.log(newValue, oldValue)时,顺着这两个完全相同的指针0x001122去堆内存中获取数据。 -
由于堆内存中的数据已经被修改成了
age: 19,因此新值和旧值最终指向同一块堆内存,读取到的都是更新后的数据。
💡 补充:为什么 Vue 不为对象保留旧值?
如果要让
oldValue准确拿到修改前的值,Vue 就必须在每次变化前对整个堆内存中的对象进行一次深拷贝(Deep Copy)。对于大型复杂对象,深拷贝会带来极大的性能损耗,因此 Vue 3 选择了性能优先,放弃了对引用对象旧值的保留。
五、 总结与最佳实践指南
为了在日常开发中写出清晰、高效且无 bug 的 watch 代码,可以参考以下规则表:
| 监听目标 | 推荐写法 | oldValue 是否准确? | deep 配置策略 |
|---|---|---|---|
单个 ref 基本类型 |
watch(sum, fn) |
✅ 准确 | 默认浅层 |
多个 ref |
watch([a, b], fn) |
✅ 准确 | 默认浅层 |
整个 reactive 对象 |
watch(person, fn) |
❌ 一模一样 | 强制深度监听(无需配置) |
reactive 的基本类型属性 |
watch(() => person.name, fn) |
✅ 准确 | 无需配置 |
reactive 的对象类型属性 |
watch(() => person.job, fn, { deep: true }) |
修改内部属性时一模一样 | 必须显式设置 { deep: true } |
核心记忆要点:
-
监听属性用函数 :只要是监听对象内部的某个属性,一律写成 Getter 函数形式
() => obj.prop。 -
区分基本与引用 :监听引用类型(对象/数组)时,不要依赖
oldValue做新旧对比。 -
嵌套对象加
deep:使用 Getter 监听对象的对象属性时,切记补上{ deep: true }。