☰
Vue watch加deep为何不生效?依赖收集原理深度解析
2026/10/8 10:37:42 网站建设 项目流程

上周帮同事排查一个线上 bug,页面某个区域的状态标签怎么都不更新。他代码里明明写了watch: { form: { deep: true, handler() { ... } } },后端也确认数据推下来了,可界面就是不动。绕了一圈才发现,问题根本不在 deep 加没加,而在于他对 Vue 的依赖收集理解少了一环——form.avatar.url这个字段,是在组件初始化之后才动态挂在对象上的,Vue 2 的Object.defineProperty压根没给它做过响应式代理,deep 就算遍历了整个当时存在的对象树,也收不到这个新子属性的依赖。后来用$set补上,界面立刻刷了。

这件事让我特别有感触。很多前端同学对 deep 的认知停留在"不加 deep,对象属性变化不触发;加了 deep 就能触发"这种粗浅层面。但面试官问"Vue 中 watch 一个对象为什么要加 deep",真正想考察的是底下一层:不加 deep 时,watch 到底收集了谁的依赖?加了 deep 之后,又多收集了谁的依赖?这两个问题想清楚,你不仅能应付面试,以后再遇到各种 watch 不触发的诡异问题,也能自己定位。

这篇文章我会从真实场景出发,带你手撸一套极简依赖收集系统,把"加 deep 才生效"这件事从原理上证明出来。全程不装神弄鬼,每一行代码都有它存在的理由。

1. 从一次线上 bug 说起:配置对象变了,页面纹丝不动

1.1 一个"加了 deep 还是不生效"的诡异问题

先还原一下那个 case。同事的代码长这样:

watch: { form: { deep: true, handler(newVal) { this.avatarUrl = newVal.avatar.url } } }

他描述的现象是:头像上传成功后,接口返回了新的 url,自己也确认form.avatar.url的值变了,但avatarUrl没更新,页面预览图还是旧的那张。

这看起来很反直觉:我 deep 都加了,为什么对象内部属性变化还是不触发?

实际上,问题的根源有两个。第一,Vue 2 的响应式系统是"先代理、后收集"的。组件的 data 初始化时,Object.defineProperty只劫持了当时已经存在的 key。form.avatar最初可能只有id和name,后台上传接口返回的url是后来才动态加上去的。对 Vue 2 来说,这个url属性根本不存在于响应式系统里,赋值它不会触发任何 setter。第二,deep 的作用是在 watch 求值那一刻,递归读取对象树上的所有子属性,强制它们把当前的 watcher 收集进自己的依赖列表。但读取的前提是属性必须已经存在并且已经被代理。一个从来没被代理过的新属性,deep 连读都读不到它,自然也就谈不上收集依赖。

所以最后修复方案也不是改 watch,而是把接口返回的数据用Vue.set(form.avatar, 'url', url)补进去,让这个新属性进入响应式系统,deep 的遍历才能在下一次求值时把它纳入收集范围。

这个 bug 最有价值的地方在于:它逼着我去思考"deep 到底是在哪个时间点、对哪些属性、做了哪些事"。如果你只背结论"加 deep 就能监听对象内部变化",碰到这种场景仍然会两眼一抹黑。

1.2 面试官真正想考察的是什么

回到标题里的面试题。面试官问"Vue 中 watch 一个对象为什么要加 deep",表面答案是:watch 默认是浅监听,只能感知被监听对象引用本身的变化,对象内部嵌套属性变化时不会触发回调。

但只答这一句,在面试官眼里和没答区别不大。因为这是一个只需要 30 秒背诵就能输出的结论,不含任何理解成本。

真正有区分度的内容在于:你能不能说清楚new Vue时 watch 是如何注册的?watcher 在初始化时为什么要主动执行一次求值?这次求值里发生了什么?为什么浅监听读不到嵌套属性?deep 又是通过什么手段把嵌套属性纳入依赖体系?说到最后能上手写代码演示,几乎就是满分答案。

接下来的内容,我会按这个逻辑一层层拆开。这套思路也是我和团队做前端面试复盘时,验证过最容易让候选人暴露真实水平的几条主线。

2. 浅监听失效的本质:拆解 watch 的依赖收集链路

2.1 一次 watch 的完整生命周期

要理解 deep,先要理解 watch 在 Vue 2 里是怎么工作的。组件初始化时会经过一个initState的阶段,这里面会根据watch选项逐条注册监听。真正干活的是$watch方法,它最终会实例化一个Watcher。

// 简化后的 Vue 内部逻辑 function createWatcher(vm, expOrFn, handler, options) { if (isPlainObject(handler)) { options = handler handler = handler.handler } return vm.$watch(expOrFn, handler, options) } Vue.prototype.$watch = function (expOrFn, cb, options) { const watcher = new Watcher(this, expOrFn, cb, options) if (options.immediate) { cb.call(this, watcher.value) } return function unwatchFn() { watcher.teardown() } }

注意一个关键细节:Watcher 构造函数内部会立刻执行一次"求值",也就是调用this.get()。这是整个依赖收集的触发器。

class Watcher { constructor(vm, getter, callback, options) { this.vm = vm this.getter = getter this.callback = callback this.deep = !!options.deep this.value = this.get() // 构造时就立即求值一次 } get() { pushTarget(this) // 把当前 watcher 设为 Dep.target const value = this.getter.call(this.vm, this.vm) if (this.deep) { traverse(value) // 深度遍历,后面重点讲 } popTarget() return value } }

也就是说,从你写下watch: { obj() {} }的那一刻起,Vue 就已经悄悄执行了一次对obj的读取。这个"读取"的动作,就是依赖收集的入口。

2.2 为什么对象内部属性变化触发不了回调

那么问题来了:当你watch: { obj() {} },this.getter到底是什么?

如果你写的是字符串形式:

watch: { form: function () {} }

Vue 会把'form'解析成一个 getter:

function () { return vm.form }

这个 getter 在Watcher.prototype.get中被调用。它只读取了vm.form这一层。也就是说,整个求值过程只触发了vm.form这个属性的 getter,把这个属性的dep与当前 watcher 建立了联系。

此时如果我们做这么一件事:

vm.form.name = 'new name'

发生了什么?

vm.form本身引用并没有变,所以vm.form的 getter 没有被触发,它的 dep 也没有执行 notify。真正执行的是form对象上name属性的 setter。而这个name属性的 dep,在刚才浅监听求值时压根没有被访问过,里面根本没有这个 watcher。

结果就是:setter 虽然执行了,notify 虽然触发了,但通知列表里空无一人,回调自然不执行。

这正是"浅监听失效"的本质:不是 Vue 不检测变化,而是它的依赖收集根本没覆盖到你改变的那一层。通知发不到这个 watcher 头上,Vue 也觉得很无辜。

2.3 快递柜类比:你只给柜门装了传感器

这个逻辑可以拿快递柜来类比。一个对象就是一个快递柜,对象里的每个属性就是一个小格子。浅监听相当于你只在柜子最外面装了一个传感器,任何一次"打开柜门"(读取或替换整个对象引用)都会触发它。

现在你用watch: { form() {} }监听 form,就只是在柜门上贴了传感器。form = newData相当于柜门开合,传感器能感知到。但form.name = 'xxx'这个操作,是柜子内部某个格子的门开了,外面柜门纹丝不动,你装的传感器自然毫无反应。

deep 干的事情,说白了就是在柜子里面的每一个格子都装上一个传感器。装完之后,不管你在内部动哪个格子,传感器都会响。而装传感器的方式只有一个:把每个格子都打开看一眼。这个"看一眼"的动作,就是依赖收集里的读取。Vue 源码里的traverse函数干的就是这件事:递归遍历整个对象树的每个属性,强制触发每个子属性的 getter,把当前 watcher 逐个登记进去。

3. 手写一套极简依赖收集,把"加 deep 才生效"证明出来

前面讲了那么多理论,现在进入最有说服力的部分:手写一套 Mini 版依赖收集系统。你不用去 Vue 源码里翻,下面这套代码把核心机制抽象得非常干净。建议你打开编辑器跟着敲一遍,敲完你对 deep 的理解会比读十篇博客都深。

3.1 最小的 Dep:depend 与 notify

先写依赖管理器Dep。它的职责有两件事:收集依赖、派发通知。对应到现实就是:传感器登记到哪张表格上;触发时逐一致电。

class Dep { constructor() { this.subs = new Set() } depend() { if (Dep.target) { this.subs.add(Dep.target) } } notify() { this.subs.forEach(watcher => watcher.update()) } } Dep.target = null

Dep.target是一个全局变量,指向"当前正在求值的 watcher"。用 Set 而不是数组,是为了天然去重,同一个 watcher 不会被同一个 dep 重复收集。这和 Vue 2 源码用数组加 id 去重是同一个目的。

然后写defineReactive,它就是 Vue 2 响应式系统的核心魔法。每个被代理的属性都藏着一个 dep 实例,get 时收集依赖,set 时通知更新。

function defineReactive(obj, key, val) { const dep = new Dep() Object.defineProperty(obj, key, { enumerable: true, configurable: true, get() { if (Dep.target) { dep.depend() } return val }, set(newVal) { if (newVal === val) return val = newVal dep.notify() } }) }

如果你测试过 Vue 2 响应式,你会发现这里和源码的逻辑同构:读取时把这个属性身上挂着的依赖登记到 dep 里,赋值时就地通知,一个不多一个不少。

3.2 用 Watcher 还原浅监听失效现场

接下来是Watcher,它模拟的就是 Vue 里 watch 创建的那个 watcher 实例。注意构造时它会立即执行this.get(),这一步就是"求值",也是依赖收集的起点。

class Watcher { constructor(source, key, callback, options = {}) { this.source = source this.key = key this.callback = callback this.deep = !!options.deep this.value = this.get() } get() { Dep.target = this const value = this.source[this.key] // 浅监听只读取一层 if (this.deep) { traverse(value) // 深监听才会递归读取内部属性 } Dep.target = null return value } update() { const oldValue = this.value this.value = this.get() this.callback(this.value, oldValue) } }

核心就在get():它读了一层属性,把 dep 收集到当前 watcher。如果deep是 true,会额外调用traverse(value),递归读取 object 的所有子属性。现在补上traverse的极简实现:

function traverse(value, seen = new Set()) { if ( value === null || typeof value !== 'object' || seen.has(value) ) { return } seen.add(value) Object.keys(value).forEach(key => { const child = value[key] traverse(child, seen) }) }

这一步的本质就是"强行读取"。每读一个子属性,就会触发它的 getter,而 getter 里的dep.depend()就会把当前 watcher 塞进那个属性的依赖列表。读完整棵树,所有子属性就都记住了你。

现在构造数据,模拟一次真实的比较:

const obj = { a: { b: 1 } } // 给整个对象做响应式代理(简化版) defineReactive(obj, 'a', obj.a) defineReactive(obj.a, 'b', obj.a.b) let shallowCount = 0 let deepCount = 0 // 浅监听 const shallowWatcher = new Watcher(obj, 'a', () => { shallowCount++ }) // 深监听 const deepWatcher = new Watcher(obj, 'a', () => { deepCount++ }, { deep: true })

运行到这里,先停下来想一个问题:现在有两个 watcher 同时存在,它们各自的依赖列表里都有谁?

浅 watcher 在求值访问obj.a时,只触发了obj.a的 getter。所以它的依赖列表里只有a属性对应的那个 dep。深 watcher 在访问完obj.a后,又通过traverse读取了obj.a.b,触发了b的 getter。所以它的依赖列表里除了a属性,还有b属性的 dep。

接着验证:

obj.a.b = 2 console.log(shallowCount) // 0 console.log(deepCount) // 1

结果清清楚楚:b属性的 setter 执行后,只会通知自己 dep 里登记的深 watcher,浅 watcher 压根不在名单里,所以它没有任何反应。这不是什么玄学,这就是依赖收集的边界。

如果在同一个案例里替换整个obj.a:

obj.a = { b: 3 } console.log(shallowCount) // 1 console.log(deepCount) // 2

这次两者都触发了,因为替换obj.a触发的是a属性的 setter,而a的 dep 里同时登记了浅 watcher 和深 watcher。从这个对照里你能看出一个重要结论:deep 不是让你"监听对象本身"变得更准,而是让"对象内部属性"的变化也能追上你。

3.3 运行结果对比与结论

把两种情况放在一张表里,结论会非常直观:

操作浅监听回调次数深监听回调次数
修改obj.a.b01
替换整个obj.a12

最后一行深监听变成 2,是因为它除了a自己的 dep,还要经过一次自身 update 的重新求值。这里不展开,但你要知道:deep 的 watcher 每次回调触发,都会再走一遍"读取+遍历",成本会比浅监听高。

说到这,你手上已经有一套能跑通的极简依赖收集了。面试官如果当场让你"手写依赖收集证明",你可以从这段代码开始,把浅监听失效和 deep 生效两条路径完整演示出来。这比干巴巴背十遍结论都有说服力。

4. deep 的真正实现:traverse 递归读取与 Vue 源码逐行解读

4.1 Watcher.get 里的 deep 分支

看完极简版,再回头看 Vue 2 源码会发现亲切很多。真正的Watcher.prototype.get比前面写的多了一些边界处理,但主干完全一致:

// Vue 2 源码 src/core/observer/watcher.js get() { pushTarget(this) let value const vm = this.vm try { value = this.getter.call(vm, vm) } catch (e) { if (this.user) { handleError(e, vm, `getter for watcher "${this.expression}"`) } else { throw e } } finally { if (this.deep) { traverse(value) } popTarget() this.cleanupDeps() } return value }

注意几个细节。第一,pushTarget和popTarget维护的是一个栈,而不是简单的Dep.target = null。因为 Vue 在渲染过程里会有父子组件、嵌套 watcher 的求值,用一个栈才能保证最内层的 watcher 结束后,能把Dep.target还原成上一层的 watcher。我上面的极简版直接用赋值,是为了演示主流程,真实实现要考虑这种嵌套场景。

第二,traverse被放在finally里。这意味着即使 getter 抛错了,深度遍历也会执行,不会因为一次异常就中断整个依赖收集。这是源码里容易被忽略但对稳定性很重要的设计。

4.2 traverse 为什么要用 seen 集合去重

Vue 2 的traverse在src/core/observer/traverse.js里,源码不难读:

const seenObjects = new Set() export function traverse(val) { _traverse(val, seenObjects) seenObjects.clear() } function _traverse(val, seen) { let i, keys const isA = Array.isArray(val) if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) { return } if (val.__ob__) { const depId = val.__ob__.dep.id if (seen.has(depId)) { return } seen.add(depId) } if (isA) { i = val.length while (i--) _traverse(val[i], seen) } else { keys = Object.keys(val) i = keys.length while (i--) _traverse(val[keys[i]], seen) } }

我先解释它不做什么:不是对每个 key 都调用Object.keys然后一层层读就完事。它做的最关键的一个事是避免循环引用。

JavaScript 对象是允许互相引用的:obj.self = obj。如果没有seen集合做去重,traverse会陷入无限递归,直接把调用栈爆掉。源码里用的去重键是val.__ob__.dep.id,因为每个响应式对象都有一个唯一的 observer 实例和 dep id。只要同一份对象被遍历过,第二次遇到就直接短路返回。

还有几个值得注意的细节:

  • Object.isFrozen(val)直接跳过:冻结对象的属性无法被改写,读不读它意义不大。
  • val instanceof VNode直接跳过:Vue 在渲染相关的 watcher 里不希望深度遍历虚拟 DOM 节点,那会产生海量无用依赖。
  • 数组会逐个递归每个元素,而不是只做val[i]的浅层读取。所以 watcher 一个数组并加 deep,数组里对象的属性变化也能触发。

我当初读这段源码时最大的体会是:Vue 对 deep 的实现并没有引入什么特殊机制,它一共就一句话,"把所有子属性全部读一遍"。"读取"这个动作本身就是依赖收集的媒介,读得越深,覆盖的 dep 就越广。这也是我反复强调"读取即绑定"的原因。

4.3 Vue 3 里 deep 的定位变化

面试如果聊到 Vue 3,要补充一下时代差异。Vue 3 的响应式底层换成了 Proxy,天然的懒代理特性让"动态新增属性"不再是个问题。所以同样是watch,行为有个微妙的变化:

  • watch(reactiveObj, cb):watch 一个响应式对象时,默认就已经是深度监听。因为 Proxy 的 get 在访问内部属性时会被拦截,依赖收集发生在 get 层面,读一层和读一百层在"是否能感知内部变更"上没有本质区别。
  • watch(() => state.obj, cb):watch 一个 getter 时,默认是浅的。它只等着 getter 返回值的引用变化,如果 getter 返回值里的某个子属性变了,不会触发。
  • 这种情况下如果你希望子属性变化也能捕获,同样需要deep: true,让 Vue 对 getter 返回的值做递归遍历。

逻辑内核还是那句话:deep 是一个"是否扩大依赖读取范围"的开关,底层手段依然是遍历读取。只是 Vue 3 在响应式对象上默认帮你开好了而已。

5. deep 的代价、替代方案与面试完整表达

5.1 别无脑加 deep:全量遍历的代价比你想的高

我在实际项目里见过不少"遇事不决 deep: true"的写法,这个习惯在大多数小项目里不出事,但一旦对象变大,问题就来了。

第一个代价是初始化阶段的全量遍历。watch 一个对象并加 deep,实例化 watcher 时就会把这棵对象树完整走一遍。一个 10000 个 key 的对象,getter 触发一次,traverse 会对 10000 个 key 逐一读取。这不仅是性能问题,还会在短暂的求值期间临时创建大量 dep 关联,内存也会随之上涨。

第二个代价藏在更新阶段。你可能以为 deep 只在初始化时遍历一次。不是的。watcher 每次被通知触发回调,update()里会重新执行this.get(),也就是说每触发一次,就要重新读一次整棵对象树。如果某个对象树很大,deep watcher 回调频率又高,这种重复遍历会造成可感知的卡顿。

第三个代价比较隐蔽:由于深层属性变化不会触发外层对象引用的 setter,watcher 回调拿到的newVal和oldVal其实是同一个对象的引用,两个参数内容完全相同。你还是能拿到新值,但拿不到"变化之前"的快照。这在你需要做 diff、做撤销回滚时会非常难受。

watch: { form: { deep: true, handler(newVal, oldVal) { // newVal === oldVal,因为只是内部属性变了,对象引用没变 // 这里没法通过两者对比找出具体哪个字段变化 } } }

如果确实需要旧值快照,常见做法是在 handler 里手动做一次深拷贝再缓存:

watch: { form: { deep: true, handler(newVal) { this.prevForm = JSON.parse(JSON.stringify(newVal)) } } }

JSON 深拷贝只适用于纯数据对象,有 Date、函数、循环引用的对象会出问题,这个要注意。也可以用lodash.cloneDeep,但那是另一个深拷贝的成本了。

5.2 更精准的监听姿势:getter、computed 与具体子属性

实战中真正该做的,不是问"要不要加 deep",而是问"我到底想监听哪一层变化"。大部分业务场景不需要全量深度监听,有以下几种替代姿势。

第一种,watch 具体的子属性路径。这是性价比最高的:

watch: { 'form.name'(newVal, oldVal) { // 只关心 form.name 的变化 } }

只要确定变化源是某个具体字段,直接用字符串路径,Vue 内部会把它解析成对应的 getter,精准收集依赖。

第二种,用 computed 派生一个"情感关注点"。watch 不直接监听原始对象,而是监听一个只依赖你关心字段的 computed 值:

computed: { displayName() { return `${this.form.firstName}_${this.form.lastName}` } } watch: { displayName(newVal) { // firstName 或 lastName 任一变化都会触发 } }

好处是依赖范围被收得很窄,不会因为其他无关字段变化而白白触发回调,性能可控。

第三种,watch 一个函数 getter,在 getter 里只返回你关心的字段拼接结果:

watch: { formName() { return this.form.name }, handler(newVal) { // 只有 form.name 变化才触发 } }

这种方式相当于手动圈定了依赖边界,没有 deep 的全量遍历成本。

什么时候才真的需要deep: true?比如你监听一个表单校验结果对象,校验器一次性要更新十几个字段,每个字段单独去 watch 会写出一堆重复逻辑,而全量遍历这十几个字段的成本又完全可接受。这时候 deep 才是合理的选择。关键判断标准是:对象规模可控、子属性数目有限、确实需要整体感知变化。

5.3 面试官想听到的四步答案与隐藏加分点

如果现在面试官直接问你"Vue 中 watch 一个对象为什么要加 deep",我建议你按四条主线讲,条理清晰且不啰嗦:

第一句先纠偏:watch 对象不是"必须"加 deep,取决于你想监听哪一层。如果你只关心对象引用被整体替换,不加也能触发。deep 解决的是对象内部嵌套属性变化时的感知问题。

第二句讲机制:watch 底层创建一个 Watcher,创建时会先执行 getter 做一次求值,这个求值会触发被监听属性的 get,完成依赖收集。浅监听时 getter 只读取了对象本身这一层,所以只有这一层的 dep 收集到了 watcher。内部属性变化走的是子属性的 setter,子属性的 dep 里没有这个 watcher,自然触发不了回调。

第三句讲 deep 干了什么:加 deep 后,Watcher 求值完会调用 traverse 递归遍历整棵对象树,读取所有子属性,强制所有子属性的 dep 都收集当前 watcher。之后的任何一个子属性变化,都会通过各自的 dep 通知到 watcher,回调就会执行。

第四句点代价:这种全量收集不是没成本的,每次触发还会重新遍历,所以不要无条件加 deep,优先监听具体字段或配合 computed 收窄依赖范围。

如果你愿意,还能掏出前面那套极简手写版本现场演示。当面试官看到你从 Dep、defineReactive、Watcher 一路写下来,跑出浅监听不触发、深监听触发的对照结果,这一题基本就锁定了。

这里再给一个隐藏加分点:你可以主动提一句 "talk is cheap,我可以手写证明",然后现场写。面试官想看到的不是重复背结论,而是具备把源码机制抽象成最小模型的能力。我最终建议每个 Vue 开发者都亲手做一遍这个练习:精简掉所有枝节,只保留 dep、getter、setter、watcher 四个概念,去复现一遍"对象内部属性的变化如何被感知"。这套模型想通了,Vue 响应式的一大半问题,基本上都在你掌控之内。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询