你是不是也经历过这种情况Vue3 项目写得好好的reactive对象里某个字段改了页面死活不更新或者ref定义的数字在模板里直接写count能显示在 JS 里不加.value就报 undefined更诡异的是把一个ref塞进reactive里改着改着就失灵了。这些问题我都踩过而且不止一次。为了搞清楚 Vue3 的响应式原理我把源码翻了好几遍把ref和reactive的内部实现一点点拆开揉碎才算真正弄明白它们到底在做什么。这篇文章就是想把我在实战中踩过的坑、查过的源码、总结出的规律一次性讲清楚让你不用再走我走过的弯路。这篇文章适合谁看写过 Vue2 正在转 Vue3 的朋友被ref和reactive绕晕的初学者以及面试前想快速系统梳理响应式原理的开发者。我会从响应式底层实现讲起逐步拆解ref和reactive的源码逻辑再结合三类高频踩坑场景给出行之有效的解决方案。所有结论都经过真实项目验证也补充了我在定位 bug 时的排查思路你可以直接拿去做参考。1. 从 Vue2 到 Vue3响应式底层换了一颗心脏很多人上来就背结论Vue2 用Object.definePropertyVue3 用Proxy。但只背结论不够你得知道它俩到底差在哪否则后面理解ref为什么一定要.value、reactive为什么不能解构全是雾里看花。1.1 Vue2 的 Object.defineProperty 到底卡在哪里Vue2 对对象做响应式核心是对每个 key 调用Object.defineProperty重写它的 getter 和 setter。在初始化阶段Vue 会遍历 data 对象的所有属性把每个属性都变成访问器属性。这一步本身没什么问题问题出在三个“先天残疾”上。第一个问题是新增属性和删除属性无法被拦截。因为Object.defineProperty是针对已存在的 key 做的劫持你后面再往对象上挂一个新属性它根本没有经过 setter自然不触发更新。所以 Vue2 才要专门提供Vue.set和Vue.delete这两个补丁 API说白了就是弥补底层机制的漏洞。第二个问题是直接通过下标修改数组元素无法触发更新。数组的索引本质上也是对象的 key但 Vue2 出于性能考虑没有对每个索引做defineProperty而是通过重写push、pop、shift、unshift、splice、sort、reverse这七个方法来拦截变更。这导致this.arr[0] xxx这种操作在 Vue2 里是静默失败的我记得当年排查这类问题排查到怀疑人生。第三个问题是对嵌套对象需要递归遍历而递归发生在初始化阶段。意味着只要 data 里挂了一个深层对象不管有没有被用到都会被递归劫持一遍。对象层级深、体量大时初始化性能损耗明显。这三个痛点基本就是 Vue3 下定决心换底层实现的核心原因。你理解了这三个缺陷也就理解了reactive设计成基于 Proxy 的必然性。1.2 Proxy 为什么能翻盘Proxy是 ES2015 引入的元编程能力。它不针对某个 key 做劫持而是直接代理整个对象。所有对对象的操作比如读取属性obj.a、写入属性obj.a 1、删除属性delete obj.a甚至Object.keys这种遍历操作都会先经过Proxy上的get、set、deleteProperty等拦截器。这带来的最大变化是新增属性、删除属性天然可被拦截不再需要Vue.set和Vue.delete这类补丁 API。访问不存在的属性也能被 get 捕获。数组下标赋值、修改 length 也都能被 set 捕获。这些在 Vue2 里的老大难在 Vue3 中从机制层面就解决了。但Proxy也有一个很重要的技术细节它只能代理一层。什么意思呢proxy.get拦截到的只是当前这一层属性的读取如果读取到的值本身是一个对象你需要对这个对象再做一层 Proxy才能保证深层嵌套也是响应式的。这也是 Vue3reactive实现里get拦截器最关键的一段逻辑——拿到原始值后判断是不是对象是对象就递归转成响应式。后面的源码解析我们会详细看到。另外一个影响是兼容性。Proxy无法被 polyfill这意味着 Vue3 直接放弃了 IE11 的支持。这也是为什么很多还在用 IE 的旧项目Vue2 才是务实选择。不是 Vue3 不好而是你的用户环境不允许。选型时这台账也要算清楚。2. ref 和 reactive 的实现差异一个包了壳一个没包壳ref和reactive到底有什么区别这是面试高频题也是实际开发最容易搞混的地方。从源码角度去看它俩的关系并没有想象中那么复杂reactive是基础能力ref是在reactive之上封装出来的一种特殊形态。2.1 reactive直接给对象套 Proxyreactive的入口很直接。它接收一个对象参数然后通过new Proxy(target, handler)生成一个代理对象。这里最有意思的是源码里的createReactiveObject函数它做了三件小事。第一件是判断 target 是不是对象不是对象就报错并返回原值。很多新手以为reactive(0)能用其实这是个误区reactive只接收对象。第二件是判断 target 是不是已经被代理过如果代理过就直接返回已有代理避免重复代理同一个对象。第三件是判断 target 是不是原始只读对象这种场景主要服务于readonly相关 API。Proxy的 handler 里最核心的拦截器是get和set。get里做了一件影响深远的事情懒代理Lazy Proxy。它拿到Reflect.get的原始值后判断这个值是不是对象如果是就想办法转成响应式。这里我说“想办法”是因为源码里有个toRaw和reactive的组合判断目的是避免对已经代理过的对象二次代理。懒代理带来的直接好处是性能提升初始化时不需要递归遍历整个对象只有真正读取到嵌套对象那一层时才做代理。这也是 Vue3 在大对象场景下初始化性能优于 Vue2 的原因之一。set拦截器里有个很容易忽略的细节它需要区分新增属性和更新已有属性。方式是通过hasOwnProperty判断 key 是否存在然后分别触发trigger。这个区分在有些业务场景下很重要比如你需要监听某个字段是新增还是更新时在watch回调里就能拿到不同类型的信息。2.2 ref给基本类型造了一个“壳”ref存在的意义很简单让基本类型也能被响应式系统追踪。reactive只代理对象而项目里最常见的count 0、name abc这种基本类型状态总不能每次都包一层{ value: 0 }再传给reactive吧于是ref就是一个工厂函数它在内部帮你做了这个壳。源码里ref会创建一个RefImpl类的实例。这个类有两个核心成员_value和__v_isRef。_value存的是真正的值注意它在写入时有个转换逻辑如果传入的是对象它会用reactive转成响应式然后存进去如果传入的是基本类型就原样保存。__v_isRef是一个标记值为true表示这是一个 ref 对象。RefImpl的get value()里调用了track收集依赖set value()里调用了trigger触发更新。类比的视角看ref就是把一个对象的value属性用getter/setter单独劫持了行为上很像一个只有value一个字段的微型reactive。但注意它没有用Proxy而是用了类的访问器属性。这是ref和reactive在实现机制上一个肉眼可见的区别。在 JS 里访问ref的值必须用.value这是设计上故意做的区分。为什么因为基本类型没有引用语义如果不加.value响应式系统就不知道你在访问哪个字段。模板里之所以可以省略.value是因为 Vue 的模板编译器在编译阶段做了自动解包它会读对象的__v_isRef标记如果是 ref就自动访问它的.value。这个机制名字叫ref unwrapping。// 简单模拟 ref 的核心逻辑 class RefImpl { constructor(value) { this.__v_isRef true this._value isObject(value) ? reactive(value) : value } get value() { track(this, get, value) return this._value } set value(newValue) { if (hasChanged(newValue, this._value)) { this._value isObject(newValue) ? reactive(newValue) : newValue trigger(this, set, value) } } }2.3 ref 内部其实也会调用 reactive把ref和reactive完全对立起来看待是不对的。源码里ref在遇到对象入参时会直接复用reactive的代理逻辑。把ref({ count: 0 })打印出来你会发现它的.value就是一个Proxy实例。这意味着嵌套在 ref 对象中的深层字段并不是通过RefImpl的 getter 追踪的而是通过reactive的 Proxy get 追踪的。这个细节直接解释了一个高并发踩坑点ref不是只有.value这一层响应式它内部嵌套的对象同样具备深度响应式。你在代码里写obj.value.deep.deep2更新的触发链路一部分靠RefImpl.value的 setter一部分靠深层 Proxy 的 set 拦截器。两条链路共同工作互相补充。这也引出一个实用的判断标准当你需要管理一个深层复杂对象时用reactive更自然当你需要管理单个基本类型值或一个会被整体替换的对象时用ref更顺手。下面一节我会用真实踩坑经历把这条标准再展开细说。3. 我踩过的三个坑解构、深层响应式、混用纸上谈兵终觉浅。这一节我把自己在真实项目中踩过的三个坑完整还原出来包括错误代码、现象表现、排查过程和最终解法。这三个坑都是社区里高频出现的经典案例看懂了能帮你省掉不少排查时间。3.1 坑一reactive 对象解构后更新失效业务场景是做一个用户信息弹窗我从reactive对象里解构出username和email两个字段直接绑定到表单上。代码如下const state reactive({ username: 老李, email: laoliexample.com }) // 弹窗绑定的局部变量 const { username, email } state function updateName() { username.value 新名字 // 错误写法 }正确的是直接改state.username。为什么解构出来的变量不行因为解构发生时username拿到的是state.username的原始值字符串它是一个普通变量与Proxy的 get/set 没有任何关联。你修改这个变量本质上是在修改一个局部变量Proxy的 set 拦截器根本感知不到。toRefs就是为这个场景设计的。它会把reactive对象的每个字段转换成ref再解构出来就同时保留了响应式关联import { reactive, toRefs } from vue const state reactive({ username: 老李, email: laoliexample.com }) const { username, email } toRefs(state) function updateName() { username.value 新名字 }原理上toRefs创建的每个字段 ref 的valuesetter 内部最终写的是原始对象state上对应的 key。这样无论你是通过proxy修改还是通过 ref 修改最终都落到同一个物理存储位置上响应式自然就保住了。还有一个细分场景如果你只需要把一个字段变成 ref可以用toRef。它比toRefs更精确不必把整个对象的所有字段都转一遍。toRef(state, username)就可以拿到一个可写的、与state.username同步的 ref。3.2 坑二把 ref 直接塞进 reactive 数组或对象里我在做购物车模块时想维护一个商品列表每个商品数量用ref单独管理然后用一个reactive数组把所有商品状态装起来。简化的代码如下const cart reactive([]) const count1 ref(1) const count2 ref(2) cart.push(count1) cart.push(count2) console.log(cart[0].value) // 1页面渲染出来后选择商品数量点加减按钮视图没有变化。排查了一下午最后发现reactive数组里存的是RefImpl实例本身它不是被自动展开的。模板里{{ cart[0].value }}挂上去倒不是拿不到值但 Vue 的自动解包只在顶层 ref生效数组里的 ref 不会自动打开。正确的做法有两种。第一数据层彻底用ref管理列表ref([{ count: 1 }, { count: 2 }])操作时通过.value访问和替换。第二如果非要用reactive数组就不要把RefImpl实例放进去而是把ref.value放进去放弃对单个基本类型的 ref 追踪改用整个数组的splice或整体替换来触发更新。顺带一提reactive对象内部嵌ref的情况其实是被 Vue 官方支持的。reactive({ count: ref(0) })这种写法访问state.count会直接得到 ref 内部的值不需要再加.value。原因是reactive的 get 拦截器里做了isRef判断遇到 ref 就自动解包。但数组不享受这个待遇你往reactive数组里塞 ref操作起来非常别扭。这也是reactive与数组配合时最需要小心的盲区。3.3 坑三在自定义 hook 里把 reactive 返回出去调用方解构后又失效这个坑是我封装组合式函数时踩的。写了一个useUserList的 hook内部用reactive管理状态最后返回出去export function useUserList() { const state reactive({ list: [], loading: false, error: null }) async function load() { state.loading true // ... state.list await fetchList() state.loading false } return { ...toRefs(state), load } }看起来已经用了toRefs没有明显毛病。但调用方这么写时就出问题了const { list, load } useUserList() watch(list, () { // ... })问题出在toRefs(state)转出来的每个字段虽然是 ref但它是关联到当前这个state代理对象上的。如果 hook 内部在某个时机对state整体重新赋值比如state reactive(newData)之前导出的 ref 仍然指向旧对象链路就断了。这种情况在重置状态、重新拉取数据时特别常见。解决方案是不要在 hook 里整体替换reactive对象而是只替换它的字段。把state reactive(newData)改成Object.assign(state, newData)。这样state这个 Proxy 引用永远不变所有依赖它派生出去的 ref 就都不会失效。这是一个典型的引用一致性陷阱。理解了这一点你也就明白了为什么那么多人建议在 hooks 里直接用ref更省心——因为ref的.value只是一个属性整体替换xxx.value newData时不需要换掉整个对象引用依赖追踪链路不会断。4. 常见问题速查与排查技巧实录这一节我把自己做项目时高频遇到的响应式问题归类整理成一个排查清单。碰到类似现象时直接对照排查效率能提升不少。4.1 修改了数据页面不更新先查这几件事最常见的响应式 bug 现象就是数据改了视图不刷新。按我的经验检查顺序应该从简单到复杂逐条排除。第一确认你有没有把ref当普通对象用。在 JS 里忘记加.value是新手高频问题比如const count ref(0); count实际上是把整个RefImpl对象赋了一个新值数值本身没变set value也没被触发。反应在页面上就是数字纹丝不动。第二确认你是否修改了reactive对象的整体引用。比如let state reactive({ count: 0 })然后state reactive({ count: 1 })。这种写法会让原本的 Proxy 失去依赖追踪页面监听的是旧 Proxy。正确做法是用Object.assign或者逐字段修改。第三确认你在模板中绑定的字段路径对不对。reactive对象被解构后再绑定或者绑定了ref对象本身而不是.value都可能导致显示不更新。第四检查是否在v-for中使用了索引作为 key 且依赖了数组长度变化。Vue3 的 diff 算法已经考虑到了很多边界情况但某些极端操作比如直接修改array.length 0虽然 Proxy 能拦截但依赖收集和触发更新的颗粒度不一定符合你的预期。更稳妥的方式是用splice或者整体重新赋值来清空数组。4.2 ref 和 reactive 到底怎么选综合源码机制和实战经验我给出的选型建议分三条线。如果你管理的是单个基本类型变量或者这个值会整体被替换比如loading、pageNum、searchText、count用ref。如果你管理的是一个结构稳定的对象或数组且你会频繁访问它的深层字段比如表单对象、接口返回的数据包用reactive更顺手。第三个建议比较反直觉在组合式函数和公共 hook 的返回值里优先用ref。原因就是我刚才讲的引用一致性问题。hook 内部状态会被调用方消费如果返回的是reactive对象调用方很容易在解构、整体赋值中踩坑返回ref则省心很多整体替换xxx.value newData完全不会断响应式。有人问reactive是不是可以存在ref我的回答是可以但要分场景。reactive({ count: ref(0) })这种写法是官方支持的访问会自动解包。但如果这个对象会被解构、会被整体替换那还是建议把状态整体管理在一个ref上。4.3 调试响应式对象的小技巧调试 Vue3 响应式时一个很实用的方法是打印对象的__v_isRef和__v_raw字段。__v_isRef帮你确认当前操作对象是不是 ref__v_raw帮你拿到 Proxy 背后真正的原始对象方便对比值变化。const state reactive({ count: 0 }) console.log(state.__v_isRef) // undefined说明不是 ref console.log(state.__v_raw) // 原始对象 { count: 0 } const c ref(0) console.log(c.__v_isRef) // true说明是一个 ref还有一个技巧是在 watch 回调里打印新旧值并比对引用。watch默认回调的第一个参数是新值第二个是旧值。如果新旧值指向同一个引用说明修改发生在对象内部watch 需要开启deep选项才能稳定感知到嵌套层级的变更。watch(() state.deepField, (newVal, oldVal) { console.log(新值:, newVal, 旧值:, oldVal) }, { deep: true })这里注意一个细节watch的getter写法里如果直接返回一个ref对象Vue 会自动解包但如果你返回的是一个函数计算出来的深层字段建议配合deep: true使用。否则你盯着某个深层字段看却不知道它根本没被追踪。另一个我自己常用的手段是effectScope。在复杂页面里同时存在多个响应式副作用时用effectScope可以统一管理它们的开启和关闭避免内存泄漏。这个 API 在平时容易被忽略但在长列表页、大数据量可视化大屏等场景非常实用。5. 再看几个偏底层的响应式机制computed、customRef 和自动解包前面把ref和reactive的核心讲透了但 Vue3 响应式系统里还有几个关联机制理解了它们能让你在遇到一些特殊场景时不再发怵。这些机制在源码层面和ref、reactive也共用同一套依赖收集和派发更新的模型。5.1 computed 的本质就是创建一个自定义 ref看 Vue3 源码你会发现computed的实现核心和ref非常相似。它也是创建一个ComputedRefImpl实例这个实例同样具有__v_isRef: true标记同样暴露.value。不同之处在于computed的.value是只读的不传 setter 时它的值不是直接存储的而是通过 getter 实时计算生成的。首次访问时它会执行 getter 并缓存计算结果依赖项变化时会触发依赖更新并重新计算。这套机制保证了计算属性自身也是响应式的。import { ref, computed } from vue const price ref(100) const count ref(2) const total computed(() price.value * count.value) console.log(total.value) // 200 price.value 150 console.log(total.value) // 300自动重新计算模板里{{ total }}直接用不需要写.value原因还是自动解包。这个体验和 ref 完全一致。理解computed是指的 ref 扩展的另一个价值点在于你在设计自定义组合式函数时可以很自然地把计算逻辑封装成computed返回给调用方。调用方拿到的是一个只读响应式值不需要关心内部依赖了哪些字段用起来非常干净。5.2 customRef 能做出什么花活customRef是一个相对冷门但非常强大的 API。它可以让你自定义响应式值的 get 和 set 行为实现类似防抖、异步校验、历史记录等能力。比如实现一个防抖 ref输入变化后 500ms 才真正更新依赖import { customRef } from vue function useDebouncedRef(value, delay 500) { let timeout return customRef((track, trigger) { return { get() { track() return value }, set(newValue) { clearTimeout(timeout) timeout setTimeout(() { value newValue trigger() }, delay) } } }) }使用方式与普通ref一模一样但写入行为完全由你控制。我自己在搜索框场景用过这个 API效果很好用户输入停顿了才触发搜索请求频繁输入期间只做本地状态维护。customRef的底层逻辑和ref一致都是通过track收集依赖、trigger派发更新。你甚至可以基于它实现带日志的 ref帮助你在调试时跟踪每次修改的调用栈。5.3 自动解包只有模板和顶层 ref 才生效自动解包auto unwrapping是ref使用体验上的关键设计。模板里可以直接访问ref名不需要加.value。但它的生效范围有几个严格的限制理解不透就会踩坑。第一自动解包只在响应式对象内部生效普通对象不行。比如const obj { count: ref(0) }模板里{{ obj.count }}会显示[object Object]除非你用reactive包裹它obj才会在读取时自动解包count。第二整个对象是顶层 ref 时模板可以自动解包它。但如果它在嵌套路径下比如const list ref([1, 2])模板里{{ list[0] }}是没问题的因为list是顶层 ref模板编译器自动处理了。但如果这个list又被塞到另一个reactive对象里state.list[0]的行为就会受到上面说的数组不解包规则影响需要你自己测试确认。第三解包是浅层的。ref({ nested: { deep: 1 } })时.value.nested已经是一个响应式 Proxy 对象了但模板访问路径需要严格沿着value.nested.deep走。如果你在代码里先解构了const { nested } refObj.value拿到的nested是响应式 Proxy 的一个字段它仍然是响应式的但已经不是通过 ref getter 追踪了而是通过 Proxy 的 get 追踪。// 注意三种情况 const a ref({ list: [1, 2] }) // 情况1模板用 a.list —— 可以顶层 ref 内部字段 // 情况2JS 里 a.list —— 不行必须 a.value.list // 情况3a.value.list.push(3) —— 可以深层响应式这一节的内容做个小结就是只要你在 JS 里动ref一律写.value不用思考模板里不用写.value因为编译器帮你处理了别把ref实例塞进reactive数组里那是自找麻烦。这三条记住了90% 的响应式坑都能绕开。6. 排查响应式问题时的现场实操记录理论知识讲了这么多最后把我一次实际排查的过程完整分享出来。这是一个抽奖转盘页面奖品列表和抽奖状态都用响应式管理页面总是出现奖品列表更新后剩余次数不更新的问题。定位到代码后发现第一处可疑点剩余次数用的ref抽奖成功后直接在定时器里修改const remainCount ref(3) function startLottery() { // ... setTimeout(() { remainCount remainCount - 1 // 注意这里没加 .value }, 1000) }remainCount ...实际上是在给RefImpl实例整体重新赋值原来的 ref 对象没有发生任何变化set value根本没执行页面当然不会更新。改成remainCount.value remainCount.value - 1后这个问题立即解决。第二处可疑点在奖品列表原来代码用了整体替换let prizeList reactive([]) function reloadPrize() { // 后端返回新奖品 prizeList reactive(newPrizeArray) // 这样是不行的 }reloadPrize执行后页面引用的prizeList变量已经不是原来那个 Proxy 了但模板编译时绑定的是旧 Proxy 的内存地址新数据完全没地方通知视图。改成prizeList.splice(0, prizeList.length, ...newPrizeArray)之后由于splice操作会触发set拦截器视图即时刷新。排查这两处问题用了不到十分钟但如果不是提前知道这些响应式机制的原理定位流程恐怕要在 console 里耗上一个下午。这类问题有一个共同特点编译都不报错浏览器控制台也不会红但业务逻辑就是不对。原因也很一致你在用普通对象的心态操作响应式代理对象。这里我建议每个 Vue3 项目里都可以加一个自检清单每次出现页面不更新就先问自己三个问题——我改的是不是reactive原始对象我是不是给 ref 忘了.value我有没有不小心把整个响应式对象重新赋值了把这三个问题养成条件反射排查速度会快得多。7. 最后分享一个让我少走弯路的小技巧除了上面这些硬核原理我在实际项目里还发现一个小技巧它跟性能有关也跟代码可维护性有关。很多人在写 Vue3 响应式数据时喜欢“全量响应式”不管用不用得上把所有状态都塞进reactive或者到处都是ref结果页面一复杂依赖追踪规模非常庞大。我的建议是拆分响应式数据的边界不要把整个页面的状态堆进一个对象里。比如表单数据、列表数据、UI 状态分别用不同的reactive或ref管理。这不仅让响应式依赖更小、变更触发更精准也让调试时能更快定位到底是哪一块状态出了问题。配合这个习惯我在团队里还推行了一条约定把“该用 ref 还是 reactive”的判断收敛到项目规范里尽量统一写法。单个基本类型变量和会被整体替换的数据用ref稳定的对象或数组优先用reactivehook 对外返回值统一用ref包裹。规范一旦定了团队里因为 ref 和 reactive 互相混用产生的隐性 bug 会大幅减少。说到底ref和reactive没有谁优谁劣它们的核心机制是同一套响应式系统只是面向不同使用场景的两种外壳。把原理吃透了用哪个都是你的工具。如果你刚接触 Vue3这篇文章里的三个坑一定要在自己的项目里亲手复现一遍并修复一遍这个过程的收获会让你对响应式系统的理解加深一个层次。