☰
Vue3中ref和reactive到底怎么选?底层原理与实战避坑指南
2026/10/14 5:13:56 网站建设 项目流程

Vue3 正式成为主流之后,最让人绕不开的其实就是ref和reactive这对兄弟。我见过太多同事在组件里纠结:有的项目从头到尾只用ref,遇到对象也硬包一层.value;有的则信奉reactive才是"正统",结果在重新赋值时一脸懵地看着页面不刷新。老实说,这两个 API 背后完全是两套响应式工作机制,用错了场景不只是代码难看,更会在不起眼的赋值语句里把响应式悄悄弄丢。这篇文章我打算把ref和reactive的底层原理、适用类型、使用陷阱和选型思路一次性讲透,你看完至少能根据实际场景直接"抄作业",不用再对着文档挠头。

我会先从 Vue3 的响应式原理入手,讲清楚为什么框架需要两个 API;然后给出一个可以直接对照的对比表;接着结合我在真实项目里踩过的坑,聊一聊到底什么时候该用ref、什么时候该用reactive;最后附上我建议的混用原则和一些"打死都不要再犯"的写法。

1. 从响应式原理说起:ref和reactive根本就是两种东西

很多人会把ref和reactive当成同一东西的两种写法,其实它们的底层机制差别非常大。不深入理解原理,后面遇到的各种"响应式失效"问题就会像玄学一样无从排查。

1.1 先理解 Vue3 的响应式"代理"地基

Vue3 的响应式核心是基于Proxy实现的。reactive做的事情很简单:把你传入的目标对象包一层 Proxy,之后你读写对象属性时,Proxy 的get和set陷阱会分别触发依赖收集和更新通知。你可以把它想象成你让一个中介代理去管理你的房子,你所有操作都是通过中介完成的,中介会同步维护真实房子的一切变化。只要我们不直接越过中介去改房本,那么任何变化都能被精确追踪。

Proxy有个重要的特点是"懒代理":Vue3 在访问深层属性时才会递归代理下一层对象,而不是初始化时就全量代理。这意味着reactive({ nested: { deep: 1 } })在初始阶段只代理了最外层,当你访问state.nested时才把nested也变成响应式代理对象。这个特性让深层对象也能被响应,但也是后面很多"解构丢响应式"问题的来源。

有一点很关键:Proxy只能代理对象,不能代理字符串、数字、布尔值这些原始类型。所以如果你写了reactive('hello'),得到的并不是响应式字符串。这是框架被迫要搞出ref的底层原因——总得有个办法让基础类型也具备响应能力。

1.2 ref 本质是一个装了 value 的盒子

ref的思路就直白多了:它创建了一个对象,这个对象内部用RefImpl这个类来实现响应式,并且对外暴露一个value属性。当你访问refName.value时,触发 getter 读取内部值,同时收集依赖;当你修改refName.value = xxx时,触发 setter 写入新值并且通知之前收集到的所有依赖更新。

我们可以把它理解成一个带锁的快递箱:你想取东西必须通过箱子上的窗口(也就是.value)去操作,窗口每一次开关都会通知关注这个箱子的人。这个设计最直接的意义就是让原始类型也能进入响应式系统。比如:

const count = ref(0) console.log(count.value) // 0 count.value++

没有Proxy,就靠一个访问器属性(getter/setter)来手动维护依赖列表。所以ref本质上是用"包装对象 + 属性劫持"的方式模拟出了响应能力。

1.3 当对象被塞进 ref 时,其实它偷偷调用了 reactive

ref并不只服务基础类型,你也可以ref({ list: [] })。此时内部实现会判断传入的值是否为对象,如果是对象,就会用reactive(value)把这个对象转成 Proxy 代理对象,然后再把代理对象作为_value存进RefImpl。换句话说,objRef.value拿到的其实已经是一个reactive对象。

所以从底层看,reactive是 Vue3 响应式的核心,ref更像是"基础类型适配器 + reactive 的带壳版本"。明白了这一点,你就知道为什么reactive({ count: 0 })和ref({ count: 0 })在模板里都能做到深层响应式,因为后者内部早就调用了前者。

2. 对比清单:类型适配、模板解包、深层响应式、赋值陷阱

这一节我直接给对比表格,后面再逐项展开,方便你收藏当速查卡。

对比维度reactiveref
适用于原始类型不支持支持
适用于普通对象支持支持(内部转 reactive)
适用于数组支持支持(内部转 reactive)
深层响应式自动自动(对象场景)
读取方式(JS)state.xxxstate.value.xxx
读取方式(模板)直接state.xxx自动解包,直接state.xxx
整体替换替换后会丢失响应式替换value后仍保持响应式
解构赋值直接解构会断链直接解构(获得原始值)也会断链
可嵌套 ref支持但会自动解包支持
可配合toRefs将 reactive 转成 ref本身就是 ref

2.1 基础类型的响应式:ref 是你唯一的选择

如果你要响应的是一个数字、字符串、布尔值,reactive根本用不了。这是最硬性的差异。举个例子:

const str = reactive('hello')

这个写法虽然不报错,但str不是响应式的,它返回的就是普通字符串,对页面没有任何追踪能力。所以遇到let name = ''、const loading = ref(false)这类基础类型状态,直接ref即可。

2.2 对象和数组:两者都支持但"整体替换"行为差异巨大

对于对象和数组,reactive和ref都能保持深层响应式。但它们最大的分水岭在于——能不能整体替换。

先看reactive:

const state = reactive({ list: [1, 2], title: 'hello' }) // 下面这行会摧毁整个响应式 state = { list: [3, 4], title: 'new' }

state是一个 const 声明的常量,这种赋值本身就会报错(不能对 const 变量重新赋值)。真正常见的是这种写法:

let state = reactive({ list: [] }) // 在某个请求回调里重新赋值 state = reactive({ list: res.data })

这样做之后,原来的响应式代理对象被丢弃,变量指向了一个新的普通 Proxy,但之前所有使用这个变量的模板和watch依然引用的是旧对象,自然就不会更新。很多人踩的坑就在这里——页面就像"断线"了一样,怎么改都没反应。

但ref就不会有这个问题:

const state = ref({ list: [] }) state.value = { list: res.data } // 没有破坏响应式

因为ref对外暴露的是value属性,你替换value只是修改了RefImpl内部存储的值,依赖依然挂在这个ref对象上,所以更新完全正常。

2.3 模板里的自动解包和 JS 里的手动 .value

在模板中,ref会被自动解包,所以你可以直接count而不是count.value。这给很多新手带来一种"看起来和 reactive 差不多"的错觉。

<template> <p>{{ count }}</p> <p>{{ state.name }}</p> </template> <script setup> import { ref, reactive } from 'vue' const count = ref(0) const state = reactive({ name: '张三' }) </script>

但一旦进入script里,ref就必须显式加.value,而reactive不需要。这个小小的差异看着不多,却是造成团队代码风格混乱的主要原因之一。有人一直写count.value觉得很繁琐,有人则老忘加.value导致取到的是一个 RefImpl 对象。

2.4 解构和深层读取的差异

reactive对象可以直接解构,但解构拿到的是普通变量,无法追踪属性变化。比如:

const state = reactive({ a: 1, b: 2 }) const { a, b } = state a++

上面的a++不会触发任何更新,因为a就是一个普通数字。要解决这个问题,要么用toRefs(state)得到一组Ref,要么老老实实state.a++。

ref同样存在解构问题。const { a } = objRef.value拿到的a是普通值,不具备响应能力。所以在组合式函数里,如果要返回多个响应式变量,正确姿势是返回一个对象,其中每个属性都是ref,这样外部解构后依然保持响应。这也是为什么很多自定义composable都习惯返回ref的原因。

3. 选型实践:我在真实项目中的两个原则

讲再多理论,最后还是要落到项目里怎么选。我自己的经验可以浓缩成两句话:基础类型一律 ref;会被整体赋值的对象一律 ref。其余对象能用 reactive 就用 reactive,但绝不随便解构。

3.1 原始值字段,不需要犹豫

页面上的 loading、visible、currentPage、keyword、total 这类单一原始值,直接用ref。有人说我用reactive({ loading: false, visible: true })也行,确实行,但当你后面需要单独修改 loading 时,还是得state.loading = true,复杂度并没有减少,反而多了一层。

更重要的是,ref的可读性更强。const loading = ref(false)一眼就能看出这是一个响应式布尔值。而state.loading除非你知道state里面有什么,否则还得翻上下文。

3.2 会被整体替换的数据对象,用 ref 更省心

接口返回的数据经常是"一坨"整体替换。例如列表页:

// 请求开始 listLoading.value = true const data = await fetchList() tableData.value = data.list listLoading.value = false

这里tableData如果用ref([])声明,整体赋值时响应式依然成立。如果你非要用reactive({ list: [] }),那你就得写state.list = data.list,赋值的时候也要小心翼翼。整体赋值越频繁,ref的优势越明显。

3.3 数组到底用 ref 还是 reactive

数组我是强烈推荐用reactive的,因为它的 API 更自然:

const tags = reactive(['vue', 'react']) tags.push('svelte')

但这里有一个隐藏风险:如果你从接口获取整个数组,很容易直接写tags = newArr,这就会断链。所以我的建议是:当数组只做push、splice、filter这类内部操作时,用reactive很舒服;当数组会被整体赋值时,用ref配合value = newArr更安全。也可以统一用ref([]),只是每次操作数组都要写tags.value.push(...),时间长了手累。

我个人的折中方案是:局部列表用ref包数组,因为后期免不了从接口整体刷新;少量固定选项用的枚举数组,直接用reactive([])初始化并在初始化时填好,之后不再整体赋值。

3.4 组合式函数的返回类型直接决定外部用法

如果你在写自定义组合式函数,返回的响应式状态最好统一用ref。原因很简单:外部调用方解构你的返回值时,如果每个属性都是ref,解构后依然是响应式的;如果你返回的是一个reactive对象,外部解构时会断链。

举个例子:

// 推荐:返回 ref 属性 export function useCounter() { const count = ref(0) const increment = () => count.value++ return { count, increment } } // 外部 useCounter() 解构后 count 仍是 ref,保持响应 // 不推荐:返回整个 reactive export function useCounter() { const state = reactive({ count: 0 }) const increment = () => state.count++ return state } // 外部如果 const { count } = useCounter(),count 就变成普通值了

这是我踩过最多次的坑。起初图省事,直接在 composable 内部用reactive聚合状态,结果其他组件解构使用后发现数据变了但视图不更新,排查半天才发现是断链了。

4. 避坑现场:reactive 最容易翻车的几种写法

这一章讲的是我真实遇到过的线上 bug,每一种都对应一个具体工程场景。你把这些坑记住了,至少能规避掉 80% 的响应式丢失问题。

4.1 整对象替换,瞬间变成"普通对象"

先看这个非常经典的 bug:

const user = reactive({ name: '李四', age: 20 }) function updateUser(newUser) { user = newUser // 错误!这里其实是把变量指向了新的普通对象 }

但刚说了,const变量不能重新赋值。更隐蔽的写法是:

let user = reactive({ name: '李四', age: 20 }) function updateUser(newUser) { user = newUser }

这个代码在语法上没有问题,但user不再指向原来的 Proxy 对象,模板和watch里监听的还是旧user,所以页面不会更新,甚至watch(user, ...)也会失效。正确做法是Object.assign(user, newUser),或者把user改成ref,然后user.value = newUser。所以我在代码审查里会特别盯"reactive 变量与整体赋值"这种组合。

4.2 解构和展开,让响应式断裂于无形

解构问题前面已经提过。这里再补一个更隐蔽的:展开运算符。

const state = reactive({ a: 1, b: 2 }) const copy = { ...state }

copy只是普通对象,copy.a++不会触发任何更新。如果你在 watch 里用了() => ({ ...state }),你其实返回的是一个新普通对象,这个 watch 回调里依赖收集会失效或变得非常奇怪。正确做法是用toRefs:

const state = reactive({ a: 1, b: 2 }) const { a, b } = toRefs(state) // 此时 a 和 b 是 Ref,解构之后依然是响应式

但要注意,toRefs只对当前层的属性生效,如果对象里嵌套了深层对象,深层解构依然会断链。这是一个很容易被忽略的细节。

4.3 reactive 内部嵌套 ref 的自动解包

Vue3 允许在reactive对象里存放ref,而且访问时会自动解包,例如:

const count = ref(0) const state = reactive({ count }) state.count++ // 相当于 count.value++

这个机制看上去方便,但容易让人产生误解:state.count和count看起来是同一个东西,实际上当你用toRefs(state)解构时,拿到的count仍然是原来的 Ref,没问题;可如果你直接写const { count } = state,得到的又是普通值。所以嵌套ref+ 解构会带来比纯reactive或纯ref更复杂的边界行为。我的建议是:一个组件内不要刻意嵌套ref到reactive里,除非你已经非常清楚自动解包的行为。

4.4 特殊对象:Date、Map、Set、Class 实例对应不上

reactive虽然基于 Proxy,但不是所有对象都适合直接代理。Date实例在 Proxy 下方法调用会出现this绑定问题,导致某些方法不正常;Map、Set虽然 Vue3 有专门的reactive处理集合类型,但不是所有操作都能直接拦截;普通 Class 实例也容易出现原型链问题。

如果你需要用reactive包裹一个Date,更稳妥的做法是把它放进一个容器对象:

const state = reactive({ date: new Date() }) // state.date 本身不是响应式代理 // 但替换 state.date = new Date() 时,容器属性变化可以被检测

如果你直接const d = reactive(new Date()),后续d.setFullYear(...)未必会触发视图更新。这类边界问题在文档里写得很隐晦,但实际项目里一旦遇到,排查成本极高。

5. 性能与心智负担:为什么越来越多人推荐一律 ref

这两年社区里开始出现一种声音:干脆全都用ref算了。我在实践之后也渐渐偏向这个做法,这背后有性能因素,但更多是心智负担和一致性带来的收益。

5.1 响应式开销对比:Proxy 与 RefImpl

reactive要创建一个 Proxy,并且对深层对象进行懒代理。理论上每次通过 Proxy 访问属性都会有一层get陷阱,性能有一定损耗,但 Vue3 已经做过大量优化,实际项目里这种开销通常可以忽略。ref如果包裹对象,内部依旧会调用reactive创建 Proxy,所以两者在"包裹对象"场景下性能差距极小。如果ref包裹基础类型,就只是一个 getter/setter,开销甚至更低。因此,从性能角度,你不需要担心"多用 ref 会不会变慢"。

5.2 从编码一致性看,ref 能把响应式边界画得更清楚

reactive最大的问题其实是"响应式不显性":你拿到一个不知道从哪来的对象,你很难判断它到底是不是响应式的。ref则不同,任何ref使用的时候都要通过.value,这种"刻意的麻烦"反而时刻提醒你:这是一个响应式值,修改它要走.value。组件规模一大,这种显式边界能省去很多调试时间。

另外,reactive要求你必须遵守"不要解构、不要整体替换、小心展开"这些心智负担。而ref相对自由,ref对象本身就是一个稳定的引用,你解构它不会错,只要记得拿到的属性依然是 ref;你整体替换value也没问题。所以用ref时,你不太需要记那么多条条框框。

5.3 reactive 覆盖不了的场景,直接导出一份清单

我按自己的经验整理了一张"别用 reactive"清单:

  • 需要被整体赋值的变量
  • 基础类型(本来就不可用)
  • 需要从组合式函数中解构返回的单个状态
  • 需要频繁传参给非 Vue 环境函数的状态
  • 对象中某个属性会被单独取出并在模板中大量使用(解构风险)

在这些场景里,ref都能更稳。而reactive则适合一个本身就长期稳定、几乎不会被整体替换的"大框架对象"。

5.4 团队协作时,统一选型比个人偏好更重要

我在带前端小组时,最头疼的不是ref和reactive谁优谁劣,而是两个人风格不一样,代码评审时互相看不顺眼。后来我们干脆统一规则:新写的状态里,除了纯展示用的大对象,其余一律优先ref。这样大家写watch、写computed、传函数参数都不需要考虑对方的偏好。因为computed返回的就是ref,props是 readonly ref,函数参数接受一个 ref 也完全没问题。整个系统的类型统一之后,心智负担下降了非常多。

6. 实战混合用法:表单、列表、详情、布局四种场景怎么选

前面说了很多理论,最后直接给一套我在项目里实际使用的"配方"。你不需要死记硬背,但可以参考。

6.1 表单状态:用 reactive 聚合成一个对象

reactive特别适合表单,因为表单项多,逻辑上是一个强相关的整体,很少整体替换。比如:

const form = reactive({ username: '', age: null, hobby: [], address: { city: '', detail: '' } })

这样在模板里写v-model="form.username"非常自然。表单提交前用Object.assign(form, payload)做一次性赋值,也没有整体替换断链的风险。如果你用ref,每个字段都要form.value.username,确实繁琐。所以这个场景我坚定选reactive。

6.2 列表数据:用 ref,因为后端刷新是常态

列表数据太常见了。后端接口每次返回全新的数组,你只需要把它整个赋值给当前变量。用ref是最直观的:

const list = ref([]) async function load() { list.value = await api.getList() }

如果你习惯reactive,用reactive([])初始化后不能直接整体赋值,需要list.splice(0, list.length, ...newItems)或者在初始化时用reactive({ list: [] })再改state.list。不管哪种,都比ref绕。列表我基本全用ref。

6.3 详情页/弹窗初始数据:用 ref 接收外部值

详情页通常有"打开弹窗 -> 回填数据 -> 编辑 -> 提交"这个流程。外部传入的详情对象很可能是普通对象或者 prop。直接用ref承接最稳妥:

const detail = ref({}) function open(item) { detail.value = { ...item } }

这样修改detail.value.name时,响应式始终存在。如果用reactive,你很容易写出detail = item这种断链代码。

6.4 全局布局状态:reactive 聚合最舒服

全屏布局的 collapsed、menuList、theme 这些状态,相互关联但散落在多个组件中,用reactive聚合起来放到 store 里非常好读:

export const layoutState = reactive({ collapsed: false, theme: 'light', menuList: [] })

因为这些属性基本不会整体替换,只做局部修改,reactive是天然契合的。如果你非要用ref,每个属性都要拆成独立 ref,最后返回时还得包一层对象,反而麻烦。

6.5 几个判断口诀,直接抄

我可以给你一个三句话口诀:

  • 单独一个值,以后要替换,用 ref。
  • 一组强相关字段,生命周期内只改不换,用 reactive。
  • 从函数里返回响应式状态,优先返回 ref 属性。

记住这三条,90% 的场景就不会选错。剩下 10% 是边界情况,比如复杂嵌套模型、需要频繁解构的数据,那时候再回头翻这篇文章的避坑清单即可。

最后再分享一个小体会:选型其实没有唯一的"正确答案",真正重要的是让整个项目保持统一。我见过有些老手全程reactive也写得非常稳,那是他们已经把所有陷阱刻进肌肉记忆了。但对大多数团队来说,ref的显式和傻瓜式恰恰能减少沟通成本。你大可以先从"基础类型和整体替换用 ref,聚合对象用 reactive"开始,等踩了几次坑再慢慢找到自己的偏好。

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

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

立即咨询