从 Vue2 项目迁到 Vue3 那阵子,我踩得最深的一个坑就是:在 setup 里声明了一个对象,改属性视图不更新,查了半天发现是忘了用 reactive。回头再去看官方文档,“ref 和 reactive 用来创建响应式数据”这句话一行带过,但真要落到项目里,什么时候该用 ref、什么时候该用 reactive、为什么 .value 有时候要加有时候不加、为什么 reactive 解构之后就失效了——这些问题文档里往往语焉不详。这篇文章我不打算只给 API 用法,而是把 ref 和 reactive 这组概念彻底拆开,从原理、用法、选型到常见坑位排查,一次性讲透。适合刚接触 Vue3 的初学者,也适合从 Vue2 迁移过来、对响应式机制一直停留在“会用但说不清”阶段的开发者。
Vue3 组合式 API 的所有响应式能力,最终都归结到两个函数身上:ref 和 reactive。它们一个管基本类型,一个管对象类型,分开设计的背后是 JavaScript 语言本身的限制,也是 Vue 团队对开发体验的一次重新取舍。我们从头说起。
1. 从 Vue2 的响应式盲区说起,理解 Vue3 为什么要重新设计
1.1 Object.defineProperty 的时代局限
Vue2 的响应式是建立在 Object.defineProperty 之上的。它会对对象的每个属性逐一拦截,重写 getter 和 setter。这样做有两个绕不开的硬伤:一是必须预先知道有哪些属性,所以对象上新增的属性天然不是响应式的,需要调用 this.$set;二是数组的原生方法如 push、splice 会被重写劫持,但仍然无法拦截通过索引直接赋值(arr[0] = xxx)这种操作,也没有好的办法兜住 length 变化。
很多从 Vue2 过来的人都写过大段的 this.$set 代码,这是那个时代的常态。Vue3 直接换了一套底层的拦截方案,但这带来的最大改变不在性能上,而在“心智负担”上——你不再需要记住“哪些操作会丢失响应性”,因为大部分对象操作在 Proxy 面前都是透明的。
1.2 Proxy 让响应式变成“全自动”
Proxy 的拦截目标是整个对象,而不是某个属性。它可以拦截属性读取、属性赋值、新增属性、删除属性、方法调用等一系列操作,并且还能配合 Reflect 保证语义上与原生操作一致。
Vue3 的 reactive 就是用 Proxy 包了一层对象。当你读取这个对象的任意属性时,触发 get 拦截,内部调用 track 把当前依赖收集起来;当你给属性赋值、新增属性或删除属性时,触发 set/deleteProperty 拦截,内部调用 trigger 通知依赖更新。新增一个以前不存在的属性,也不需要额外调用什么 API,直接 state.newProp = 1,视图就能响应。
这就是我在项目中最直观的感受:从 Vue2 到 Vue3,响应式代码的边界明显变小了。以前封装表单组件要小心翼翼,哪些字段先声明好,哪些可能要动态加,都得规划好;现在直接用 reactive 包一个空对象,后面随便塞字段,完全不需要操心响应式丢失的问题。
1.3 组合式 API 中响应式数据的新形态
还有一个背景是标题里容易忽略的:ref 和 reactive 是组合式 API 的产物。Vue2 时期数据都放在 data() 里,框架替你完成了响应式绑定,用户其实不需要主动创建响应式数据。但组合式 API 把逻辑拆到 setup 函数内,你必须在函数作用域内显式创建数据,然后 return 给模板使用。这时框架无法自动判断你声明的哪个变量需要响应式,于是提供了 ref 和 reactive 两个显式入口。
理解这一层,“为什么要手动创建响应式数据”这个问题就自然通了:不是因为复杂度高,而是逻辑复用和灵活性需要你自主控制变量的响应式能力。这也是为什么在组合式函数里,你可以用 ref/reactive 来创建共享状态,跨组件复用。
2. ref:给基本类型戴上一个响应式“壳”
2.1 ref 的基本用法与 .value
ref 的用法非常直接:
import { ref } from 'vue' const count = ref(0) console.log(count.value) // 0 count.value++ console.log(count.value) // 1基本类型的值在 JavaScript 中是不可变的、按值传递的,无法被拦截。直接 let count = 0 之后,没有任何手段可以在 count 被重新赋值时通知别的代码。所以 ref 的做法是给它套一个对象壳:返回一个带有 value 属性的对象。你改的是 count.value,触发的是对象的 setter,天然就能被追踪。
这一点很多人刚学时觉得别扭,但换个角度想:如果把响应式看成一条“数据更新的通知渠道”,基本类型本身没有渠道,壳就是渠道。这也是 ref 本质的含义:包装一个内部值,使其变成响应式引用。
2.2 ref 的底层原理:RefImpl
看一下源码(Vue 3.4 左右的简化实现)就清楚了:
class RefImpl { constructor(value) { this._value = toReactive(value) } get value() { track(this, 'value') return this._value } set value(newVal) { this._value = toReactive(newVal) trigger(this, 'value') } }你会发现在源码里没有魔法。get 里 track 收集依赖,set 里 trigger 触发更新,和 reactive 的机制一脉相承。它甚至比我们想象的更朴素,就是对象访问器(getter/setter)配合依赖收集系统。
有个细节值得记住:toReactive 这个函数会把内部值再交给 reactive 处理。也就是说,如果你 ref 了一个对象,内部这个对象会被转换成响应式代理。这一点很多人不知道,到后面讲 ref 包裹对象时还会用到。
2.3 模板里的自动解包与注意事项
在模板里使用 ref,不需要写 .value,因为模板编译时会对 ref 自动解包:
<template> <div>{{ count }}</div> </template> <script setup> const count = ref(0) </script>自动解包有几个边界要注意。数组和嵌套在 reactive 对象里的 ref,在模板中也会自动解包吗?答案是:reactive 对象内部的 ref 属性在读取时会被自动解包并保持响应性,但数组里的 ref 在模板中不会自动解包,需要手动 .value。这个差异容易让人踩坑,尤其是写 el-table 的 slot 或者 v-for 列表时。我建议要么避免在数组元素里放 ref 对象,要么明确使用 .value。
另一个注意点:模板自动解包只处理顶层的 ref 属性。如果返回的是一个嵌套对象 obj = { inner: ref(0) },模板里 {{ obj.inner }} 会直接显示 ref 对象本身而不会解包,需要写 obj.inner.value。所以组合式函数返回状态时,我习惯把需要模板直接用到的 ref 展开到顶层,保持模板整洁。
提示:Vue 3.2+ 的 script setup 中,模板顶层 ref 自动解包是默认行为;但这个行为只对顶层生效,嵌套在对象里的 ref 需要 .value。
2.4 当 ref 遇到对象:内部代理机制
前面提到 toReactive,当 ref 接收对象时:
const objRef = ref({ name: 'vue' }) objRef.value.name = 'vue3'这里的 objRef.value 其实已经是一个 reactive 对象,深层属性变化同样会被触发依赖更新。也就是说 ref 并不只能管基本类型,它实际上能包任何东西。
那标题为什么强调“基本类型用 ref”?因为语义更清晰:ref 的职责是提供一个响应式引用,尤其适合基本类型这类无法直接代理的值;对象类型用 reactive 更直接、少一层 .value。但如果你希望整个对象可以被直接替换(比如从接口拿到新的 list 整体赋值),ref 反而更方便,直接 objRef.value = newList 即可。这一点在实际开发中很重要,我在第五部分会专门展开。
2.5 模板 ref 与响应式 ref:同名异义的两兄弟
在 Vue3 里有一个非常容易混淆的知识点:模板 ref(template ref)和响应式 ref 都叫 ref。响应式 ref 管的是数据,模板 ref 管的是 DOM 节点或子组件实例。
<template> <div ref="containerRef">内容</div> </template> <script setup> import { ref, onMounted } from 'vue' const containerRef = ref(null) onMounted(() => { console.log(containerRef.value) // DOM 节点 }) </script>这里的 containerRef 也是一个 ref,初始值是 null,组件挂载后被赋值为真实 DOM。在编辑器类项目里,经常用模板 ref 拿代码编辑器或富文本框的实例,比如常见的 data ref editor 场景。
模板 ref 与响应式 ref 在底层共享 RefImpl 机制,但赋值来源完全不同:一个是框架在挂载时写入 DOM 节点,一个是业务代码在运行中修改。我见过有人把响应式数据和模板 ref 混用,在 onMounted 里对数据 ref 赋值时又想去拿 DOM,结果两件事搅在一起。遇到这种困惑,先分清这个 ref 到底是谁在维护。模板 ref 的 .value 同样要在 onMounted 之后才能访问,在 setup 执行期间它是 null;v-for 里的模板 ref 会变成一个数组,数组顺序依赖 DOM 渲染顺序。
3. reactive:对象类型响应式的直接方案
3.1 reactive 的基本用法与深层响应
reactive 的用法:
import { reactive } from 'vue' const state = reactive({ user: { name: '张三', age: 18 }, tags: ['前端', 'Vue'] }) state.user.name = '李四'深层响应是 reactive 默认行为。你改 state.user.name,视图会更新;push 一个 tag,视图也会更新。它的实现是在 get 拦截里发现访问的是对象时递归地再包一层 Proxy,形成代理链。这样做会带来一定的额外开销,但 Vue3 用了惰性代理:只在访问到对象属性的那一刻才真正代理下一层,而不是初始化时递归全部。
3.2 Proxy 的拦截机制与 Reflect
Reactive 的核心逻辑:
import { track, trigger } from '@vue/reactivity' const reactive = (target) => { return new Proxy(target, { get(target, key, receiver) { if (key === '__v_raw') return target const res = Reflect.get(target, key, receiver) track(target, key) if (isObject(res)) return reactive(res) return res }, set(target, key, value, receiver) { const oldValue = target[key] const result = Reflect.set(target, key, value, receiver) if (hasChanged(oldValue, value)) { trigger(target, key) } return result }, deleteProperty(target, key) { const hadKey = key in target const result = Reflect.deleteProperty(target, key) if (hadKey && result) trigger(target, key) return result } }) }这里必须用 Reflect.set / Reflect.get 而不是 target[key] 直接读写,是为了保证 receiver 的语义正确,尤其在 getter 里 this 指向的问题上。我见过不少同学不明所以地照抄 Proxy 代码,发现自己手动实现的响应式在某些嵌套场景失效,多半就是这里出了问题。
3.3 必须正视的限制:类型与身份
reactive 有两个硬性限制,必须在项目里形成肌肉记忆。
第一,它只能代理对象类型(Object、Array、Map、Set 等),不能用于基本类型。对基本类型调用 reactive 会直接警告并返回原值,所以在需要把 number/string/boolean 变成响应式的时候,只有 ref 可选。
第二,reactive 返回的是一个代理对象,这个代理对象和原始对象并不严格相等。与之相伴的还有一个“身份保持”规则:对同一个目标对象多次调用 reactive,返回的是同一个代理对象;但如果你先创建了代理,又用原始对象去创建另一个代理,两者不会互相共享响应式状态,容易造成状态分裂。所以在封装数据结构时,尽量始终操作代理对象,不要混用原始对象和代理对象。
3.4 解构陷阱与 toRefs 补救
reactive 最容易被吐槽的就是解构丢失响应性:
const state = reactive({ count: 0, name: 'vue' }) const { count, name } = state // 此时 count、name 都是基本类型的快照,改它们不影响 state原因很简单:解构是在读取值的那一刻把 value 复制给新变量,之后新变量与 state 没有任何关系。要保留响应性,有两条路。
一是用 toRefs,把 reactive 的每个属性都转成 ref:
import { reactive, toRefs } from 'vue' const state = reactive({ count: 0, name: 'vue' }) const { count, name } = toRefs(state) // 现在 count 是 ref,template 里可以直接 {{ count }},修改会同步到 state.count二是干脆别解构,在模板里使用 state.xxx,在组合式函数里也使用 state.xxx。这条路径在嵌套层级较深时更直观,也避免了 toRefs 之后对每一层都还得保持引用带来的负担。我的经验是:顶层解构用 toRefs 没问题,深层的嵌套对象尽量保留整个结构,不要层层解构,否则代码的可读性和维护性都会下降。
4. ref 还是 reactive?按数据形态和场景选型
4.1 两种风格的适用边界
先把结论说清楚:
| 数据形态 | 推荐方案 | 理由 |
|---|---|---|
| 基本类型(number、string、boolean、bigint) | ref | 唯一可用的方式,语义清晰 |
| 简单对象,字段数量少、结构稳定 | reactive 或 ref 均可 | 看团队规范,两种都能胜任 |
| 复杂嵌套对象(级联选择器、表单模型、图表配置) | reactive | 直接操作属性,不需要多层 .value |
| 需要整体赋值的对象(接口返回列表、动态切换的数据源) | ref | ref.value = 新对象 一步到位 |
| 跨组件共享状态(组合式函数返回值、状态仓库) | ref + toRefs | 便于解构和逻辑复用 |
表格是一种视角,但选型还取决于写法风格。有的团队偏好全 ref,理由是 API 统一、类型推导更友好、解构不丢响应性;有的团队偏好 reactive + toRefs,理由是代码更接近 Vue2 的 data 写法,模板里不用想 .value。
我没有绝对的立场。但在同一个项目里建议只定一个主基调,不要混得太乱。最容易翻车的是:有人用一个 reactive 对象里塞了很多 ref 属性的写法,模板自动解包规则在这种混合里最容易被误解,排查成本反而高。
4.2 团队协作下的代码规范
在实际协作中,我推荐一套比较容易执行的规范。
- 单个基本类型的响应式值,一律 ref。
- 一个页面级的、结构稳定的对象状态(比如搜索表单、编辑表单模型),用 reactive。
- 需要从接口整体替换的数据(如表格数据 list),用 ref 持有,然后 list.value = res.data。
- 组合式函数对外返回状态时,统一用 ref 形式,内部无论是 ref 还是 reactive,在 return 前通过 toRefs 转换。这样调用方永远不需要知道内部实现是 ref 还是 reactive。
这套规范的好处是:模板里 ref 自动解包,reactive 属性需要 state.xxx 访问,两套写法边界清楚;组合式函数层面则统一 API,调用方不需要揣测“这个返回值到底要不要 .value”。我在 Vue3 后台管理系统项目里用了大半年,组员基本不会在“解构后数据不更新”这种低级问题上反复栽跟头。
4.3 后台管理系统表单页的实际选型
拿后台管理系统最常见的场景——搜索条件和表单校验(比如日期范围校验)来说:
// 搜索表单:字段稳定、还涉及校验规则联动,用 reactive 很合适 const searchForm = reactive({ keyword: '', dateRange: [], categoryId: null, }) const rules = reactive({ dateRange: [ { required: true, message: '请选择日期范围' }, { type: 'array', len: 2, message: '日期范围须完整' } ] })而某个从接口拿下来的列表:
const tableData = ref([]) const loading = ref(false) const fetchList = async () => { loading.value = true try { const res = await getList(searchForm) tableData.value = res.data.list } finally { loading.value = false } }这两种形态互补得很好。searchForm 用 reactive,因为它的字段几乎恒定,并且校验规则需要随时读取最新值;tableData 用 ref,因为每次接口返回都是新的数组,整体赋值最自然。如果你用 reactive 去接接口数组,就会遇到一个经典问题:直接 state.list = res.data 确实可以赋值,但如果你试图把整个 state 对象替换成新对象,就会破坏响应式,因为 reactive 只能代理同一个目标对象,重新赋值会让 state 指向一个全新的普通对象,所有依赖全部失效。
5. 高频报错和响应式坑位排查实录
5.1 解构后数据“变死”的排查链路
我接到过不少同事报的 bug,描述都是“界面不更新,数据明明变了”。第一步我会让他们在控制台打印出当前变量,如果打印出来是一个普通值而不是带 value 的对象,多半就是解构导致的。
完整的排查链路一般是这样的:
- 先确认数据源是否响应式。在 setup 里打印 const x = state.count,如果 x 是一个普通数字,说明这一行已经把响应式丢了。
- 检查出处。变量是否来自 reactive 对象的解构?如果是,考虑改成 state.xxx 或 toRefs。
- 检查赋值时机。如果是在 setTimeout、接口回调、事件回调里直接给解构出来的变量赋值,必定不会触发更新。
- 检查是否有覆盖整个对象。如果执行了 Object.assign(state, newData),响应式还在;但如果执行了 state = newData,响应式就丢了,必须写成 Object.assign(state, newData) 或遍历赋值。
这套链路帮我在项目里省了不少时间。尤其是从 Vue2 迁移过来的老同学,经常习惯性地把整个对象直接赋值,这是 Vue2 和 Vue3 响应式体系的重大差异之一。
5.2 maximum recursive updates exceeded 从出现到定位
先记住报错原文:Maximum recursive updates exceeded. This means you have a reactive effect that is mutating its own dependencies and thus recursively triggering itself。
这个报错本质上是“响应式副作用在更新它自己的依赖,导致无限递归”。最常见的三类诱因。
第一类,watch 回调里同步修改被监听的数据源:
watch(count, () => { count.value++ })count 一变,watch 回调执行,回调里又改 count,触发 watch……无限循环,最终报错。
第二类,computed 里存在副作用:
const doubled = computed(() => { count.value++ // 不要这样做 return count.value * 2 })computed 必须保持纯函数,它只应该读取依赖并返回新值,绝不能修改依赖。否则读取 doubled 时修改 count,count 又导致 doubled 重新计算,形成自激振荡。
第三类,组件渲染过程里直接修改响应式依赖。比如在渲染函数里 push 数组、在模板表达式中调用有副作用的函数,每一次渲染都会产生新修改,又会触发渲染,最终也会走到这个报错。
遇到报错时不要慌,按顺序排查:
- 打开控制台,看报错堆栈里是哪个组件的渲染函数,定位到具体组件。
- 检查该组件的模板、computed、watch,找到是否存在“修改自己依赖”的路径。
- 把可疑的副作用代码临时注释,分段排除。
- 修复后验证:watch 里如果需要基于新值再改数据,应当加 flush: 'post' 或用 nextTick,而不是直接在回调里同步修改同一个依赖源。
5.3 调试响应式数据的实用工具
最后聊工具。Vue3 的官方 DevTools 已经能很清晰地展示 ref 和 reactive 的状态,但如果你想在断点里查看依赖关系,可以用 watchEffect 做临时的状态观测:
import { watchEffect } from 'vue' watchEffect(() => { console.log('current state:', JSON.stringify(state)) })这里只建议在本地开发时用,生产环境记得移除。还有一个非常实用的方法:在浏览器控制台拿到组件实例,Vue3 里可以通过 $vm 访问。如果你正在调试一个组件,直接在 console 里输入 $vm.state,就能看到该组件的响应式状态;配合 setter 断点,几乎可以定位所有“数据变没变、为什么没触发更新”的问题。
我记得有个项目里用户反馈表单日期校验不通过,但字段值看着没问题。我用 watchEffect 打印整个表单对象,发现 dateRange 里存的是字符串数组而不是 Date 对象,rules 的 type: 'array' 校验逻辑始终把它当成了长度不对的数组。这种问题如果只靠眼睛看模板是看不出来的,必须把响应式数据原样摸到。
最后的一点个人体会
ref 和 reactive 的边界,我用了大概两个迭代才真正内化。一开始我也纠结“到底哪个更好”,后来发现纠结本身就是错的:它们不是竞争关系,而是 JavaScript 语言局限下的分工——基本类型没有代理能力,所以用壳;对象类型可以直接代理,所以用 reactive。真正影响项目质量的不是选型本身,而是团队是否会遵守同一套使用规范,以及是否理解“响应式依赖更新”这个底层闭环。
如果你正在学 Vue3,我的建议是先把 ref 用熟,再回头体验 reactive。因为 ref 的 .value 能时刻提醒你这个值背后有一条依赖链路;而 reactive 太顺手,反而容易让人忘记这是一层代理,等遇到解构丢失、整体赋值失效的问题时才反应过来。搞懂这两个 API,Vue3 组合式 API 的其他部分就像打通了任督二脉,后面学 computed、watch、provide/inject 都会顺畅很多。