Vue 3响应式API选型:ref与reactive底层原理及最佳实践
发布时间:2026/9/28 14:11:08来源:尧图网络
1. 先从一次 Code Review 说起ref/reactive 乱用的现场事情是这样的上周我们组做一次前端 Code Review正好评审到一个新同事写的表单组件。那个组件功能不复杂无非是拉用户信息、改两个字段、保存。但看了一遍状态定义我的血压就上来了。他大概是这样写的const userInfo reactive({ name: 张三, age: 28, address: { city: 上海 } }) async function fetchUser() { const res await getUser() userInfo res.data // 直接替换整个响应式对象 } function updateName(val) { userInfo.name val }接着我们开始讨论几个老生常谈的问题为什么userInfo res.data之后页面怎么点都不更新同事说我在setup里已经return { userInfo }了模板里也用了为什么没用另一个更老的同事说应该是ref于是把reactive换成了ref结果所有userInfo.name全要改成userInfo.value.name又有几个地方漏改了运行报错。看到这里你大概明白了ref和reactive这两个 API 本身不难难的是它们背后的运行机制不一样导致使用习惯、代码组织方式、甚至团队规范都要跟着变。这篇内容我分成几块来讲先带你理解底层差异再复盘最容易翻车的高频场景最后给出一套可以直接抄的选型标准和工作流。如果你已经用 Vue 3 写过一阵子但偶尔还是会在到底该用谁上犹豫这篇文章应该能帮你把这个问题一刀切掉。2. 底层差异为什么 reactive 只能接对象ref 却能包一切2.1 两套包装机制其实是完全不同的实现很多人对ref和reactive的理解停留在ref 用来包基本类型reactive 用来包对象。这个说法不算错但它掩盖了一个关键事实这两个 API 的响应式实现路径根本不同。reactive基于Proxy。它接收一个对象然后返回这个对象的代理版本之后你对对象属性的读取、赋值、删除全部经过代理拦截Vue 在拦截器里做依赖收集和更新触发。换句话说reactive是给整个对象套了一层监控。那问题来了基本类型数字、字符串、布尔值没有属性没法Proxy。你总不能new Proxy(1, handler)吧所以你拿一个原始值传给reactive它什么都做不了。在开发模式下 Vue 会警告但更可怕的是它不报错只是静默地返回原始值或者什么都不返回让你以为代码没问题实际上响应式已经彻底失效。ref的思路则完全不同。它的底层是一个叫做RefImpl的类这个类内部保存了一个value属性并且给这个value定义了 getter 和 setter。当你读取ref.value时getter 触发依赖收集当你赋值ref.value xxx时setter 触发更新。ref相当于给任意值装了一个小盒子——不管是基本类型还是对象一律塞进盒子然后再用 getter/setter 去追踪盒子里面的内容。用一个生活化的类比理解reactive像是一个有监控摄像头的房子你住进去一举一动都被记录但摄像头只能装在房子里不能装在一个数字上。ref则像是一个可以随身携带的保险箱你把任何东西放进去只要开箱、关箱的动作发生就能触发通知。2.2 依赖收集与触发的完整链路不管ref还是reactive本质上都在做同一件事getter 时收集依赖setter 时触发更新。我把这个过程简化一下// reactive 的简版逻辑 function reactive(target) { return new Proxy(target, { get(obj, key) { track(obj, key) // 收集依赖我现在正在用到 obj[key] return obj[key] }, set(obj, key, value) { obj[key] value trigger(obj, key) // 触发更新告诉之前收集过的依赖值变了 return true } }) }ref的简化逻辑则长这样class RefImpl { constructor(value) { this._value value } get value() { track(this, value) // 读取 .value 时收集依赖 return this._value } set value(newValue) { this._value newValue trigger(this, value) // 修改 .value 时触发更新 } }所以在 Vue 3 里你去给ref.value赋值、给reactive对象的属性赋值其实都是套了一层 getter/setter 或者 Proxy 拦截。理解这一点后文所有坑都能找到解释。2.3 自动解包的甜蜜陷阱ref最让新手困惑的就是.value。模板里你不需要写.valueVue 会自动解包template p{{ count }}/p !-- 直接写 count而不是 count.value -- /template script setup const count ref(0) /script这个自动解包只对顶层 ref生效。什么意思如果你在模板里访问arr[0]而这个arr[0]是 ref 对象那它不会自动解包你得写arr[0].value。同样如果你把 ref 塞进一个 reactive 对象里读取的时候会被自动解包这又带来了另一种混乱const count ref(1) const state reactive({ count }) console.log(state.count) // 输出 1而不是 RefImpl 对象也就是说state.count读出来是数字如果你把它当成 ref 再用一次state.count.value直接报错。这种自动解包的设计本意是降低心智负担但实际用下来它让很多人对当前拿到的到底是不是 ref的判断产生混乱。我在后面做组合式函数封装时会专门讲怎么尽可能规避这种状态。3. 高频踩坑实录解构、整体替换与数组操作3.1 解构让响应式瞬间失效这是所有坑里出现频率最高的一个。看这段代码const state reactive({ name: 张三, age: 28 }) const { name, age } state function changeName() { state.name 李四 }你在模板里用了name这个变量但等到state.name变成李四的时候页面上的name还是张三。原因很简单const { name, age } state执行的那一刻你只是把state.name的当前值拷给了局部变量name这个局部变量和state之间再也没有任何关系。Proxy 拦截的是读取state的name属性这个操作你解构完成后已经完成了读取后续局部变量就是一个普通字符串。正确的修法是用toRefs或者单个toRefconst state reactive({ name: 张三, age: 28 }) const { name, age } toRefs(state) // 这时候 name 是 Ref 对象 // 模板里可以用 name / age自动解包 // 脚本里修改 name.value 李四注意一个细节toRefs只会转换对象最顶层的属性。如果你的数据是多层嵌套比如state.address.city你想解构address再继续const { city } address那第二层解构依然会丢失响应式。因为address解出来确实还是 reactive 的代理对象它是state.address的引用你访问address.city仍然走代理响应式还在但如果你再次解构{ city }这个city又是一个普通值了。所以嵌套越深越容易出问题我的建议是深层数据不要解构直接用state.address.city这样的路径访问或者用计算属性/组合式函数去封装。3.2 整体替换 reactive 对象经典中的经典第二个高频坑就是我们开头的场景let user reactive({ name: 张三, age: 28 }) user { name: 李四, age: 30 }reactive返回的是一个 Proxy你把这个 Proxy 赋给user之后你又把user重新指向一个普通对象。问题在哪不在于 Proxy 不能换而在于user这个变量的引用被整体改了。老对象还在内存里但已经没有人持有它模板里引用的也是旧的那个 Proxy所以自然不更新。解决思路有三种如果状态是整包替换的类型直接改用ref最省心const user ref({ name: 张三, age: 28 }) user.value { name: 李四, age: 30 }ref的value指向被替换setter 触发视图更新。如果坚持用reactive但只想改部分字段用Object.assignconst user reactive({ name: 张三, age: 28 }) Object.assign(user, { name: 李四, age: 30 })Object.assign是把新对象的字段逐个赋值到旧的代理对象上走的是 Proxy 的 set 拦截所以能触发更新。如果接口一次性返回整个对象又不想一个个字段去拷那就在函数里把一个一个字段赋值给user或者干脆别用单个reactive包整个对象改成多个ref字段。我的个人经验是凡是要整体赋值的数据一律用ref不要死磕reactive。这个判断准则在后面选型部分也会用到。3.3 数组操作索引、length、整包替换很多人从 Vue 2 转过来会担心数组的响应式问题因为 Vue 2 里通过索引修改数组是不行的。Vue 3 的 Proxy 解决了大部分问题const list reactive([1, 2, 3]) list[0] 99 // 可以触发更新 list.length 0 // 也可以触发更新 list.push(4, 5) // 当然可以所以数组内部的操作基本不用再像 Vue 2 那样用$set了。但有一个坑依然存在——整包替换list [4, 5, 6] // 不行list 是 const就算改成 let 也丢响应式数组本质上也是对象所以它的整包替换问题和 3.2 一模一样。修法也类似用ref定义数组整体赋值时改arr.value。还有一个实际开发中经常会遇到的同名 ref混淆问题v-for里用的ref其实是DOM ref和响应式ref完全不是一回事。div v-foritem in list :refel itemRefs.push(el)/div这里ref的功能是拿 DOM 元素不是创建响应式数据。v-for中的 ref 回调是 Vue 模板编译期处理的写惯了ref(0)的人很容易把它和响应式 ref 搞混。不过这个坑更多是命名上的误导知道有这回事就行。3.4 reactive 里嵌套 ref 的类型混乱与读取不一致最后再补一个我在真实项目里观察到的低质量问题同一个状态对象里有的字段是ref有的字段是普通值然后被reactive包着最后整个团队都在猜这个字段到底要不要加.value。const state reactive({ count: ref(1), // 会被自动解包读取 state.count 得到 1 name: 张三, list: ref([]) // 读取 state.list 得到数组不是 Ref }) // 有人习惯 setState.count有人写成 state.count.value // 后一种直接报错前一种一切正常 // 于是代码风格分裂我的建议不要在 reactive 对象里放 ref 字段。要么全部用 ref 管理要么全部用普通值靠 reactive 代理不要混。混着写虽然 Vue 能处理但团队协作时心智负担极大代码 Review 时也没有一个统一标准可依。4. 选型决策表为什么多数场景无脑用 ref以及 reactive 的正确用法4.1 默认用 ref 的理由Vue 官方在组合式 API 的 FAQ 里其实已经给过倾向推荐使用 ref 而不是 reactive。官方没有强迫但推荐的原因很实际ref可以包任何值数字、字符串、布尔、对象、数组、甚至null。一个 API 走天下不需要纠结。ref支持解构和传递你在组合式函数里返回一个 ref外部拿到后不管是继续读取还是重新赋值响应式都还在。这不比 reactive 解构后就断气强多了ref在 TypeScript 里类型更直观RefT一目了然。reactive的深层 unwrap 类型推断相对绕一些。想要替换整个数据直接ref.value newData干净利落。对于一个项目来说统一使用 ref 还能带来一个额外好处——代码风格统一。大家在 Review 的时候不需要想这个对象是不是 reactive 的这里解构会不会吞响应式因为全是 ref模板里直接用脚本里统一.value。4.2 真正适合用 reactive 的场景那难道reactive就该被扔进垃圾桶吗不是。它有三类很典型的适用场景。第一个是表单模型的集合。比如一个页面里用户名、邮箱、地址、验证码这些强关联字段你用reactive包在一起整体上更像一个表单对象模板里写form.name、form.email而不是name.value、email.value可读性有明显优势。const form reactive({ username: , email: , address: { province: , city: } }) function submit() { api.submit(form) // 直接把整个响应式对象交给请求体 }第二个是从 Options API 的 data 迁移过来的场景。老项目里data()返回一个对象组件里到处都是this.xxx迁移到组合式 API 时用reactive保持一个状态对象的直觉对老团队更平滑。第三个是深层嵌套对象且你对访问路径很敏感。比如一个配置对象const config reactive({ server: { host: localhost, port: 3000, options: { timeout: 5000 } } })直接用config.server.options.timeout访问和修改永远不需要写.value。如果换成 ref 嵌套你会在某个深层修改时突然想起这里为什么又多了一层 value4.3 一个可以直接用的决策流程如果你不想记那么多原则我给你一个简单的判定流程基本覆盖了我见过的 90% 场景这个值需要整体替换吗比如接口返回整个对象/数组——是用ref。这个值需要直接解构出来用并且要保持响应式吗——是用ref配toRefs也行但 ref 直接就能解构。这些字段是不是强关联的一组数据而且你会把整个对象传给某个函数——是可以考虑reactive。这个状态是不是一个嵌套很深的结构且你经常要改深层属性——是可以考虑reactive。除了以上特殊情况其余一律ref。再把关键差异整理成一张表方便和朋友聊的时候直接丢出来对比项refreactive可接收类型任意类型仅对象/数组等引用类型基本类型响应式支持不支持原始值被忽略模板自动解包顶层 ref 自动解包不需要解包直接访问属性脚本中读取用.value直接obj.prop解构保持响应式支持直接解构不支持需toRefs整体替换数据直接改.value需Object.assign或改多个属性TypeScript 类型RefT直观深层 unwrap 较复杂适合场景默认选择尤其基本类型、整包替换强关联字段集合、深层嵌套结构、表单对象5. 组合式函数里的状态组织从一堆 ref 到可维护结构5.1 散装 ref 的坏味道如果你刚接受都用 ref的建议很容易写出这种代码const name ref(张三) const age ref(28) const address ref(上海) const loading ref(false) const list ref([]) const error ref()然后这些 ref 被return { name, age, address, loading, list, error }一次性抛给模板。短时间看没啥问题但当一个组合式函数里冒出七八个 ref 时代码已经很难维护了。你回来看这个函数不知道这些状态之间的关联新增一个字段时也只能继续往外面加一个 ref最后整个函数变成一张散装变量清单。我见过更离谱的写法是有人为了封装一个useUser返回了十几个 ref调用方在模板里用了十二个顶层变量组件看起来像菜市场。5.2 推荐的封装模式reactive 内部状态 toRefs 对外导出我现在的默认做法是组合式函数内部用 reactive 组织强关联状态对外导出用 toRefs 转成 ref这样外部既能解构又不失响应式。举一个实际例子export function useUser() { const state reactive({ name: 张三, age: 28, address: 上海, loading: false, error: }) async function fetchUser(id) { state.loading true state.error try { const res await api.getUser(id) // 局部字段赋值不整体替换 state state.name res.name state.age res.age state.address res.address } catch (e) { state.error e.message } finally { state.loading false } } return { ...toRefs(state), fetchUser } }调用方const { name, age, loading, fetchUser } useUser() // 模板里直接用 name / age / loading // 脚本里要用 name.value 读取这样做的优点很明确内部是 reactive所以你在函数里写状态更新时不用处处.value外部是 ref所以调用方可以随意解构、传递、整包赋值不会断响应式。两层各取所长。要注意一个细节toRefs转换后返回的 ref 与 state 属性保持同步。你外部name.value 李四内部state.name也会变反之亦然。所以这个模式也解决了跨函数共享状态的通信问题。5.3 大型 store 的组织建议如果你在做全局状态管理比如一个跨组件的用户会话 store我建议把 store 内部核心状态定义为reactive或ref都可以但对外暴露时不要直接 return 整个 state 对象。最好只暴露明确的读取属性和修改方法。// store/user.js const state reactive({ token: , profile: null, permissions: [] }) export function useUserStore() { function setToken(token) { state.token token // 对应 localStorage 同步等逻辑 } function setProfile(profile) { state.profile profile } return { // 只读属性用 computed 包装防止外部直接改状态 token: readonly(state.token), profile: readonly(state.profile), permissions: readonly(state.permissions), setToken, setProfile } }这样外部组件即便拿到token它也只是一个只读 ref改不了想改只能走setToken。对于团队协作来说这种约束比所有字段都裸奔安全得多。如果项目状态复杂度再上几个量级建议直接上 PiniaPinia 内部本身也大量使用了 reactive/ref 的相同机制但你不需要自己操心这些细节了。6. 性能敏感场景的进阶工具箱shallowRef、readonly、toRaw、customRef最后聊几个在特定场景中能救命的 API。它们不算日常必须但如果你要写中等规模以上的 Vue 3 应用这些工具会帮你避开一些隐性开销和交互问题。6.1 大列表与深层对象shallowRef / shallowReactiveref和reactive默认都是深度响应式。什么意思你ref({ a: { b: 1 } })之后修改obj.value.a.b 2视图照样更新因为 Vue 会递归地把每一层都变成响应式。这个特性很强但也有成本数据量大时递归代理会消耗不少内存和初始化时间。如果你的数据是一次性拉取、整体替换的场景深度响应式完全没必要。比如一个几千条的商品列表你只会在交互时整体替换整个数组不会有人去改list[100].price然后希望视图更新的。这时候用shallowRef能省掉一大笔开销const products shallowRef([]) async function loadProducts() { const res await api.getProducts() products.value res.data // 整体替换触发更新 } // 如果你直接 products.value.push(...)因为浅层不会触发更新 pushProducts() // 不生效需要用 triggerRef 强制触发shallowRef不会递归代理内部数据所以它只认引用是否改变。一旦你用了它就得遵守它的规矩要么整体替换value要么手动调triggerRef强制触发。类似地shallowReactive只代理对象的第一层适合那种外层字段经常变、深层数据整体替换的表单/配置对象。6.2 readonly防止状态被意外修改只读代理是个容易被低估的工具。在组合式函数返回状态给外部时你可以用readonly包装一层const state reactive({ count: 0 }) function getState() { return { state: readonly(state), increment: () state.count } }这样外部组件只能读state.count想直接state.count 99开发模式下 Vue 会警告生产模式也不会生效。这个能力很小但能省掉很多谁改了这个公共状态的排查时间。6.3 toRaw把响应式对象打回原形的场景有些时候你反而需要脱离响应式追踪。典型场景是要把数据传给第三方库比如图表库、Canvas 绘制这些库只需要读数据不希望被 Proxy 干扰或者你一直在处理的是代理对象性能上有损失。import { reactive, toRaw } from vue const state reactive({ name: 张三, list: [] }) const raw toRaw(state) // raw 是原始对象不再是 Proxy修改它不会触发 Vue 更新需要注意toRaw只能用在 reactive 对象上。如果你传进来的是 ref调用toRaw是取不到内部值的先ref.value才行。这个细节容易踩我之前在这个 API 上就吃过亏。6.4 customRef自定义追踪与触发逻辑customRef属于进阶中的进阶但它在某些交互场景里特别好用。最典型的例子是搜索框防抖export function useDebouncedRef(value, delay 300) { let timer return customRef((track, trigger) { return { get() { track() // 收集依赖 return value }, set(newValue) { clearTimeout(timer) timer setTimeout(() { value newValue trigger() // 延迟触发更新 }, delay) } } }) }使用const keyword useDebouncedRef(, 500) watch(keyword, () { // 只有停止输入 500ms 后才会触发 search(keyword.value) })这里get里必须调track()set里必须调trigger()这是customRef的固定协议。你可以在set里加入任意自定义逻辑比如合并、节流、甚至异步校验后再真正更新值。这个能力让 响应式 变成了一种你可以完全掌控的机制而不是黑盒。最后分享两个我项目里的落地心得第一团队规范里我只定了三条默认用ref只有强关联表单/配置对象才用reactive任何组合式函数对外导出时统一用toRefs转成 ref 解构。三句话就能讲完新人来了也不会自由发挥。第二如果你在看旧代码时发现一堆reactive 手写解构 整体赋值的问题不用急着重写所有文件。先把有整体替换需求的部分迁到ref再把强耦合字段集合的部分统一成 reactive 内部 toRefs导出。一步到位重构大项目风险很高但按这个方向逐文件改造代码会肉眼可见地变清爽。我自己就是这么一点一点把项目里的响应式代码理干净的。
网站建设高端定制企业官网