首页 / 资讯中心 / 文章详情

Vue3中ref与reactive怎么选?从原理到实践的完整指南

Vue3中ref与reactive怎么选?从原理到实践的完整指南 ★ FEATURED ARTICLE
Vue3上线这么多年了ref和reactive这两个响应式API的对比依然是前端群里聊得最凶的话题。每次有新同事加入项目几乎都会问一句到底什么时候用ref什么时候用reactive说实话这个问题没有标准答案但如果你真正理解了两者背后的设计逻辑和各自的脾气秉性选型这件事就会变得非常自然而不是靠死记硬背对象用reactive基本类型用ref这种表面规则。这篇文章我想把自己在实际项目里折腾出来的经验完整梳理一遍从原理差异讲到选型思路再聊几个常见的坑和排查技巧希望对你有所帮助。1. 为什么Vue3要拆出两套响应式API1.1 从Object.defineProperty到Proxy的那一步跨越要理解ref和reactive为什么长成现在这个样子得先回头看Vue2的响应式实现。Vue2用的是Object.defineProperty逐个属性劫持这带来几个很麻烦的问题新增属性不会触发更新必须用Vue.set数组的索引操作和length修改没法被侦测到对象嵌套越深递归劫持的开销越大。这些坑在真实项目里都踩过尤其是后端返回的数据结构一复杂动不动就数据改了页面没反应。Vue3全面切换到Proxy之后情况彻底变了。Proxy代理的是整个对象不管你是新增属性、删除属性、还是通过索引改数组它都能拦截到。而且Proxy是懒代理只有访问到嵌套对象时才会递归代理下一层初始化性能比Vue2好得多。这块是reactive的底层基础它直接把一个普通对象变成响应式代理对象没有多余的包装层。但问题来了Proxy只能代理对象类型对string、number、boolean这些基本类型是无能为力的。总不能让开发者把所有基本类型都包一层对象才能用吧那也太反人类了。于是ref作为补充方案出现了——它内部创建一个值为原始值的对象再用Proxy去代理这个对象让你能对基本类型数据也实现响应式追踪。这就是ref是reactive的语法糖这个说法的来源。1.2 两套API的设计意图差异从设计意图上说reactive更贴近直接操作数据的直觉拿到的是一个和原对象结构一模一样的代理对象读写方式跟普通对象没有任何区别。这在处理复杂嵌套数据结构时很舒服代码看起来干净。而ref的价值在于统一它允许你把任意类型的值都装进一个带.value的容器里不管你是存数字、字符串、数组还是对象访问方式都是一致的。这个差异带来的一个直接影响是ref在组合式函数composable里特别占优势。当你写一个useUserList之类的逻辑封装时内部可能同时涉及页面页码、加载状态、列表数据、筛选条件等不同类型的数据。如果都用ref返回出来就是一个结构统一的对象调用方一眼就能看出哪些是响应式引用如果混用ref和reactive调用方就得分辨这个字段是要.value还是不用心智负担明显变大。我见过不少团队直接定规矩默认全用ref只有当你明确需要reactive的某些特性比如深层对象结构保持原样、批量替换整个对象、配合toRefs做解构时才用reactive。这个规矩虽然粗暴但在中大型项目里确实能减少很多不必要的纠结。2. ref与reactive的核心差异拆解2.1 数据类型支持的边界从数据类型支持角度看reactive只接受对象类型包括普通对象、数组、Map、Set等。如果你试图给reactive传一个基本类型值Vue3会在开发模式下直接报警告生产环境里则直接不生效。ref则没有这个限制它接受任意类型的值——基本类型、对象、数组统统可以往里塞。有一个点容易被忽略ref包装对象值时内部其实也是通过reactive来处理的。也就是说ref({ name: 张三 })和reactive({ name: 张三 })在响应式内核上走的是同一条路区别只在于ref多了一层.value的访问包装。这意味着通过ref存下来的对象它的深层属性一样是响应式的修改userRef.value.name同样能触发视图更新。从类型边界出发我整理了一张对比表对比维度refreactive支持的数据类型任意类型基本类型、对象、数组仅对象类型普通对象、数组、Map、Set访问方式需要.value模板中自动解包直接属性访问嵌套对象响应式是内部借助reactive是整体替换对象直接给.value赋新值即可直接替换对象会导致响应式连接断开解构数据解构后依然是响应式因为.value是引用解构后失去响应式除非用toRefs类型推断用Ref 包装直接用原始类型模板中使用自动解包不需要写.value直接访问这张表基本覆盖了日常开发里能遇到的所有差异点。表格里整体替换对象那一行特别关键后面讲踩坑的时候会细说。2.2 访问方式差异与模板解包机制ref最让新手困惑的就是那个.value。在JavaScript里写count.value看起来总觉得不够优雅而reactive直接state.count就完事了。但这里有个细节值得展开Vue3的模板编译器对ref做了自动解包处理你在模板里写{{ count }}时Vue会机智地帮你拆成count.value所以模板里完全感觉不到.value的存在。但要注意自动解包只在模板的顶层属性访问时生效。如果你在模板里写的是{{ count 1 }}Vue仍然能解包如果写的是{{ userInfo.name }}且userInfo是个ref对象模板里的userInfo也会被自动解包成.value再继续访问name这块Vue3.3之后处理得已经很成熟了。真正会翻车的是你把ref传进某个普通函数的时候function logValue(v) { // 期望打印的是值结果打印的是RefImpl实例 console.log(v) } const count ref(10) logValue(count) // 输出 RefImpl { _value: 10, ... }类似的场景在把响应式数据传给vant、element-plus这类组件库的表单组件时也经常出现——它们内部如果直接接收你的ref而不做解包处理数据绑定就会出问题。解决办法就是明确在调用处写.value或者在传参前用toRaw、unref之类的工具函数处理好。reactive就没有这些心智负担拿到的是什么结构访问就是什么结构。反过来它的缺点出现在赋值替换的那一瞬间下面细说。2.3 响应式追踪的粒度与触发时机从响应式追踪的粒度看ref和reactive都用的是get/set拦截器粒度上没有本质区别区别主要在整个对象替换这个动作上。ref允许你用count.value 20这种整体赋值方式替换整个值。因为外部持有的永远是那个RefImpl对象你只是改了它内部的value字段引用关系没断所以响应式连接能一直保持。而reactive就尴尬了如果你直接写state { count: 20 }这个操作针对的是变量名而不是对象内部属性等于是把state这个引用从原来的响应式代理对象切到了一个新的普通对象上——响应式连接直接没了。这里有个典型的实用场景后端返回了一条新的用户详情数据你需要用这条数据整体替换掉页面上的userInfo。// ref方式直接赋值响应式依然有效 const userInfo ref({}) userInfo.value await getUserDetail(id) // reactive方式直接赋值会丢失响应式 const userInfo reactive({}) userInfo await getUserDetail(id) // 不生效userInfo被替换成普通对象针对reactive的这个问题常见做法是赋值时手动合并或者用Object.assignconst userInfo reactive({}) Object.assign(userInfo, await getUserDetail(id))或者一开始就用ref一了百了。这也是为什么我在实际项目里越来越偏向ref的核心原因——整体替换这个操作太常见了。3. 实际项目中的选型与落地实践3.1 什么时候坚定地选ref结合我自己的项目经验下面几类场景我会毫不犹豫地选ref第一所有基本类型的响应式数据。计数器、开关状态、输入框的值、当前页码、计时器句柄……这些用reactive要么包装一层对象让代码变丑要么根本没法用。这类数据是前端页面里出现频率最高的ref是天然解。第二需要整体替换的数据。分页列表、详情数据、接口返回的大对象几乎每次请求之后都需要用新数据覆盖旧数据。ref在这种情况下体验极佳直接xxx.value response.data就完事不用费心想合并逻辑。第三在组合式函数composable里做封装时。我自己写useTable、useForm这类复用逻辑时内部所有状态一律用ref返回对象统一成ref结构。调用方不管拿到什么数据都习惯性地用.value去读思路非常统一。第四跨组件传递响应式状态时。用provide/inject或者pinia管理状态时ref的引用传递特性更省心。子组件拿到一个ref就可以通过.value读写不会因为意外赋值整个对象而断开连接。选ref还有一个隐形好处调试方便。打开Vue Devtools你能看到每个RefImpl里的value和依赖收集情况。如果全是reactive对象嵌套层级一深排查到底是哪个属性触发了更新会费劲不少。3.2 什么时候reactive反而更合适虽然我在前面表现得很偏向ref但reactive依然有它的用武之地。第一当你有一个结构相对固定、不需要整体替换的深层嵌套对象时reactive可以让代码极度简洁。比如表单数据字段多、层级深、修改频率高但很少会发生整个表单对象被替换的情况。用reactive所有字段访问都直接属性链式操作配合v-model直接绑子属性感受比ref舒服得多。const form reactive({ user: { name: , profile: { age: 0, avatar: } } }) // 模板和逻辑中直接访问 form.user.profile.age 18如果用ref写同样结构form.value.user.profile.age光看这一串.就有点劝退了。第二当你操作的是数组且会频繁使用数组原生方法时reactive有优势。reactive([])得到的代理数组push、splice、map等方法和原生数组几乎无差别。用ref([])每次访问都要先取.value数组方法链式调用时会不太方便比如refArray.value.filter(...).map(...)。第三当你需要把一个对象传给子组件且希望子组件内直接用属性访问时reactive是更自然的选择。因为子组件模板里读取props本身就不需要.value如果父组件传来的是ref包装过的大对象在子组件里反而要多一步解包略显繁琐。一句话总结我的选型策略结构稳定的深对象、数组操作频繁的场景用reactive其他一律先用ref遇到痛点再切换。3.3 混用场景下的注意事项真实项目里不可能只用一种ref和reactive的混用是常态。混用本身没问题但有三个点我觉得值得注意。注意点一ref对象放进reactive里会被自动解包。比如你有一个reactive对象里面某个字段是ref访问这个字段时拿到的直接是值而不是RefImpl对象。const count ref(10) const state reactive({ count }) console.log(state.count) // 10不是 RefImpl state.count 20 console.log(count.value) // 20两边同步这个解包行为在深层嵌套时同样生效但要注意如果整个reactive对象被重新赋值而不是修改属性内部的ref解包关系也会断裂。所以混用时依然要避免整体替换reactive对象。注意点二从reactive对象里提取单个属性时要小心响应式丢失。直接const name state.name拿到的是一个普通值后续修改它不会影响原对象。正确做法是const name toRef(state, name)或者一次性把多个属性抽出来用toRefs(state)。注意点三toRefs只对reactive对象的顶层属性生效嵌套层级的属性如果要提取得手动一层层toRef。而且toRefs产生的ref与原对象是双向同步的修改任一方都会反映到另一方这是它的响应式连接基础。4. 常见问题与排查技巧实录4.1 响应式丢失与凶案现场响应式丢失是我群里被问得最多的问题。症状通常都是页面数据变了但视图没更新下面几种情况几乎囊括了绝大多数案例。第一种直接给reactive变量整体赋值。前面提过了state newData之后state指向新普通对象响应式彻底丢失。排查时你可以打开Vue Devtools看看state是否还是一个Reactive实例如果是Plain Object就基本石锤了。第二种解构reactive对象。很多人习惯const { name, age } state解构出来的name和age都是普通值修改它们不会触发任何更新。如果一定要解构请用toRefs。第三种把reactive对象整个塞进store里再取出使用。某些状态管理场景下你从store拿到的如果是通过解构等方式拆出来的属性也可能丢失响应式。排查这类问题时我有个固定的流程先在模板渲染依赖的地方打断点确认模板里访问的确实是响应式对象再用isReactive或isRef检查对象身份最后检查赋值路径看看是不是在某个环节把对象拉平成普通值了。这个流程能解决95%的响应式丢失问题。4.2 数组与Map等特殊类型的坑reactive处理数组时大部分表现正常但有一个记忆点要提醒通过索引直接赋值arr[0] x会被Proxy捕获是响应式的但通过length属性赋值触发的变更覆盖范围可能超出预期需要留意。另外Map和Set这类集合类型在reactive里的响应式行为是支持的但它们的操作方法本身是非响应式风格的。比如你对reactive(new Map())执行map.set(key, value)Vue的Proxy拦截机制会拦截到是响应式的但如果直接通过map.size做条件判断并依赖它触发更新则不会触发——因为Proxy的get拦截对size这种内部槽位的读取不一定建立依赖追踪。为了避免这类问题说实话我在业务代码里很少用reactive包Map/Set更倾向于用ref装或者干脆序列化成普通对象来管理。4.3 类型推导与TypeScript场景下的体验差异在TS项目里ref和reactive的类型体验差异也挺明显的。ref的推导很直接const count ref(0)自动推导出Refnumber.value访问时类型安全检查完全在线。如果要用接口定义更复杂的数据结构refUserInfo({})或者refUserInfo | null(null)都很顺滑。reactive在类型推导上有个小坑const state reactive({})推导出的类型是{}给这个对象任意赋值都会被TS标红。正确姿势是提前定义接口const state reactiveUserInfo({})。如果你定义了接口但部分字段初始不存在可以用可选属性或者空值联合类型来处理。另外注意reactive数组的类型场景const list reactive([])推导类型是never[]赋值后端数据时TS会直接报错。必须显式声明reactiveany[]([])或reactiveUserInfo[]([])。这些都是我实际开发里踩过、也被同事吐槽过的细节。4.4 性能表现与初始化开销很多人担心Proxy比Object.defineProperty慢直觉上也是这么想的。但Vue3的Proxy是懒代理策略初始化时只代理当前访问到的层级深层对象在真正被访问时才递归代理。所以大多数业务场景下reactive和ref的初始化性能反而优于Vue2。实际对比来看ref因为多了一层RefImpl包装和.value访问读取时略微多一步操作但差距微乎其微在性能敏感的场景下几乎感知不到。真正要关注的是深度嵌套大对象的代理开销和依赖追踪粒度这方面ref和reactive是同一个内核选哪个影响都不大。如果确实遇到大列表渲染性能问题我建议优先检查是不是渲染了太多无关的响应式字段或者是否可以在v-memo、computed、shallowRef层面做优化而不是纠结ref和reactive之间的那点差异。5. 一个偷懒但好用的选型口诀与扩展5.1 个人项目里常用的快速判断法如果你实在不想记太多规则我送你一个自己项目里反复使用的判断口诀临时变量、基础数据、要整体替换的选ref固定结构、深层表单、频繁数组操作的选reactive封装逻辑时统一ref跨组件传递优先ref。简单来说默认ref遇到痛点再换reactive。这个思路可能不会被部分Vue3原教旨主义者认可但在实际项目里尤其是多人协作的中大型项目里统一规则的价值远大于哪个API更优雅——团队成员不需要每次都在心里做一次选择判断代码review时也不需要争论这里为什么用reactive这种没有标准答案的问题。5.2 shallowRef与customRef的延展思路最后再聊一个和ref紧密相关的扩展方向。shallowRef不会深度代理对象内部只有.value本身的替换才能触发更新。这个API在处理大对象更新场景比如地图实例、Canvas画布状态、第三方库实例时非常实用因为它省去了深层代理的开销也不需要对象内部属性变化时触发Vue的更新流程。customRef则允许你自定义一个ref的行为在get和set之间插入自定义逻辑。我遇到过用customRef实现防抖输入的场景输入框每次值变化都延迟更新响应式数据避免频繁触发计算和请求。这个用法在搜索框、筛选器这类组件里很实用。function debouncedRef(value, delay 300) { let timer return customRef((track, trigger) { return { get() { track() return value }, set(newValue) { clearTimeout(timer) timer setTimeout(() { value newValue trigger() }, delay) } } }) }类似的扩展思路还能延展到toRef、toRefs、unref这些工具函数上它们共同构成了Vue3响应式的基本工具箱。掌握ref和reactive的核心差异之后这些周边工具用起来会自然很多。说回开头的问题——到底用ref还是reactive我个人的体会是这个问题没有唯一答案但默认ref按场景换reactive的策略在绝大多数项目里都适用。与其纠结哪个更高级不如把重点放在理解它们底层的响应式机制上理解透了选型自然就顺了。
阅读完成 · 觉得有帮助?
咨询建站