资讯动态

Vue watch deep原理解析:响应式依赖收集与手写实现

发布时间:2026/10/9 12:44:19 来源:尧图企业网站定制
前段时间一个朋友去面前端岗被问到“Vue 中 watch 一个对象为什么要加 deep”。他脱口而出“因为不加 deep 监听不到对象内部属性的变化。”面试官接着追问“那依赖收集是怎么工作的你手写一个证明给我看”他当场卡住了。这个场景我见过太多次问题本身不难难的是你能不能把 Vue 的响应式原理、Watcher、依赖收集串起来讲清楚。这篇文章我会从面试场景出发把 watch 的源码逻辑拆开再带你手写一个迷你依赖收集系统用代码证明deep 的本质就是让对象内部属性的 dep 都记住你的 watcher。适合准备 Vue 面试的初中级前端也适合想彻底搞懂响应式原理的开发者。1. 面试开场一个看似简单的问题先别急着看答案。你回想一下几乎所有 Vue 面试题清单里都会有这道watch一个对象为什么要加deep它会出现在这里不是因为它有多难而是因为它能精准筛选出“背过答案”和“真正理解原理”两类候选人。1.1 面试官到底在问什么这个问题带着三个隐含考点。第一你知不知道watch在 Vue 内部本质上是什么。第二你知不知道默认不加deep时Watcher 只收集了哪一层依赖。第三你能不能把“依赖收集”这四个字落实到getter→dep→watcher的具体链路里。面试官想要的不是一个“是或否”的结论而是一条完整的因果链为什么对象不加deep就监听不到内部属性因为修改obj.a不会触发obj本身这个 getter而内部属性 a 的 dep 里又没有收集到当前这个 watcher。为什么加了deep就能监听到因为在初始读取时deep会递归遍历对象的所有子属性触发它们的 getter把这些子属性的 dep 全部绑定到同一个 watcher 上。这串逻辑如果讲不完整就只是背了个结论。1.2 为什么背结论的回答过不了关“因为对象是引用类型”这句话我自己在面试里听过无数遍。它不能说错但太笼统了。引用类型意味着什么意味着obj和obj.a是两个不同的属性访问路径也对应着两个不同的 dep。你可以这样回答但面试官一定会追问“引用类型怎么影响依赖收集了”如果不能继续解释这就变成了一个“你知道这个词但不懂它”的信号。更常见的是这种回答“不加 deep 只能监听到整个对象被替换加了 deep 才能监听到对象属性变化。”这句话基本正确但还是只停留在现象层。面试官想听的是“整个对象被替换”为什么会触发回调“对象属性变化”为什么不会触发回调中间差的那一步到底发生在 Vue 的哪个函数里。所以与其说面试官在考察 Vue API 的用法不如说他在考察你有没有读过源码、有没有自己动手写过最小实现。这也是我这篇文章想带大家完成的先理解原理再手写证明最后总结一套能流畅讲出来的回答。2. 默认 watch 为什么只监听对象“本身”我们先从现象出发把问题固定住。假设你在 Vue 2 中这样写data() { return { info: { name: 张三, detail: { age: 18 } } }; }, watch: { info(newVal, oldVal) { console.log(info 变化了, newVal, oldVal); } }此时如果你执行this.info.detail.age 20控制台是不会打印的。但如果你执行this.info { name: 李四, detail: { age: 30 } }打印就会触发。为什么同样的一个info整体替换和内部修改待遇完全不同这就得回到 JavaScript 的基础类型和 Vue 的响应式劫持机制。2.1 对象引用类型特性把浅监听困住了对象是引用类型的this.info的“值”本质上是一个指向堆内存的地址。this.info.detail.age 20这类操作修改的是堆内存里的数据而this.info这个属性本身的内容那根“指针”没有变。Vue 2 用Object.defineProperty劫持属性时setter只有在属性被重新赋值时才会触发。所以this.info.detail.age 20不会触发this.info的 setterwatch自然接收不到通知。你可能会有个直觉那对象内部的属性不也被劫持了吗detail和age都有自己的 setter它们变化了也会派发通知。你说得没错但这里的关键是通知发给了谁。Vue 的响应式系统里每一次set都会通知该 dep 里登记的 watcher。默认的watch: { info() {} }watcher 在初始化时只读取了this.info这个属性本身没有读取this.info.detail.age。也就是说age属性的 dep 里根本没有登记这个 watcher所以它变化时你的回调函数永远等不到通知。2.2 deep 其实就是递归读取子属性deep: true的作用不是“让对象变成深响应式”而是让这个 watcher 在初始化时“多走一步”遍历对象的所有层级逐个读取子属性。在 Vue 2 源码中这一步叫traverse核心逻辑就是递归遍历一个 value只要遇到对象就继续往下读。每读到一个属性就会触发该属性 getter 里的dep.depend()把这个 watcher 装进对应 dep。用大白话讲默认 watch 只是和info这个“门牌号”建立联系deep 则会走进门把屋里每一个角落的报警器都登记一遍哪里的值变了都会喊这个 watcher 一声。所以加了deep之后this.info.detail.age 20能触发回调不是因为响应式系统变了而是因为你在依赖收集阶段主动扩大了依赖的范围。3. Vue 依赖收集全链路从 getter 到 Dep 再到 Watcher要证明 deep 的作用不能只看 API 文档得把 Vue 2 响应式系统的核心链路理清楚。这套链路虽然代码不多但初次接触的人容易绕晕。我尽量用生活化的类比把它讲透。3.1 响应式系统里的三个主要角色Vue 2 响应式系统由三个角色配合完成Observer、Dep、Watcher。Observer负责给对象“上装备”遍历对象的每一个属性用Object.defineProperty把它们改写成带有 getter 和 setter 的属性。每个属性在改写时都会配对创建一个Dep实例。Dep是依赖收集器可以理解为一个“通知名单”名单里装的是所有关心这个属性的Watcher。Watcher代表一个“观察者任务”。比如渲染流程会创建渲染 watcherwatchAPI 会创建用户 watchercomputed会创建计算 watcher。每个 watcher 内部都有一个getter函数用来读取它关心的数据。读取动作发生在 getter 中而依赖收集恰恰就发生在这个动作里。打个比方Dep是小区物业的业主名册Watcher是业主本人。只有业主主动去物业登记过“我关心这套房”以后这套房水管坏了、电路跳了物业才会打电话通知他。Vue 里业主登记的动作就是“读取数据”。3.2 依赖收集是“读”出来的来看一个最小化的依赖收集过程。当某个 watcher 被创建时它会先执行一次 getter把Dep.target指向自己然后读取数据。读取一个被Object.defineProperty改写过的属性时会触发属性的 getter在 getter 里执行dep.depend()将当前Dep.target也就是这个 watcher登记进 dep。等到属性被重新赋值时setter 会执行dep.notify()把名单里的 watcher 挨个通知一遍watcher 再重新执行 getter 并触发回调。这就是依赖收集和派发更新的闭环。现在回到 watch 场景。你写watch: { info() {} }Vue 内部会创建一个 watcher它的 getter 大致等价于function() { return this.info; }注意这里只“读”了this.info这一个属性。执行完这个函数后Dep.target会被清空。此时这个 watcher 只出现在info属性本身的 dep 名单里。至于info.name、info.detail.age这些属性的 dep压根没机会执行dep.depend()因为你的 getter 根本没去过那些地方。如果加了deep: trueVue 会在执行完 getter 后额外调用traverse把这个返回值里所有子属性都读一遍。读到的每个属性的 getter 都会执行dep.depend()于是这个 watcher 的名字就写进了所有层级的 dep。之后任何一层的属性被修改notify 都会找到它。3.3 整体替换对象为什么不用 deep 也能触发理解了上面的链路再回看“整体替换”的场景就会非常清晰。如果你执行this.info { name: 李四, detail: { age: 30 } }这相当于给this.info属性重新赋值。Vue 拦截到了info的 setter它一查 dep 名单发现这个 watcher 早就登记过了于是立刻通知。所以整体替换时即使不加 deepwatch 也能触发。这种对比正好印证了 deep 解决的核心问题内部属性变化时外层属性的 setter 根本感知不到所以要靠深层的 getter 提前建立联系。整体替换时外层属性 setter 被触发依赖关系早就存在不需要 deep 来加强。到这里原理层面的东西已经足够回答“为什么”了。但面试官如果让你“手写证明”你还需要能现场写出一套最小实现来佐证。下一节就是硬核部分。4. 手写一个迷你依赖收集系统证明手写一个完整的 Vue 响应式系统没必要但手写一个能跑通“依赖收集 派发更新 deep 遍历”的最小模型是完全可以做到的。下面这份代码参考了 Vue 2 的核心思路删掉了边界处理只保留原理主线。4.1 60 行代码实现核心模型class Dep { constructor() { this.subs []; } depend() { if (Dep.target !this.subs.includes(Dep.target)) { this.subs.push(Dep.target); } } notify() { this.subs.forEach((sub) sub.update()); } } class Watcher { constructor(getter, callback, options {}) { this.getter getter; this.callback callback; this.deep !!options.deep; this.value this.get(); } get() { Dep.target this; const value this.getter(); if (this.deep) { this.traverse(value); } Dep.target null; return value; } update() { const oldValue this.value; this.value this.get(); this.callback(this.value, oldValue); } traverse(value) { if (value null || typeof value ! object) return; Object.keys(value).forEach((key) { this.traverse(value[key]); }); } } function observe(obj) { if (obj null || typeof obj ! object) return; Object.keys(obj).forEach((key) { defineReactive(obj, key, obj[key]); }); } function defineReactive(obj, key, val) { observe(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; observe(newVal); dep.notify(); } }); }代码里的observe负责递归构建响应式属性defineReactive负责给每个属性建立依赖容器Watcher负责登记和更新。Dep.target是一个全局开关用来标记“当前正在执行哪个 watcher 的 getter”这样 getter 里读到的每个属性都能准确知道该把谁写进名单。4.2 三种场景的实验结果先用这份代码模拟 Vue 的watch然后跑三个场景const state { info: { name: 张三, detail: { age: 18 } } }; observe(state); function watch(getter, callback, options) { return new Watcher(getter, callback, options); } // 场景一不加 deep修改内部属性 watch( () state.info, (newVal, oldVal) { console.log(info 内部变化触发回调); }, {} ); state.info.detail.age 20; // 结论控制台没有任何输出 // 场景二加 deep修改内部属性 watch( () state.info, (newVal, oldVal) { console.log(info 内部变化deep 触发回调); }, { deep: true } ); state.info.detail.age 21; // 结论控制台输出 “info 内部变化deep 触发回调” // 场景三不加 deep整体替换对象 const oldInfo state.info; state.info { name: 李四, detail: { age: 30 } }; // 结论控制台输出 “info 内部变化触发回调”场景一没有输出是因为state.info.detail.age的 setter 触发了age对应 dep 的 notify但该 dep 名单里没有登记场景一创建的 watcher。场景二之所以有输出是因为创建 watcher 时deep: true让traverse把state.info里所有可枚举属性都读了一遍age属性的 dep 名单中就有了这个 watcher。场景三则属于外层属性state.info被重新赋值它的 setter 直接 notify 了自己 dep 名单里的 watcher所以不需要 deep。4.3 代码跑完再回头看 Deep这份手写代码证明了一件事deep并没有改变对象属性被劫持的事实它改变的是依赖收集的覆盖范围。不加 deepwatcher 的 getter 执行完就收工了依赖图里只有一条边state.info的 dep → watcher。加 deepwatcher 的 getter 执行完后还要递归读取子属性依赖图里出现了很多条边state.info.detail的 dep → watcherstate.info.detail.age的 dep → watcher。之后任意一条边上的属性被修改都会沿着边通知到同一个 watcher。补充一个面试亮点Vue 2 源码里真正的traverse实现会维护一个visited集合避免循环引用造成死循环同时遇到__ob__等内部属性会跳过。数组会被遍历每一项。我在手写版本里省略了这些边界处理但面试时如果能主动提一句“真实场景中存在循环引用所以源码里用 visited 做了去重”会显得你确实读过源码。5. 面试现场这样答从“背答案”变“讲原理”原理清楚了代码也写了接下来要解决的是临场表达。面试官不是让你写论文他需要听到一个逻辑完整、能自己推导出来的答案。这里我给出一套可以直接用的回答链路。5.1 推荐答法按 4 层递进第一层先说 watch 的本质watch在 Vue 内部会创建一个Watcher实例这个 watcher 有一个 gettergetter 负责读取被监听的数据。第二层说默认行为不加 deep 时getter 只读取对象本身比如return this.info。这个读取动作只会把对象info所属的 dep 和当前 watcher 建立联系。对象内部属性的 dep 和 watcher 之间没有建立任何关系。第三层说后果修改this.info.detail.age时触发的只是age这个属性的 setter这个 setter 的 notify 找不到当前 watcher所以回调不执行。只有整体替换this.info触发了info属性自己的 setterwatcher 才会收到通知。第四层说 deep 的作用加了deepwatcher 创建时会在 getter 执行完之后递归读取对象的所有子属性。读取每个子属性都会触发 getter把这些子属性的 dep 都收集到同一个 watcher 名下。于是深层属性变化时notify 就能找到这个 watcher回调自然触发。这四层讲完面试官就已经知道你不是死记硬背了。如果还有余力可以顺手补一句“我可以再补充一下 Vue 2 里 deep 的实现叫 traverse核心代码就是递归遍历 value触发 getter 完成依赖收集。”这句话很加分。5.2 几个容易被追问的源码细节面试官听完主逻辑通常还会往下追。我整理过三个高频追问提前准备好会很从容。第一个追问加了 deep 会有什么性能影响答案是需要遍历所有子属性如果对象嵌套很深、数据量很大watcher 初始化和后续更新的成本都会增加。所以不建议无脑给整个大对象加 deep优先考虑精确监听。第二个追问为什么oldVal和newVal在 deep 监听时经常是一样的这是 Vue 深度监听的经典陷阱。因为对象是引用类型内部属性变化不会产生新的对象oldVal和newVal指向同一个内存地址前后对比没有意义。要拿到真正的旧值要么手动拷贝一份快照要么避免 deep 监听这种“只能确认变化但无法对比增量”的场景。第三个追问JavaScript 新增/删除属性能不能被 deep 监听到在 Vue 2 中对象的 delete 和新增属性不是响应式的因为Object.defineProperty只能劫持已经存在的属性。想在对象上新增一个响应式属性需要Vue.set删除属性要用Vue.delete。这个问题经常和 deep 混在一起考不要搞乱。5.3 Vue 3 的 deep 还一样吗如果你的面试聊到了 Vue 3这个问题需要额外说明。Vue 3 的响应式底层换成了Proxy响应式能力本身不再局限于已有属性新增和删除属性都能被拦截。但是在watchAPI 的语义上deep 仍然存在。Vue 3 中如果你直接watch(reactive 对象, callback)默认会开启深度监听因为源码里会把它当作 deep 处理。但如果你写的是watch(() state.info, callback)返回的是一个普通对象那就依然是浅监听需要手动加{ deep: true }。也就是说deep 的身影还在只是触发条件和 Vue 2 略有不同。面试时把这条差异说出来能够覆盖更广的考察面。6. 最后分享几个我在实际项目里踩过的坑这个问题不光面试会问开发里也真的会遇到。我自己的项目里就踩过几次印象最深的有三个。6.1 无脑 deep 带来的性能灾难有一回我在低代码平台里监听一个 schema 对象数据量不大但用户每次拖拽组件都会修改几个字段。图省事我直接给 watch 加deep: true结果拖拽时页面明显卡顿。后来排查发现deep 会让 watcher 在每次更新时都重新递归遍历整个 schema节点一多开销就上去了。最后改成用 computed 派生出真正需要关注的字段分别 watch再配合flush: post卡顿问题才消失。经验就是能用精确 getter 解决就别让 deep 做“全屋扫描”。6.2 deep 监听下新旧值相同的坑另一个坑是深度监听时拿不到正确的旧值。比如一个编辑弹窗用户改了姓名watch 的回调里oldVal.name已经和newVal.name一样了。因为内部属性变化不触发对象引用变化oldVal和newVal本质上是同一个对象。后来我在 watch 触发时用JSON.parse(JSON.stringify(...))手动快照才勉强拿到想要的数据。但这只适合没有函数、Date 等特殊类型的场景否则还是考虑用 immutable 的数据结构更稳妥。6.3 一句顺口溜记住 deep 的适用场景我自己后来总结了一句顺口溜整体替换不加 deep内部变化 deep 来顶能用 getter 精确就少用 deep。这句话既方便记忆也能在面试最后快速概括你的理解。把这句话说出来面试官通常会觉得你不仅有原理还有实战总结。回到最初的问题Vue 中 watch 一个对象为什么要加 deep本质上是因为默认 watch 只收集了对象自身的依赖而 deep 帮你递归读取子属性把深层依赖也收进来。下次再遇到这个面试题别再只说一句“不加 deep 就监听不到”了从 Watcher 到 Dep 再到 traverse你完全可以用一条清晰的逻辑链把背后那套依赖收集机制讲得明明白白。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑