ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue watch加deep为何不生效?依赖收集原理深度解析

Vue watch加deep为何不生效?依赖收集原理深度解析 上周帮同事排查一个线上 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解析成一个 getterfunction () { 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 最小的 Depdepend 与 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 nullDep.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.aobj.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 回调频率又高这种重复遍历会造成可感知的卡顿。第三个代价比较隐蔽由于深层属性变化不会触发外层对象引用的 setterwatcher 回调拿到的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 响应式的一大半问题基本上都在你掌控之内。
返回列表