Vue3 都发布好几年了,ref和reactive到底怎么选,依然是社区里讨论最多的话题之一。前两天还有同事拿着代码来问我:为什么我的reactive对象解构出来就“不响应”了?为什么有时候ref和reactive看起来差不多,有些场景却必须用ref才行?这些问题的背后,不是“哪个更高级”的选择题,而是理解 Vue3 响应式系统底层逻辑的必修课。
这篇文章我会从源码层面的设计思路出发,把ref和reactive的底层机制、使用场景、易踩的坑、以及我这两年实际项目里摸索出的选型经验系统地捋一遍。不管你是刚接触 Composition API 的新手,还是已经在项目里用了一阵子但偶尔被响应式问题困扰的同学,这篇内容都能提供一个相对完整、可复现的判断框架。
1. 从底层原理看ref和reactive的真正区别
很多人背概念的时候都会说“ref用于基本类型,reactive用于对象”,这个说法不算错,但它掩盖了最关键的本质。如果只记结论不追原理,一旦遇到复杂场景就很容易翻车。
1.1 ref的本质:一个带 .value 的响应式容器
ref并不是把变量变成响应式,而是创建了一个全新的对象——你可以把它理解为一个箱子,箱子里有一个叫value的隔层,真正的数据放在隔层里。这个箱子的类型在源码里叫RefImpl,实现了 ES6 的 getter / setter 访问器。当你读取xxx.value时,会触发track收集依赖;当你给xxx.value赋值时,会触发trigger通知所有依赖更新。
这里有个很关键的细节:ref塞进去的值如果是对象,内部并不会直接保存这个对象,而是调用toReactive(value)把它交给reactive再处理一遍。也就是说,ref({ name: 'foo' })底层其实是一个“响应式对象的容器”,你通过.value拿到的,已经是一个被 Proxy 代理过的响应式对象。
const obj = { name: 'foo' } const state = ref(obj) // state.value 已经不是原来的 obj 了 console.log(state.value === obj) // false console.log(state.value.name) // 'foo' 且具有响应性所以别再说ref只能处理基本类型了——它能处理一切,只是访问方式多了一层.value。
1.2 reactive的本质:一个被 Proxy 包裹的原生对象
reactive的核心是基于Proxy代理。它接收一个目标对象,返回一个代理对象,所有对代理对象的属性访问、赋值、删除操作,都会被拦截并触发对应的track/trigger。
和ref不同,reactive返回的代理对象和原对象不是同一个引用。这一点很多人容易忽略:你必须在整个代码中都使用返回的代理对象,而不是继续操作原始对象,否则触发不了响应式更新。
const raw = { list: [] } const state = reactive(raw) raw.list.push(1) // 不触发响应式 state.list.push(1) // 正常触发视图更新我在实际代码审查里看过无数次这种问题:const state = reactive({...})之后,某个不熟悉原理的同学不小心在某个函数里引用了原来的raw对象,结果界面就是不动。排查半天发现数据其实改了,只是改在了“错误”的对象上。
1.3 关键差异:原始类型与引用类型的鸿沟
为什么reactive不能直接处理string、number、boolean这类原始值?原因很简单:Proxy只能代理对象,无法代理一个基础类型的值。如果你指望reactive(1)能让你像ref那样拿到一个响应式的数字,那是行不通的——API 设计上就不会支持。
而ref的设计恰好填补了这个空缺。因为它本质上是一个对象容器,把原始值装进对象里,再通过 getter / setter 拦截访问,自然就可以对基本类型做响应式追踪。
顺便提一个协作上的细节:reactive对象中的属性如果被赋值为一个ref对象,Vue 会自动“解包”——直接访问属性拿到的就是内部值,不需要写.value。
const count = ref(0) const state = reactive({ count }) // 属性访问时自动解包 console.log(state.count) // 0,而不是 RefImpl 对象但这个自动解包是有前提的:这个ref必须是响应式对象的“直接属性”,如果是嵌套在数组或 Map 里则不会解包。这个细节后文会详细展开。
2. 核心差异:解构、替换、嵌套,三场实战检验
前面讲的都是原理层面的区别,下面我们拉出三个实战场景,看看这两者在面对真实代码时,行为上的分水岭到底在哪里。
2.1 解构陷阱:为什么 reactive 一解构就“死”
最常见的翻车现场就是解构。const { name, age } = reactive(...)这行代码一写,响应式基本就凉了一半。
原因在于解构操作会把属性值从 Proxy 代理对象中“取出来”,得到的快照是普通值或者普通对象的引用,和原来的代理链路彻底断开。如果你解构出来的是一个基本类型,那它就是一个静态值;如果你解构出来的是一个对象属性,那它的子级可能还保有响应性,但顶层属性确实已经丢失了track关系。
const state = reactive({ count: 0, info: { name: 'vue' } }) const { count, info } = state count++ // 只是给一个普通数字做自增,视图毫无反应 const name = info.name // name 是普通字符串 // 但 info 本身仍然是个响应式代理对象 // 你如果直接操作 info.name,依然会触发更新这种情况我见过太多回,尤其是从 Options API 切换到 Composition API 的时候,大家习惯了this.xxx那种直接访问,一到setup里就顺手解构,结果页面异常安静。
要想保留响应式地进行解构,官方提供了toRefs/toRef工具,它们把reactive对象的属性转换为独立的ref,这样解构出来的是一个个带.value的容器,依赖关系依然存在。后文实操部分会专门讲这两个方法的用法。
2.2 整体替换:ref 的 .value 赋值为何更“结实”
第二个容易踩的坑是“整体替换”。很多人会这样写:
let state = reactive({ userList: [] }) // 某个异步请求回来后 state = reactive({ userList: result.data })这行代码的问题在于,state这个变量被重新赋值成了另一个全新的代理对象,原来的track关系只建立在了旧对象上,视图怎么可能有反应?此时state在组件的更新逻辑里已经指向了一个“陌生对象”。
而用ref就不会有这样的问题。因为ref的.value赋值,触发的是RefImpl的 setter,它会走完整的trigger逻辑,通知所有依赖“我内部的值变了”。
const state = ref({ userList: [] }) // 直接替换内部对象,响应式依然生效 state.value = { userList: result.data }这个差异直接决定了在“频繁整体替换数据”的场景下,ref要省心得多。典型的例子就是列表页的筛选:每次查询结果都返回一个新的数组或对象,你只要xxx.value = data即可,不需要额外做任何处理。
2.3 嵌套响应式:reactive 的隐式代理与 ref 的显式访问
reactive处理嵌套对象是“自动档”。当你访问state.a.b.c时,代理会惰性地为深层对象创建响应式版本,所以不管结构多深,几乎所有属性都是响应式的。这个特性极大地简化了深层数据的管理,不需要你手动一层层包。
ref则偏向“手动档”。虽然ref内部遇到对象也会转交给reactive,但你在代码里访问每一层时都得通过.value:
const data = ref({ list: [ { id: 1, name: 'a' } ] }) // 访问深层属性必须带 .value data.value.list[0].name = 'b'这种“显式访问”在深层结构里写起来确实烦琐,但如果结构本身只涉及一层,多写一个.value换来的是“整体替换”时的踏感。各有取舍,没有绝对优劣。
3. 场景抉择:什么时候用ref,什么时候用reactive
到了真正写业务代码的时候,如果每个变量都停下来想“这里该用哪个”,会很累。我自己摸索出的原则是:先根据数据形态分,再根据变更方式微调,最终以“团队里最好理解”为准。
3.1 表单、独立变量、基础数据:ref 的主场
像loading、pageNum、keyword、selectedId这种独立的基本类型变量,直接用ref,写法最节省心智。表单这一类结构相对固定、但字段会频繁重置或覆盖的场景,我也有越来越强的倾向用ref:
const form = ref({ name: '', age: 0, address: '' }) function resetForm() { // 整体覆盖非常痛快 form.value = { name: '', age: 0, address: '' } }如果此时用reactive,重置就得逐字段写form.name = '',容易漏字段。所以当“整体赋值”是高频操作时,ref是更好的容器。
单个 ref 在处理函数传参时也更安全。比如一个接口请求函数,你直接fetchData(param.value),传出去的是值,谁也不担心引用被搞丢;如果传一个reactive对象,你传的是 Proxy,函数内部如果擅自改属性,副作用是隐性的。
3.2 对象集合、业务域、深层结构:reactive 的用武之地
当数据是一个“业务域”下结构复杂、层级较深、以属性修改为主的集合时,reactive用起来更自然。比如一个表单的联动校验数据、一个编辑器的完整文档状态、一个购物车的多级结构,用reactive可以把深层节点的响应式自动铺好,省去一层层.value的噪音。
const editorState = reactive({ title: '', blocks: [ { type: 'text', content: '' }, { type: 'image', url: '' } ], meta: { author: '', tags: [] } }) function updateBlock(index, content) { editorState.blocks[index].content = content }这种嵌套访问方式非常接近我们操作普通 JS 对象,对团队里从 Options API 迁移过来的同学尤其友好。
3.3 混合使用与团队规范:一个可持续的选型策略
有经验的团队通常不会在项目里只用一种。我目前比较推荐的默认策略是:
- 组件内部的中小型独立状态:优先
ref - 跨模块共享的大型业务模型:优先
reactive - 从组合式函数返回状态:一律用
ref或toRefs转换后的ref,防止使用者解构后丢失响应性 - 需要全局响应式store的,内部可以用
reactive,但暴露给外部的接口可以用ref
原因是:组合式函数(Composable)被调用方解构是家常便饭,如果函数返回一个reactive对象,解构即丢响应性,使用者根本防不胜防。但如果返回的是ref或由toRefs处理的ref集合,解构之后依然安全。
// 返回 ref 的解构是安全的 export function useCounter() { const count = ref(0) const add = () => count.value++ return { count, add } } // 组件里 const { count, add } = useCounter() // 依然响应式这也是 Vue 官方组合式函数文档里隐含推荐的风格。我可以负责任地说,这个策略在多人协作的项目里能显著减少“响应式丢失”类 bug 的数量。
4. 实操中的性能与使用细节
选型除了看“好不好用”,还得稍微懂一点“性能开销”。虽然绝大多数业务场景下二者的性能都足够,但在高频交互和超大数据集场景里还是存在细微差别。
4.1 响应式开销:Proxy 的懒代理与 ref 的即时包装
reactive的代理是懒代理,也就是说,它初始只是给最外层对象套了一个 Proxy,只有当真正访问到深层属性时,才会为对应的子对象再创建新的代理。这意味着,深层结构的代理开销是“按需产生”的,不是一次性给你递归代理完。这在数据量较大的场景下能省不少初始化时间。
ref的开销主要集中在 getter / setter 的访问层,每次读.value都有一次函数调用。如果某个ref在render函数里被访问成千上万次,理论上确实会有一丁点额外的调用开销,但在业务开发中,这一丁点几乎感知不到。
比较有感知的场景反而是“整体替换大数组”时的渲染。用ref整体替换数组,更新粒度是“整个依赖重新渲染”;用reactive往数组push数据,更新粒度可以细化到依赖了length或对应索引的组件。所以如果你的接口每次返回成千上万条数据,而你又习惯整体替换列表,ref会让渲染更大;如果能做增量更新,reactive的细粒度更新更有优势。
4.2 模板中的解包规则与常见误解
在模板中使用ref,你不能写count.value,而是直接写count。这是 Vue 模板编译器提供的自动解包能力。但它只针对模板顶层上下文中的 ref 自动解包,嵌套在组件 props、数组里的 ref 就不会解包。
<!-- 正确 --> <p>{{ count }}</p> <!-- 错误,模板里不能访问 .value --> <!-- <p>{{ count.value }}</p> -->在reactive对象里,访问 ref 属性时自动解包的规则我前面提过,它只作用于对象直接属性。如果 ref 被塞进数组,通过索引访问不会自动解包:
const arr = reactive([ref(0)]) console.log(arr[0].value) // 需要手动 .value,不会自动解包这类细节你要是没看过文档,基本只能靠踩坑才能体会。建议在写公共组件或复杂结构时,心里时刻默念“解包只发生在特定语境”,代码里不要依赖那些模糊的自动解包行为,显式写arr[0].value反而更安全。
4.3 toRefs、toRef 等工具函数的正确用法
如果就是要解构reactive对象,可以用toRefs把每个属性变成独立的ref。我的习惯是:如果这个reactive是组合式函数返回的,并且调用方大概率会解构,就直接返回toRefs(reactiveObj)。
function useUser() { const state = reactive({ name: '', age: 0 }) return { ...toRefs(state) } } // 调用方解构完全安全 const { name, age } = useUser() name.value = 'new name' // 触发更新toRef则是针对单个属性:
const state = reactive({ name: 'vue', count: 0 }) const nameRef = toRef(state, 'name') console.log(nameRef.value) // 'vue'这里要强调:toRefs和toRef转换后,你拿到的ref与原始reactive属性是“共享同一个数据源”的关系。对新的ref赋值,会反馈回原来的reactive对象;反过来修改reactive,新ref也能感知到。它们本质上是同一个依赖源的“另一个出口”。
5. 常见问题与排查技巧实录
最后这部分,我把实际开发中真正高频出现的问题整理成了一个速查表。每一个都是我在项目里或帮别人排查时遇到过的真实案例。
5.1 reactive 对象解构后绑定不更新
问题表现:从reactive解构出某个属性,模板里也正常显示了,但数据更新后视图不变化。
排查思路:先判断解构出来的属性是基本类型还是对象。基本类型没有任何响应性;对象类型的字层可能还有响应性,但顶层属性已经丢失。如果必须解构,考虑用toRefs。如果是组件内部直接解构并且后续不需要单独操作属性,干脆不要解构,直接通过state.xxx访问。
// 不要这样 const { count } = state // 更不要这样之后还试图直接修改 count 来“触发更新” // 需要解构就这样 const { count } = toRefs(state) count.value++5.2 reactive 整体替换后视图不刷新
问题表现:接口返回新数据后直接state = data,视图没反应,数据错乱。
排查思路:看是不是重新赋值给了变量。reactive变量本质上是一个代理引用,重新赋值等于“换了一个代理”,原有依赖绑定全丢。解决方案有两种:如果数据形态是同构的,用Object.assign(state, data)做属性级合并;或者干脆一开始就用ref定义这个数据容器,直接.value = data。
const state = reactive({ list: [] }) // 更新时 // state.list = data —— 如果列表是属性,这样是可以的 // state = { list: data } —— 这样不行,换掉了整个代理 // 如果整体替换频率很高,推荐: const state = ref({ list: [] }) state.value = { list: data } // 干净利落5.3 数组和集合的响应式陷阱
问题表现:reactive包裹的数组,直接通过索引修改能触发更新吗?替换整个数组呢?
先说结论:reactive数组通过索引修改能够触发更新,因为 Proxy 会拦截set;但直接arr = []这种整体替换不能触发,因为换了整个代理对象。ref数组的整体替换则完全没问题。
另外还要注意响应式集合(Map、Set)的访问方式,不要用size直接判断空,要使用.size但注意需要访问返回值。在模板里使用这些集合时,最好多写一层访问,避免自动解包规则给你添乱。
下面整理成速查表,方便你以后排查时直接对照:
| 场景 | ref | reactive | 备注 |
|---|---|---|---|
| 基本类型变量 | 推荐 | 不支持 | reactive 只能处理对象/数组/集合 |
| 对象整体替换 | 推荐 | 不支持 | reactive 会断开代理,需要用 Object.assign |
| 深层嵌套对象 | 可用但需要 .value | 推荐 | reactive 惰性代理深层自动响应式 |
| 解构使用 | 安全,解构后仍是 ref | 危险,解构即断 | 可用 toRefs 转换后解构 |
| 数组索引修改 | 通过 .value 修改 | 支持 | 索引 set 会被 Proxy 拦截 |
| 数组整体替换 | 直接 .value = newArr | 不支持直接替换 | 可用 splice 或 ref 容器 |
| 组合式函数返回 | 推荐 | 慎重 | 解构安全是第一优先级 |
5.4 三条匿隐患与规避建议
除了上述三个高频问题,我这几年的使用过程里还积攒了一些比较隐蔽的经验,分享出来:
第一,避免把大面积的非响应式外部数据直接塞进 ref / reactive 里再读出来。如果数据源本身就来自外部 SDK,并且你会直接拿state.xxx修改,但不会经过 Vue 的 setter,那么 Vue 可能无法知道数据变了。这时候可以考虑用triggerRef或自定义一个“版本号” ref 来强制刷新,虽然这属于比较 hack 的玩法,但遇到不听话的外部 SDK 时是真有效。
第二,shallowRef和shallowReactive也值得了解一下。如果你想优化性能,只追踪最外层属性,比如深对象内部你确定不会改,或者想手动控制哪些层响应,这两个 API 能派上用场。shallowRef的.value整体替换需要配合triggerRef手动触发更新。
第三,watch 的默认行为要心里有数。watch(ref)默认触发条件是基于引用变化的,如果你ref内部是对象,修改内部属性不会触发(除非开启deep: true)。而watch(reactive)默认就是隐式 deep 的。这个差异会让很多人在写 watch 时产生困惑:
const userRef = ref({ name: 'a' }) watch(userRef, () => console.log('trigger')) userRef.value.name = 'b' // 默认不会触发,因为引用没变 // 需要 watch(userRef, cb, { deep: true }) const userReactive = reactive({ name: 'a' }) watch(userReactive, () => console.log('trigger')) userReactive.name = 'b' // 默认会触发,隐式 deep这个点如果项目里有新人接手,建议在文档或代码注释里明确标注,因为它是实际最容易产生误判的“特性之一”。
6. 组合式函数里的经验总结
如果要我给一句话总结,那就是:在中小型项目里,除非你明确知道自己需要reactive的深层自动代理和细粒度更新,否则优先用ref,尤其是暴露给外部复用时。
我自己用了几年 Vue3 之后,现在的默认姿势已经是“处处 ref,必要时 reactive”。一开始很多人会觉得ref.value写起来烦,但正是因为.value显式,代码的可读性和可追踪性反而提升了。当我看见所有的变量变化都通过.value完成,调试代码时心里反而更踏实——我会清晰地知道这是“一个响应式容器的内部值被替换了”,而不是“某个深层属性被隐式修改”。
最后再分享一个小技巧:如果你是从业务代码里抽公共逻辑,返回状态前不妨先用toRefs把 reactive 转一遍。不要等组件那边解构出了问题再回去改,因为每一次响应式丢失的排查,都要花费远高于你转换这三个字的时间。
Vue3 的响应式设计没有绝对的“银弹”,ref和reactive二者是互补关系。理解它们各自的底层模型,再结合你实际的数据变更方式,选择就会变得很简单。希望这篇对比对你有所帮助。