☰
Vue3响应式原理:ref与reactive的坑与避坑指南
2026/9/30 8:33:39 网站建设 项目流程

被 ref 和 reactive 坑过的人,大概都经历过大同小异的诡异场景:控制台打印数据,值确实变了;页面上的视图,纹丝不动。你要是再仔细看看代码,逻辑也没写错。我在把公司老项目从 Vue2 迁到 Vue3 的那段时间,前前后后踩了三次这种"数据改了页面死活不刷新"的坑,最后靠反复翻源码、写测试用例,才算把 Vue3 的响应式原理真正搞明白。这篇文章就是那几次"踩坑—翻源码—做实验"的完整复盘。如果你也在用 Vue3 写项目,或者正在准备 Vue3 面试,花十来分钟读完这篇,能让你少走好几个月弯路。

1. 先把响应式原理的地基夯实

1.1 从 Object.defineProperty 到 Proxy,Vue3 做对了什么

Vue2 的响应式是建立在 Object.defineProperty 上的,它只能拦截对象上"已经存在的属性"的 get 和 set。你可以把它想象成给一栋楼的每一层装一道闸机,楼上每新加一层,你得先去装闸机才能监控。新增属性、删除属性、数组通过下标修改元素这些事情,defineProperty 天然监听不到,所以 Vue2 才需要 $set、$delete 这些额外 API。说白了这不是框架能力不够,是底层 API 天生有边界。

Vue3 换成了 Proxy,它可以拦截整个对象层面的 13 种操作,包括 get、set、has、deleteProperty、ownKeys、getOwnPropertyDescriptor 等。对应到生活里,就是大楼门外装了一个总门禁,任何人进出、查人、删人、盘点人数,全部都要过门禁。所以新增属性、删除属性根本不需要额外 API,响应式系统天然覆盖。这也是为什么 Vue3 里已经不存在 $set 这个 API 了。

不过 Proxy 也有代价。能拦住的,只有那些"经过 Proxy 包装后的对象"上的操作。如果你把 Proxy 底下的原始对象悄悄传给了某个第三方库,第三方库直接在原始对象上改数据,那么拦截器完全不知道,页面自然就不会更新。很多"数据改了页面没反应"的问题,根源其实在这里,别急着怀疑框架 bug。

1.2 响应式的核心循环:track 与 trigger

Vue3 响应式的核心是两个函数:track 和 trigger。track 负责收集依赖,trigger 负责触发更新。打个比方,你在小程序里点了一个按钮,服务端数据变化后小程序要刷新页面。track 就像是把"这个页面依赖了哪条数据"注册到后台;trigger 就像是数据一变,后台立刻发消息推送,叫页面重新渲染。

具体到代码逻辑:

  • 当你读取一个响应式对象的属性时,会走 Proxy 的 get 拦截器,在 get 内部调用 track(target, key),把"当前正在运行的副作用函数"和这个 key 的关系记录下来。
  • 当你给这个属性赋值时,会走 set 拦截器,在 set 内部调用 trigger(target, key),找到所有依赖这个 key 的副作用函数,挨个重新执行。

什么算"副作用函数"?组件渲染函数、computed、watch、watchEffect 其实都是 effect。Vue3 内部维护了一个全局的 activeEffect 指针,任何时候只有一个 effect 正在执行。track 记录的就是这个 activeEffect。

这就像外卖平台:你下单(effect 读取数据)的时候,平台把你的送餐地址(依赖关系)登记好;商家出餐(数据变化)后,骑手按登记地址配送(触发 effect 重新执行)。没有这个登记表,出餐了也没人知道你该送到哪。

1.3 依赖数据结构:WeakMap + Map + Set 的默契配合

Vue3 的依赖存储结构看起来是三层嵌套:WeakMap<原始对象, Map<属性名, Set >>。

第一层用 WeakMap 以原始对象为 key。为什么不用 Map 而是 WeakMap?因为 WeakMap 是弱引用,当原始对象被垃圾回收时,对应的依赖记录也能被回收,不会出现内存泄漏。这是 Vue3 源码里一个容易被忽略但至关重要的设计。

第二层 Map 以属性名为 key,用来区分同一个对象上不同属性的依赖。第三层 Set 装着所有依赖该属性的 effect 函数,用 Set 可以天然去重,避免同一个 effect 被重复收集。

还是用办公楼做类比:WeakMap 相当于整栋大楼的台账,每一层楼(对象属性)有一份员工名单(Set)。某个员工迟到(数据变化),楼管直接从名单上挨个打电话(触发 effect)。楼拆了(对象被回收),台账也跟着销毁,不会留下死信息。

这个三结构模型理解了,再去看 Vue3 的响应式面试题基本就稳了一半,因为很多题绕到最后都是在问"依赖是怎么收集的、又是怎么触发的"。

2. ref 和 reactive 的源码级差异

2.1 reactive:给对象套上多层代理,而且带缓存

reactive 做的事情说起来不复杂:入参必须是对象或者数组,返回一个 Proxy 代理实例。但有几个细节值得注意。

第一,深度代理是懒加载的。比如 reactive({ a: { b: 1 } }),刚开始只代理最外层。当你访问 state.a 时,get 拦截器发现 a 的值还是普通对象,才递归调用 reactive 给内层对象也套上代理。这样做的最大好处是性能,一个巨大的配置对象不会在初始化时就对所有层级做代理,只有真正用到的数据才会被代理。

第二,reactive 有缓存机制。同一个原始对象多次调用 reactive,返回的是同一个代理实例,不会重复创建。源码里是一张 WeakMap<原始对象, 代理对象>。这也是为什么你会看到"同一个对象在多处使用,改一处会同时触发多依赖"的原因,因为它们本质是同一个 Proxy。

第三,reactive 处理不了基本类型。你不可能 new Proxy(1, handler),所以 number、string、boolean 这些值必须用 ref 包一层。这也是 ref 存在的最根本理由。

2.2 ref:给基本类型穿一件外套,本质是包装器

ref 的底层是一个 RefImpl 类,实例上有两个关键字段:_value 和 value。当你访问 .value 时,get 里会做依赖收集;当你给 .value 赋值时,set 里会做触发更新。

如果 ref 接收的对象类型,内部会交给 reactive 处理,也就是 toReactive(value) 会把对象转成响应式代理。这意味着 ref({}) 和 reactive({}) 在深层响应能力上是等价的。我简化一下源代码,长这样:

class RefImpl { constructor(value) { this.__v_isRef = true this._value = toReactive(value) } get value() { trackRefValue(this) return this._value } set value(newVal) { if (hasChanged(newVal, this._value)) { this._value = toReactive(newVal) triggerRefValue(this) } } }

看这段代码就能理解:ref 就是一个带响应式能力的"小盒子"。基本类型进盒子,value 就是原始值;对象类型进盒子,value 自动变成 Proxy。

那为什么模板里写 {{ count }} 不用 .value?因为 setup 返回的对象在进入模板前会被 proxyRefs 处理,自动把 ref 解包成 value。这是框架给你开的便捷通道,不是 ref 本身拥有魔法。

2.3 到底该选 ref 还是 reactive,我的取舍逻辑

关于这个,社区里吵了很久。有人说都用 ref,有人说大对象用 reactive。我这里给一个我目前觉得最顺手的规则。

默认情况下,能 reactive 就用 reactive,因为直接操作对象属性代码最干净。比如一个表单对象,有十几个字段,你用 reactive 定义,模板里写 state.name、state.age 就完事了;如果用 ref 写,你得写 state.value.name,多一个 .value 非常别扭。而且 reactive 在 JS 代码中修改时,state.name = 'xx' 一目了然。

单个值、或者需要被多个模块共享的状态,用 ref。比如 loading、pageNum、scrollTop、定时器句柄这种单一值,用 ref 保存,配合 computed 和 watch 非常方便,而且 .value 这种写法反而是在提醒你:这是一个引用类型,不要直接把它丢进函数里。

不过要注意的是,团队里最好统一风格,不要一会儿 reactive 一会儿 ref。混用本身不是大问题,但混用加解构,就容易踩到我后面要讲的坑。

3. 我踩过的三个坑

3.1 坑一:解构一时爽,响应火葬场

这个坑我是在一个筛选表单页面踩的。当时写法大概是:

const state = reactive({ keyword: '', pageNum: 1, pageSize: 20, userInfo: { name: '小明' } }) // 错误示范:直接解构 const { keyword } = state keyword = 'abc' // 这只是改了一个普通字符串变量,state.keyword 完全没动

你可能一眼就看出来了,state 是 reactive,但是解构出来的 keyword 只是个普通字符串,改 keyword 跟 state.keyword 没有半毛钱关系。我当时排查了半天,最后在 console 里打了个 log,才意识到 keyword 已经完全没有响应性了。

这里有个特别容易误导人的点:如果你的解构对象是嵌套对象,解构出来的还是响应式的。因为 reactive 深层代理后,state.userInfo 本身就是一个 Proxy:

const { userInfo } = state userInfo.name = '小红' // 这个是响应式的!userInfo 本身还是 Proxy

这种"对象解构没事、基本类型解构就死"的不对称,让很多人以为解构没问题,直到解构到一个基本类型才翻车。我的建议是:reactive 对象一律不要直接解构,尤其是基本类型字段。要用就 toRefs,后面有说。

3.2 坑二:整体替换对象,一夜回到解放前

第二个坑出现在表格加载数据的时候。我从接口拿到列表,脑子一热写成了整体赋值:

let state = reactive({ list: [] }) // 接口回调里 const res = await fetchList() state = { list: res.data } // 错误示范!直接把 Proxy 扔了

如果我用 let 定义 state,这个错误根本不会报错,但它会把 state 换成普通对象,模板里所有渲染全部失效。这个问题在调试器里很难看,因为控制台打印 state 明明就是 { list: [...] },但页面就是不渲染。后来我是用 watchEffect 打了个 log 才确认,原来 state 已经不是 Proxy 了。

正确的做法有两种。一种是只改属性不改引用,把接口数据塞进已经存在的响应式对象里:

state.list = res.data

另一种是用 Object.assign 把批量字段合并进去:

Object.assign(state, { list: res.data })

这两个写法都能保住 Proxy 身份,数据更新后页面能正常刷新。

这个坑为什么常见?因为大家从 Vue2 过来时习惯了直接把 data 里的一个对象整体替换,而从后端返回的数据去替换也是潜移默化的习惯。关键是理解"响应式对象这个身份一旦被替换就没了",而不是"我赋值给一个响应式变量就万事大吉"。

3.3 坑三:reactive 和 ref 混用,一份数据两份状态

第三个坑最折腾,也是我在一个多 Tab 业务里踩的。当时为了让一个表单对象在多个组件间共享,我先用 ref 保存了初始值,后来为了操作方便,又在组件里 reactive 了一下,结果一份数据出现了两个"状态源"。

const formTextRef = ref({ formText: 'hello' }) // 某个函数里,为了方便响应式操作,把初始值再包一层 const formProxy = reactive(formTextRef.value)

看这段代码。formTextRef 保存的是普通对象,formProxy 是响应式代理,但你修改 formProxy.formText 时,formTextRef.value 还是原来的字符串,完全不知道你改了。反过来,如果你直接改 formTextRef.value,formProxy 也不知道。

为什么会这样?因为 reactive(formTextRef.value) 是"基于 formTextRef 当前的 value 创建一份代理",它和 formTextRef 之间没有任何实时同步关系。Proxy 只能套在原始对象外面,当原始对象被 ref 包住时,如果你不通过 .value 去拿原始对象,reactive 拿到的就是一份"值拷贝",自然不会双向互通。

这个坑教会我一个原则:同一个数据源,必须在整个项目里只有一个响应式的入口。要么走 ref,要么走 reactive,不要在多个地方用不同的 API 分别包一次。特别是在团队协作场景里,别人在另一个组件里用 ref 改了值,你以为 reactive 这边会跟着变,结果白白流失了联动。

顺带说一句,如果你确实需要在 reactive 里放 ref,要记住三个边界:

  • 直接属性会自动解包,state.loading 读出来就是 ref 的 value。
  • 数组和 Map/Set 里的 ref 不会自动解包,你得 .value 访问。
  • 解包只发生在那一层属性上,不要指望它穿透到更深层。

这就是我在实际开发里看到的第不知道多少位同学踩过的坑了。

4. 响应式调试与避坑工具箱

4.1 toRefs 与 toRef:把失去的响应性找回来

toRefs 的作用是把 reactive 对象里的每个属性都转换成 ref,返回一个普通对象。这样你就可以在两个世界里飞:外层可以随便解构,解构出来的是 ref;内层访问值用 .value,模板中又可以自动解包。

const state = reactive({ keyword: '', pageNum: 1 }) const { keyword, pageNum } = toRefs(state) keyword.value = 'vue3' // 会同步修改 state.keyword

toRef 是单属性版,更省性能,适合只打算用其中一个字段的场景。而且 toRef 返回的 ref 和源对象是同步的:改 countRef.value,state.count 也会变,反之亦然。这个同步关系在很多场景里非常好用,比如要把一个 reactive 对象里的某个字段作为独立响应式数据传给子组件时,先用 toRef 取出来再传,子组件内 .value 修改也能同步回父级。

4.2 shallowRef 与 triggerRef:性能与精细控制

如果你有一个对象,不关心对象内部字段的深度变化,只关心整体被替换的情况,shallowRef 是个性能利器。

const config = shallowRef({ theme: 'dark', chart: hugeObj }) config.value.theme = 'light' // 不会触发更新 triggerRef(config) // 手动触发一次

shallowRef 不会对 value 内部做深度代理,所以 config.value.theme = 'light' 这种深度修改不会自动触发。如果你确实想手动触发渲染,必须调用 triggerRef。这在管理一些大体积、低频更新的对象时非常有用,比如图表配置、大型静态数据。

注意,浅响应不代理内部,不代表内部完全不能更新。你可以更新内部值,只是不会自动触发渲染。如果你能接受"改完手动 trigger"或者"整个替换 value",性能收益是很明显的。很多后台管理系统首屏卡顿的问题,就是因为在一个巨型响应式对象上做了深度代理,换成 shallowRef 能立刻缓解。

4.3 响应式边界清单:这些情况不会更新

这一节把容易翻车的边界情况整理成一个速查清单:

  • Object.freeze 冻结的对象不会响应,因为 Vue 检测到 frozen 会直接返回原对象,不做代理。
  • markRaw 标记的对象不会被代理,适合放一些不需要响应的第三方实例。
  • 第三方库拿到的是原始对象时,对它的修改都不会触发更新。
  • 数组直接改 length 和按下标赋值,Vue3 的 Proxy 是可以捕获的,这点比 Vue2 强不少,但要小心不要整体替换数组。
  • Map/Set 本身可以用 reactive(new Map()),访问时 map.get/set 都会走代理,但解构出来的方法有 this 绑定的坑要留意。
  • setTimeout、Promise 回调里修改响应式数据是正常的,响应式不是局限于同步代码。

如果你发现数据改了但视图不更新,第一件事不是猜框架 bug,而是写一行 watchEffect 去观察数据:

watchEffect(() => { console.log(state.count) })

如果 watchEffect 里能正常触发,说明响应式链路是通的,问题大概率是你自己的引用条件写错了。这个调试方式简单、直接,比单步断点高效得多。

5. 团队落地建议与面试考点

5.1 一套不会吵起来的 ref/reactive 使用规范

我自己在团队里推过一段规范,核心就四条:

  • 一个数据源只保留一个响应式入口,禁止重复包装。
  • reactive 对象禁止直接解构,需要解构用 toRefs。
  • 禁止对 reactive 变量整体赋新对象,想批量更新用 Object.assign。
  • 基本类型状态一律用 ref,对象类型看团队统一风格。

有了这四条,大部分人遇到的诡异问题都能规避。Code Review 的时候,我会重点盯那些"从 reactive 对象解构基本类型""直接给 reactive 整体赋值"的写法,一出现就立刻打回。办法听起来很笨,但真的比出 bug 后再排查省太多时间。

另外,建议把 watchEffect 当成日常调试利器用起来。写代码时如果心里没底,临时加一个 watchEffect,等确认链路没问题,再删掉这行临时日志。这套流程我已经用了大半年,排查效率明显提升。

5.2 面试官最喜欢的响应式原理题

如果你在准备 Vue3 面试,下面这些点是几乎绕不开的:

  • ref 和 reactive 有什么区别?基本类型和对象类型各自该怎么选?
  • 为什么 Vue3 用 Proxy 替换 defineProperty?Proxy 能拦截哪些操作?
  • 依赖收集和派发更新的完整链路是什么?track、trigger、effect 各是什么?
  • 响应式数据为什么在解构后会丢失响应性?toRefs 怎么解决?
  • reactive 的深层响应是懒加载的吗?这样做是为了什么?
  • ref 在模板中为什么能自动解包?在 setup 里为什么要用 .value?
  • toRef 和 toRefs 有什么区别?各自什么场景?
  • shallowRef 和 triggerRef 适合用在什么场景?
  • 同一个响应式对象被多处引用时,为什么能联动更新?

回答这些东西不需要死记硬背,只要把前面讲到的 WeakMap + Map + Set 依赖模型、Proxy 拦截机制、ref 包装器这三块搞明白,自然就能串起来。

说实话,刚接触 Vue3 那阵子,我对 ref 和 reactive 的态度就是"能用就行",直到连续三次被响应性丢失的 bug 折磨后,才老老实实回头读源码。现在回头看,最值的不是记住了多少 API 用法,而是建立了一个思维模型:响应式不是一个开关,而是一条从数据到视图的管道。你必须在整条管道上维护一致的身份引用,任何一环脱开,结果就是数据改了、页面不动。希望这篇复盘能帮你省下我当初花掉的那些排查时间。遇到一次诡异的 bug,把这篇文章再翻一遍,你大概率会和我一样突然通透。

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

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

立即咨询