从语法坑点到 JS 内存底层:Vue 3 watch 侦听器深度全解

在 Vue 3 的 Composition API 中,watch 是我们处理异步操作、数据联动时最常打交道的 API 之一。然而,很多开发者在实际使用中经常碰到一些让人困惑的现象:

  • 为什么直接传 person.name 监听不到变化?

  • 为什么监听 reactive 对象时,oldValuenewValue 打印出来是一模一样的?

  • 为什么有时候配置 { 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 函数之前,会先计算表达式的值

  1. person.name 被求值,得到具体的字符串 '张三'

  2. 实际传给 watch 的代码等同于:watch('张三', callback)

  3. '张三' 只是一个普通的静态字符串(基本数据类型),它既没有 Proxy 代理,也没有响应式能力 。Vue 根本无法得知这个 '张三' 来自哪个对象的哪个属性,依赖收集(Track)直接失败。

解决方案:使用 Getter 函数 () => person.name

当你传入一个函数 () => person.name 时:

  1. 传给 watch 的是一个可执行的函数

  2. Vue 在初始化 watch 时会主动执行一次这个函数。

  3. 在执行 person.name 的瞬间,触发了 person(Proxy 对象)的 getter 拦截器。

  4. 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 认为"返回值没变",不触发监听

四、 底层解密:为什么 newValueoldValue 会一模一样?

在监听 reactive 对象(或开启 deep: true 的对象属性)时,你会发现控制台打印的 newValueoldValue 完全相同,都是修改后的最新值:

JavaScript

javascript 复制代码
person.age = 19
// 控制台输出:
// person变化了 { name: '张三', age: 19 } { name: '张三', age: 19 }

要理解这个现象,我们需要深入 JavaScript 的内存分区机制(Stack 与 Heap)

1. 内存分区:栈(Stack)与堆(Heap)

JavaScript 的内存主要分为两个区域:

  • 栈内存(Stack) :空间小、读取快,存放基本数据类型 以及引用对象的内存地址指针

  • 堆内存(Heap) :空间大,存放引用数据类型 (如 ObjectArray)的实际内容。

当你定义 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  <-- (原位修改)
                                    |  }                     |
                                    +------------------------+
  1. 栈内存完全没有改变 :变量 person 存储的依然是地址 0x001122

  2. 堆内存发生变更 :系统顺着地址 0x001122 找到堆内存中的对象,直接在原位将 age 修改为 19

3. watch 参数传递与打印逻辑

watch 触发时,Vue 需要将 newValueoldValue 传递给回调函数:

  1. Vue 传入的 newValueperson 在栈内存里的指针:0x001122

  2. Vue 传入的 oldValue 也是 person 在栈内存里的指针:0x001122

  3. 浏览器执行 console.log(newValue, oldValue) 时,顺着这两个完全相同的指针 0x001122堆内存中获取数据。

  4. 由于堆内存中的数据已经被修改成了 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 }

核心记忆要点:

  1. 监听属性用函数 :只要是监听对象内部的某个属性,一律写成 Getter 函数形式 () => obj.prop

  2. 区分基本与引用 :监听引用类型(对象/数组)时,不要依赖 oldValue 做新旧对比。

  3. 嵌套对象加 deep :使用 Getter 监听对象的对象属性时,切记补上 { deep: true }

相关推荐
张元清1 小时前
React useFocus Hook:追踪并控制元素焦点状态 (2026)
javascript·react.js
k4m7v2pz1 小时前
Rust 长跑守护进程日志治理:切分、时区与结构化
开发语言·后端·rust·日志系统·日志轮转·ndjson
FfHUCisI1 小时前
Golang RESTful API 设计原则
开发语言·golang·restful
breeze jiang2 小时前
ESLint flat config 配置实战:五大字段、规则严重级别与 --fix 能力边界详解
开发语言·前端·javascript
巴勒个啦2 小时前
ECharts 大屏开发实战:从布局设计到动画优化的完整流程
javascript
书中枫叶2 小时前
做了个「句拾」小程序,最难的不是业务,是字体
前端·javascript·vue.js
weixin_307779132 小时前
PHP 大文件上传:内存占用、超时与最佳实践
linux·服务器·开发语言·nginx·php
m0_527034332 小时前
异步任务审核系统设计:消息队列、超时重试与失败补偿
java·大数据·开发语言
FFF_634560232 小时前
简单的画板小工具,下载即用
前端·javascript·css