Vue 3 的 ref 和 reactive 到底该用谁?结合源码谈谈我的真实感受

🔑 关键词:Vue3,ref,reactive,Composition API,响应式

📖 摘要:本文从源码和实际项目经验出发,深入对比Vue3的ref与reactive的使用场景,指出两者在实现机制、类型推导和响应式丢失上的关键差异,并提出个人建议。

不是所有数据都需要变成响应式

图片

在Vue3项目里用了大半年ref和reactive,踩了不少坑。最开始我习惯把所有对象都包成reactive,因为写起来像Vue2的data,比如const state = reactive({ count: 0 }),模板里直接state.count,看着很舒服。直到有一天,我用解构赋值把state里的count拿出来用,结果发现页面怎么都不更新。我查了一下Vue的源码,reactive通过Proxy实现,但解构出来的count已经不再是代理对象,自然丢失了响应式。这个坑在官方文档里其实有提示,但很多教程里根本没强调。我觉得这种设计本质上是把响应式建立在“引用”层面,而不是“值”层面,这跟JavaScript语言的传值特性天然冲突。

源码里藏着答案

图片

然后我打开packages/reactivity/src/reactive.ts看了下,reactive()内部对目标对象做了一次createReactiveObject,如果目标已经是readonly或已经有某个标记,就直接返回原对象,否则创建Proxy,并缓存代理。关键是在Ref的实现里,ref()的处理不一样,它对值是对象的情况会调用reactive(),但值是原始值时,它会用类属性访问器来拦截读写。也就是说,ref解决了原始值的响应式,但它不是一个代理,而是一个包装对象。所以那些说“ref是reactive的语法糖”的说法其实不准确,两者的底层工作方式有本质差异:reactive是对对象结构的代理,ref是创造一个新的可观察对象。我实际测试过,把10万个对象用reactive包起来和用ref对象包着,ref的性能要差一大截,因为每次访问.value都要走getter,而reactive直接走Proxy的get,尤其在深层嵌套时ref的嵌套结构会非常笨重。

使用场景应该按“数据形态”和“传递方式”划分

图片

现在的官方文档和社区意见有很多种,有的说默认用ref,有的说尽量用reactive。我觉得都不够根本。我的判断标准是:如果这个数据在代码流转中会被拆分、重组合、或者作为参数传给工具函数,那就不要用reactive,因为一旦拆出属性就响应式丢失。反过来,如果数据是一个完整的、不会被拆散的业务对象,比如表单对象,那你用reactive是安全的。前阵子我写一个监控面板,要实时处理多个传感器数据流,我用了ref(new Map())来存缓存,结果.value.set()是可以触发更新的,但如果我用reactive(new Map()),其实也有同样的效果,因为Proxy会拦截方法访问。但要注意,如果整个Map被重新赋值为一个新的Map,那ref能感知到,reactive如果直接state.map = newMap其实也可以感知,因为reactive对象本身也是响应式的属性赋值。所以真正的痛点不是reactive失去了对内部结构的劫持能力,而是“解构”和“传参”这两个把reactive对象“拆开”的操作。

我的折中方案:用ref管理基本数据,用reactive管理不可拆分的对象,然后统一用toRefs暴露

图片

实际项目中我越来越倾向一种折中的写法:定义store时用reactive包一个对象,但通过toRefs导出每个字段的ref,这样在组件中既能通过解构使用,又不丢失响应性。比如我写一个用户配置的store:

图片

const state = reactive({ name: '张三', age: 25, tags: ['admin'] })

然后在另一个文件里const { name, age } = toRefs(state),这样模板里可以直接用nameage,在setup里改写的时候要name.value = '李四'。这种方式对开发体验提升很大,也避免了一整串state.xxx的啰嗦。但要注意,toRefs只对对象第一层有效,嵌套的对象如果直接解构出来,内部的属性更新仍然不会触发深层视图更新,因为嵌套对象本身不是响应式代理。我某次在一个嵌套三层的数据上用了这个方式,结果改了内层数据,页面不刷新,排查了大半天。后来我干脆把内层也调用toRef,或者用ref深度包装。说到底,Vue3的响应式API设计得再细,最终还是要求开发者对“引用”的传递路径有清晰的认知。

图片

我的一些非主流意见

现在Vue社区一直强调“组合式函数”的使用,把相关逻辑放到useXxx里,看上去很好。但组合式函数本身并不解决响应式选型问题。我甚至觉得,组合式函数暴露返回的值如果是reactive对象,很容易造成隐性耦合。比如我做了一个useCountdown函数,返回reactive({ total, minute, second }),调用方直接解构出total来用,过了几分钟后setInterval更新了响应式对象,界面没变,因为解构出来的total已经是常量字符串。后来我改成返回total的ref对象,调用方用toRefs。这种问题很少被教程讨论,因为它们基本都假设你会乖乖写state.total。实际上,当你的代码规模一上去,你不可能每次都不解构。Vue官方文档其实建议在模板中可以直接使用ref的自动解包,但setup函数中无法自动解包,所以每次必须写.value,这导致很多新手犯错了。我的结论是:Vue3的ref和reactive不是简单的二选一,而是从设计上就要求你在“传递引用”和“保持响应式”之间做一个显式选择,这种选择无疑是增加了心智负担。但既然用了Vue,接受了这套体系,那么我们能做的就是建立自己团队的编码规范,比如规定所有跨模块共享状态用ref,组件内部的基础状态用ref,页面级的大型业务对象才用reactive,并且禁止直接解构reactive对象,所有对外输出必须经过toRefs。这个规范我们跑了三个月,基本没再出现“变了不更新”的奇怪bug。

🏷️ 标签: