
从 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 的基本用法与 .valueref 的用法非常直接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 里有一个非常容易混淆的知识点模板 reftemplate ref和响应式 ref 都叫 ref。响应式 ref 管的是数据模板 ref 管的是 DOM 节点或子组件实例。template div refcontainerRef内容/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 执行期间它是 nullv-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 的拦截机制与 ReflectReactive 的核心逻辑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 的每个属性都转成 refimport { reactive, toRefs } from vue const state reactive({ count: 0, name: vue }) const { count, name } toRefs(state) // 现在 count 是 reftemplate 里可以直接 {{ count }}修改会同步到 state.count二是干脆别解构在模板里使用 state.xxx在组合式函数里也使用 state.xxx。这条路径在嵌套层级较深时更直观也避免了 toRefs 之后对每一层都还得保持引用带来的负担。我的经验是顶层解构用 toRefs 没问题深层的嵌套对象尽量保留整个结构不要层层解构否则代码的可读性和维护性都会下降。4. ref 还是 reactive按数据形态和场景选型4.1 两种风格的适用边界先把结论说清楚数据形态推荐方案理由基本类型number、string、boolean、bigintref唯一可用的方式语义清晰简单对象字段数量少、结构稳定reactive 或 ref 均可看团队规范两种都能胜任复杂嵌套对象级联选择器、表单模型、图表配置reactive直接操作属性不需要多层 .value需要整体赋值的对象接口返回列表、动态切换的数据源refref.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 时修改 countcount 又导致 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 太顺手反而容易让人忘记这是一层代理等遇到解构丢失、整体赋值失效的问题时才反应过来。搞懂这两个 APIVue3 组合式 API 的其他部分就像打通了任督二脉后面学 computed、watch、provide/inject 都会顺畅很多。