尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Vue2与Vue3响应式原理对比:从Object.defineProperty到Proxy
1. 从数据改了视图没变说起Vue的响应式到底在解决什么问题1.1 一个典型的线上事故现场我先讲一个自己踩过的真实场景。某次在维护一个后台报表系统需求很简单点击展开按钮后把后端返回的额外字段挂到当前行对象上然后页面上的汇总卡片跟着变化。代码写得很顺手this.reportRow.trendData response.data.trend结果呢页面上的trendData相关区域纹丝不动刷新浏览器之后数据才显示出来。我当时的第一反应是赋值没生效赶紧在控制台打印对象里确实已经有了trendData。后来才意识到这是Vue2响应式的一个经典边界直接给一个已存在的响应式对象新增属性Vue是看不见的。这不是一次偶然事故而是几乎所有Vue开发者都会撞上的墙。当你理解了背后的响应式原理这类问题就不再是玄学而是规矩。今天这篇内容我打算把Vue2和Vue3的响应式实现彻底拆开从源码思路到实际写法差异一次讲明白。1.2 响应式的两个核心问题收集依赖和派发更新Vue的响应式系统说穿了只回答两个问题。第一个问题某个数据在被读取的时候是谁在读可能是模板里的插值表达式可能是计算属性也可能是某个监听器。这些读取方会在读取发生时被记录到该数据的一份依赖清单里这个过程叫依赖收集。第二个问题当这个数据被修改时怎么高效地通知所有依赖清单里的读取方去重新执行从而让页面更新这个过程叫派发更新。你不需要记住高深的术语只要记住一条主线读取时收集修改时派发。Vue2和Vue3的差异本质上就是用不同方式回答这两个问题——用什么机制来拦截读取和修改。Vue2用Object.defineProperty对对象的每个已有属性做拦截Vue3用Proxy对整个对象做拦截。一句话先撂在这里后文所有细节都是这句话的展开。2. Vue2的响应式实现Object.defineProperty的两大边界和它的补丁方案2.1 初始化时递归defineReactiveVue2拿到data后会通过Observer类递归遍历它的所有属性对每个属性调用defineReactive也就是用Object.defineProperty重写属性的get和set。function defineReactive(obj, key, val) { const dep new Dep() // 每个属性维护一份依赖列表 Object.defineProperty(obj, key, { enumerable: true, configurable: true, get() { if (Dep.target) { dep.addSub(Dep.target) // 收集当前正在读取的 Watcher } return val }, set(newVal) { if (newVal val) return val newVal dep.notify() // 通知所有依赖去更新 } }) }这里有个细节容易看晕set里为什么不能写成obj[key] newVal因为一旦进入set再给obj[key]赋值就会再次触发set造成无限递归。所以必须用一个外层变量val来闭包保存当前值。这也是源码里常见的闭包桥接手法。由于Object.defineProperty只能针对已存在的属性定义描述符所以Vue2在初始化时必须把所有属性都提前准备好。data里没写的字段后面无论怎么赋值都不会有get/set拦截自然也就无法触发视图更新。我开头那个事故根源就在这里。2.2 数组为什么是重写方法而不是监听索引很多初学者会问既然defineProperty能监听属性那为什么不对数组的每个索引也统一监听技术上确实可以Vue2早期也尝试过但最终放弃了。原因是性能与语义的双重考量。数组元素可能很多为每个索引挂上getter/setter初始化开销会非常大。更关键的是数组的操作方式与对象不同——push、pop、shift、unshift、splice这类方法会改变数组长度新增的索引是之后才产生的光靠初始化劫持根本覆盖不到。Vue2的选择是在保持原有数组功能的前提下重写会改变数组自身的七个方法push、pop、shift、unshift、splice、sort、reverse。具体做法是创建一个以原数组原型为__proto__的新数组对象拦截这些方法在调用方法完成真正的操作后额外执行依赖通知。const original Array.prototype const arrayProto Object.create(original) ;[push, pop, shift, unshift, splice, sort, reverse].forEach(method { arrayProto[method] function (...args) { const result original[method].apply(this, args) this.__ob__.dep.notify() // 通知依赖 return result } })意味着什么呢在Vue2里arr[0] 1不会触发更新因为索引不是方法没有走重写逻辑arr.length 0也不会触发更新。想要响应式地修改数组必须用arr.splice(0, 1, 1)或者用Vue.set。理解了这一层社区里大量数组更新失败的问题就都有了答案。2.3 新增属性和删除属性$set/$delete的补丁逻辑既然defineProperty只能拦截已有属性Vue2就设计了两个补丁方法Vue.set和Vue.delete实例上为this.$set和this.$delete。它们的核心逻辑并不复杂。当Vue.set(target, key, value)执行时如果target是数组就调用target.splice(key, 1, value)借助重写过的splice触发更新。如果key已经存在于对象上直接赋值即可因为已有属性已经具备响应式能力。如果key不存在则调用defineReactive给这个新属性挂上getter/setter然后手动通知依赖更新。一句话$set的作用是把一个本不属于响应式体系的新属性手动改造成响应式属性再触发一次更新。$delete的逻辑也是对称的删除属性后通知依赖。但要注意它同样不能直接删掉data顶层的根属性因为根属性一旦删除整个组件的响应式根基就塌了Vue2内部是禁止这种操作的。所以我一直建议在Vue2里设计data结构时尽量把可能出现的字段都先声明出来默认值给null或空对象宁可丑一点也不要到处补$set。2.4 依赖收集与派发的最小模型Vue2的响应式体系里有三个基础角色Dep、Watcher、Observer。Observer把对象变成响应式遍历属性并调用defineReactive。Dep一个属性对应一个Dep负责管理依赖列表。Watcher一个组件渲染对应一个Watcher也可以手动创建用于computed或watch。组件在渲染时会创建渲染Watcher并把它赋值给Dep.target。然后组件模板开始读取数据读取到哪个属性就会触发哪个属性的getget里把当前Dep.target也就是这个渲染Watcher收集进Dep。等数据变化时set触发dep.notify()通知所有Watcher重新执行渲染函数再跑一遍视图就更新了。这个模型本身非常优雅问题只出在拦截能力上。defineProperty一次只能拦截一个属性且无法拦截新增属性和删除属性。所以Vue2在边界场景下必须要求开发者按规矩办事。3. Vue3的响应式重构Proxy Reflect如何做到更完整的拦截3.1 Proxy对比Object.defineProperty从装窃听器到门口装监控Vue3把底层从Object.defineProperty换成了Proxy。要说清楚这个改变我常用一个类比。Object.defineProperty是在对象内部的每个属性上装一个窃听器。你装好窃听器后如果房间里新搬进来一件家具新增属性窃听器是监听不到的如果有人把家具搬走删除属性窃听器也感知不到。Proxy则是在对象门口装了一个总监控。不管是谁只要想读取或修改这个对象的任何属性都必须先经过门口都会被监控记录到。包括新增属性、删除属性、判断是否存在属性in操作符、遍历属性等等全部都在监控范围内。这个差异对开发者最直观的影响就是在Vue3里用reactive定义对象之后随便给它新增属性视图依旧会更新。我开头那种trendData问题放在Vue3项目里根本不会出现。3.2 track/trigger的完整流程Vue3的响应式核心简化为两个函数track和trigger。track负责依赖收集trigger负责派发更新。它们配合构成一套读取时追踪、修改时触发的最小闭环。let activeEffect const targetMap new WeakMap() function track(target, key) { if (!activeEffect) return let depsMap targetMap.get(target) if (!depsMap) { depsMap new Map() targetMap.set(target, depsMap) } let dep depsMap.get(key) if (!dep) { dep new Set() depsMap.set(key, dep) } dep.add(activeEffect) } function trigger(target, key) { const depsMap targetMap.get(target) if (!depsMap) return const dep depsMap.get(key) if (dep) { dep.forEach(effect effect()) } }targetMap是一个WeakMap键是原始对象值是MapMap的键是属性名值是一个Set里面存放依赖该属性的副作用函数。为什么要用WeakMap因为它对键的引用是弱引用当原始对象不再被其他地方引用时可以被垃圾回收不会造成内存泄漏。当副作用函数执行时会被记录为activeEffect然后副作用内部访问任意响应式对象的属性就会触发该属性对应的track把当前activeEffect收集进去。整个过程非常直接。3.3 为什么Reflect不能少写Proxy的get和set陷阱时我见过不少新手直接写target[key]这在大多数情况下能工作但要写对完整语义必须用Reflect。const proxy new Proxy(target, { get(target, key, receiver) { const res Reflect.get(target, key, receiver) track(target, key) return res }, set(target, key, value, receiver) { const oldValue target[key] const result Reflect.set(target, key, value, receiver) if (oldValue ! value) { trigger(target, key) } return result } })Reflect.get(target, key, receiver)的作用是确保get操作里的this指向receiver也就是proxy本身而不是原始对象target。如果你直接写target[key]一旦读取的属性是对象内部方法方法体内的this会指向target后续该方法内部再去访问其他响应式数据就会绕过代理导致依赖追踪失败。Reflect让代理行为与原生操作保持一致这是正确实现响应式的关键细节。3.4 惰性代理带来的性能改变Vue2在初始化时不管嵌套多深都会一次性递归遍历所有属性并劫持。数据规模一大初始化会明显变慢。Vue3的reactive则不同Proxy只需要在读取某个属性时发现这个属性的值还是一个对象再对它进行懒代理。get(target, key, receiver) { const res Reflect.get(target, key, receiver) if (isObject(res)) { return reactive(res) } return res }这就是惰性代理。页面用到了深层对象代理就到那一层页面用不到它就不会白白花费性能。由于Proxy的拦截是整对象级的也不需要像Vue2那样在初始化时对数组索引入侵性能开销更加可控。这也是为什么Vue3在处理大型列表、复杂嵌套数据时初始渲染和响应式创建都明显更轻快的原因。4. 从写法到心智模型Vue2和Vue3响应式API的核心差异4.1 this.count 与 count.value一个盒子引发的习惯变化Vue2里在data中定义count组件内部通过this.count读取模板直接写count。Vue3如果用reactive定义响应式对象还可以state.count读取但如果用ref定义基础类型就必须写count.value。很多从Vue2迁移过来的开发者上来就忘记写.value然后问为什么我定义了ref页面却不变。这个细节的背后是响应式原理的必然。JavaScript本身无法直接拦截一个基础类型变量的读写。let count 1count就是一块普通内存没有任何机制能让框架知道你什么时候读取了它。想要让基础类型变得可追踪唯一办法是把它放进一个盒子对象里比如{ value: 1 }然后对这个盒子对象做代理。ref的本质就是这样——它返回一个带有value属性的响应式对象你访问.value其实就是访问这个盒子里面的值。所谓ref就是给基础类型套了个对象壳这句话一点都不夸张。4.2 ref和reactive的分工为什么基础类型必须用ref源码里ref的实现不仅仅是一个壳。当ref接收的值是一个对象时内部会把对象交给reactive处理class RefImpl { constructor(value) { this._value toReactive(value) // 对象走 reactive基础类型原样保存 } get value() { track(this, value) return this._value } set value(newVal) { this._value toReactive(newVal) trigger(this, value) } }toReactive会判断参数是否是对象是对象就调用reactive否则直接返回原值。所以ref既能放基础类型也能放对象而reactive只能代理对象。使用建议也很简单在setup里写逻辑时基础类型统一用ref对象类型用reactive。有些团队干脆全部用ref统一心智模型这也是一种合理选择。但要注意如果整体对象内部嵌套很深全部用ref会导致写代码时到处.value反而繁琐。我个人的习惯是对象用reactive从外部传入或返回值需要保持独立响应式时用ref。4.3 模板中的自动解包与JS里的手动.valueVue3的模板会自动解包ref所以在模板里写{{ count }}而不是{{ count.value }}。这种魔法只存在于模板顶层并且只针对ref。举个例子在模板中{{ object.id }}里如果object是一个reactive对象访问object.id没有任何问题如果object是一个ref并且ref的值是一个对象那么在object.id这种嵌套路径中Vue3也会自动解包ref后再访问id这是模板编译器的便利。但在组件的setup返回之后的methods函数里或者在组合式函数内部你依然需要手动写.value因为JavaScript不会自动解包。这个差异经常导致困惑模板里能改代码里不能改来回切换要注意语境。4.4 toRefs到底救的是什么reactive对象虽然好用但有一个先天问题对它解构时响应式会丢。const state reactive({ count: 1 }) const { count } state count // 只是局部变量变化state.count 不变视图不变原因很简单解构出来的是一个普通数字和state.count毫无联系。为了应对这种情况Vue3提供了toRefs它会把reactive对象的每个属性都变成一个独立的ref这些ref和原对象保持连接const state reactive({ count: 1 }) const { count } toRefs(state) count.value // 会同步修改 state.count并触发视图更新toRefs在处理setup返回值时尤其方便。如果你在setup里写了一大堆响应式状态想统一返回给模板使用直接return { ...toRefs(state) }就能既保留解构便利又不丢响应性。注意toRefs只处理对象已有的属性运行中新增的属性不会自动生成对应的ref。5. 升级到Vue3后容易踩的响应式坑解构、watch、ref和reactive的纠缠5.1 解构reactive对象后页面为什么不动了这个问题在前面已经点到了。在真实项目中最常见的错误发生在组合式函数返回时function useCounter() { const state reactive({ count: 0, step: 1 }) function increment() { state.count state.step } return { ...state, increment } // 明显的问题count 和 step 被解构成普通值 }直接在返回值里展开state拿出去的对象已经和响应式源头断开了。正确做法是用toRefsreturn { ...toRefs(state), increment }或者干脆返回整个state需要哪个属性就用state.count访问从根上避开解构。如果在模板里发现数据明明变了视图就是不更新先检查一下是不是解构掉了响应式。我在团队里做代码review时这是出现频率极高的一类问题。5.2 watch监听对象属性的正确姿势Vue2中直接watch: { obj.attr: handler }就能监听嵌套属性。Vue3的watch接受三种主要形式直接监听一个ref。监听一个getter函数即返回值的函数。监听一个reactive对象会隐式开启深度监听。如果你想监听reactive对象的某个嵌套属性不能直接写watch(state.count, handler)因为此时state.count被读取后只是传了一个普通值进去后续变化不会触发。应该这样watch(() state.count, (newVal, oldVal) { console.log(count changed, newVal, oldVal) })这里的getter函数在每次副作用执行时都会重新读取state.count从而完成依赖收集。很多从Vue2迁移过来的项目在这里会静默失效最典型的表现就是控制台里依赖项明明变过但watch回调从未执行。另一个容易混淆的点是watch的深度监听。在Vue3中监听reactive对象时默认就是深度监听监听ref时如果ref的值是对象默认不是深度监听需要显式传入{ deep: true }。所以如果你用ref包着一个大对象又想监听某个嵌套字段数据变化触发不了先看看是不是deep没开。5.3 数组和Map/Set响应式的暗雷Vue3的Proxy确实能拦截新增属性和删除属性但数组的特殊操作仍然有不少需要注意的地方。比如通过proxy[1] x赋值是能被监听的但修改数组的length呢Vue3里也会触发更新因为set陷阱拦截到了length属性。但是要小心arr.length 0这种批量清空操作只会触发一次track/trigger而不是每个元素变化都触发一次。如果你的依赖是按索引建立的可能不会按预期全部更新。Map和Set这类集合类型也有自己的暗雷。reactive(new Map())之后直接调map.set(key, value)会被代理拦截吗答案是Vue3内部用了一套单独的集合处理器collectionHandlers来重写这些方法。在源码层面set/get/has/size等属性都被特殊处理过所以大多数场景下直接用就行。但如果你用解构的方式获取map.size到局部变量它就只是一个普通数字后续Map变大时这个变量不会自动更新。建议在Vue3中处理集合类型时尽量不要把size、get结果等缓存到局部变量而是保持通过reactive代理对象实时访问。5.4 性能变化初始化更快但代理本身也要注意把项目从Vue2升级到Vue3后我感受最明显的是大型数据初始化变快了。原因是reactive不再递归遍历所有深层属性而是用到时再代理。这一点在分层较深的后台配置数据上体感非常明显。但Proxy并不是没有成本。每次访问代理对象的属性都要经过一层陷阱处理如果某个热路径上频繁读写同一个响应式对象性能会略逊于原生对象。Vue3也做了很多优化来抵消这个问题比如对只读代理、缓存函数等。在实际开发中我的建议是不要在模板或高频循环里做复杂的响应式对象属性访问。如果有一段数据不需要响应式就直接用普通对象保存比如静态配置、临时计算中间量。使用shallowRef/shallowReactive的场景要清楚它们只代理表层内部变化不触发更新适合性能敏感的容器。这些细节在中小型项目里感受不明显在大数据量表格、实时图表场景中就会暴露出来。6. 手写一个最小响应式Demo把Vue2和Vue3的原理差异落在地上6.1 用defineReactive 一个Dep模拟Vue2不扯太多源码我用最容易读的方式做两个最小实现。先看Vue2风格的极简版const dep new Set() let activeReactiveEffect null function defineReactive(obj, key, val) { Object.defineProperty(obj, key, { get() { if (activeReactiveEffect) { dep.add(activeReactiveEffect) } return val }, set(newVal) { if (newVal val) return val newVal dep.forEach(fn fn()) } }) } function watcher(fn) { activeReactiveEffect fn fn() activeReactiveEffect null }定义一个原始对象并执行watcherconst data { count: 0 } defineReactive(data, count, 0) watcher(() { console.log(render:, data.count) }) data.count 1 // 输出 render: 1 data.newField 2 // 没有任何反应因为 newField 没有 defineReactive这个Demo把Vue2的核心问题几乎完整暴露了newField不会被拦截。6.2 用Proxy track/trigger模拟Vue3再看Vue3风格的极简版let activeEffect const targetMap new WeakMap() function track(target, key) { if (!activeEffect) return let depsMap targetMap.get(target) if (!depsMap) { depsMap new Map() targetMap.set(target, depsMap) } let dep depsMap.get(key) if (!dep) { dep new Set() depsMap.set(key, dep) } dep.add(activeEffect) } function trigger(target, key) { const depsMap targetMap.get(target) if (!depsMap) return const dep depsMap.get(key) if (dep) { dep.forEach(effect effect()) } } function reactive(obj) { return new Proxy(obj, { get(target, key, receiver) { const res Reflect.get(target, key, receiver) track(target, key) return res }, set(target, key, value, receiver) { const oldValue target[key] const result Reflect.set(target, key, value, receiver) if (oldValue ! value) { trigger(target, key) } return result } }) } function effect(fn) { activeEffect fn fn() activeEffect undefined }验证一下const state reactive({ count: 0 }) effect(() { console.log(render:, state.count) }) state.count 1 // render: 1 state.newField 2 // 仍然会有对应追踪这里没有读取 newField 的 effect所以不会输出注意Vue3也不是对一个从未被读取过的属性执行副作用。新增属性后没有effect读取过它自然不会触发任何更新。但如果你在effect里已经访问过整个对象或后续访问了它逻辑会正确收集。这个行为其实比Vue2更自然。6.3 两个Demo对比后面试问法就清晰了把两个Demo放在一起就能回答最典型的面试题Vue2和Vue3的响应式有什么区别。对比维度Vue2Vue3拦截机制Object.defineProperty逐个属性拦截Proxy整对象拦截新增属性不自动响应需要$set自动响应删除属性不自动响应需要$delete自动响应数组索引/长度直接赋值无法响应可以响应初始化性能递归遍历所有属性惰性代理按需拦截集合类型普通数组/对象Map/Set不友好内置Map/Set/WeakMap等集合类型处理器依赖存储属性级DepWatcherWeakMaptarget, Mapkey, Set API心智Options APIthis.root/this.countComposition APIref/reactive显式管理面试时如果只答Proxy比defineProperty强其实没到点子上。更有价值的回答是围绕拦截能力、依赖收集时机、惰性代理带来的性能收益来展开。如果还能顺带手写一个最小Demo讲清楚track和trigger基本就证明你不是背答案了。最后再分享一个我自己的体会。代码写多了以后会发现响应式原理不是面试八股它是你在排查数据变了视图没变这类问题时的第一把尺子。Vue2时代我遇到这类问题会习惯性检查有没有走$set、有没有用spliceVue3时代我会先检查是不是解构丢了响应式、watch的getter写没写对。搞明白底层机制这些排查都是顺理成章的事情。如果你现在还在Vue2和Vue3之间纠结我建议直接选Vue3并把reactive、ref、toRefs、watch这四件事玩明白响应式这套体系就算真正拿下了。
RELATED

相关推荐

BP神经网络光伏发电太阳辐照度预测Matlab仿真实践指南

BP神经网络光伏发电太阳辐照度预测Matlab仿真实践指南

简介:面向光伏发电预测、BP神经网络建模与Matlab仿真学习者,这套源码以太阳辐照度预测为场景,完整覆盖数据加载、网络构建、训练及输出对比流程,可直接运行,适用于课程设计、毕业设计或科研预研。压缩包共4个文件&…

📅 2026/10/18 13:28:11
Linux命令实战指南:从高频命令到高效排障

Linux命令实战指南:从高频命令到高效排障

1. 为什么还是得学Linux命令先说实话,现在图形界面工具越来越强,云服务器控制台点几下就能完成大部分操作。但你会发现真正遇到问题的时候,绕来绕去还是会回到命令行。Linux命令不是门槛,是底层能力。不管是维护服务器、跑数据分析…

📅 2026/10/18 13:28:11
智能引文数据平台功能比较与趋势分析:选型评估与落地实践

智能引文数据平台功能比较与趋势分析:选型评估与落地实践

简介:这份PDF文献《智能引文数据平台的功能比较与趋势分析》面向图书情报、科研评价与学术计量领域的研究者及研究生,聚焦传统引文分析忽视引用情境、无法提炼情感与意图等痛点的解决方案。资源为单份PDF文档,压缩包约5.25MB,内容…

📅 2026/10/18 13:23:10
MORE NEWS

更多资讯

📰

Java AI应用开发实战:框架停更后的技术选型与工程化路径

1. 一个框架停更引发的技术选型焦虑前几天在一个技术交流群里,有位做Java后端的兄弟抛出一个问题:“Spring AI Alibaba是不是停更了?我看仓库好久没动静了,Java搞AI这块是不是没戏了?”群里瞬间热闹起来,有…

📰

Python批量提取PDF表格到Excel:pdfplumber与pandas实战

简介:这份资源面向需要批量处理PDF表格的Python学习者与办公自动化开发者,提供从PDF中一键提取表格并导出Excel的完整实例。包内共14个文件,以png截图、xlsx结果表、pdf样本、ipynb笔记本和py脚本为主,压缩包约18.34MB&#xff0c…

📰

Docker 常用命令速查

Docker 常用命令速查 一、镜像(Image) # 搜索镜像 docker search nginx# 拉取镜像(不指定标签默认 latest) docker pull nginx docker pull nginx:1.25# 查看本地镜像 docker images docker image ls# 删除镜像 docker rmi nginx:…

📰

强化学习智能体 vs 大模型智能体:选型、落地与避坑指南

简介:由快手科技快意大模型知识增强研发负责人毛航宇分享的《强化学习(多)智能体 和 大语言模型(多)智能体》PDF,系统梳理了近十年强化学习智能体与LLM智能体的研究脉络,并沿Deep RL、Transformer-based RL、LLM-based AI Agent的演化路径选讲…

📰

Idea断点调试(debug)详解:从条件断点到远程调试的完整实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

OpenClaw开源AI助手:本地优先部署、插件开发与自动化任务实战

2025年下半年开始,我明显感觉到一个趋势:身边的个人开发者和小团队,渐渐对“云端AI助手”产生了某种倦怠感。订阅费越叠越高,数据全部要上传到别人的服务器上,想改底层的提示词逻辑又改不了,更不用说把AI跟…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬