☰
Vue3响应式三剑客:ref、reactive、toRefs的本质区别与选型指南
2026/10/5 8:46:08 网站建设 项目流程

如果你是从 Vue2 切过来的,第一次看到 ref、reactive、toRefs 这三兄弟的时候,一定会冒出同一个念头:它们仨到底有什么本质区别?不都是让数据变成响应式的吗,为什么要造三个轮子?我当年翻了半个晚上的源码,结合自己后面做后台管理系统、写复杂表单弹窗和排一个整天整夜响应式问题的经历,才把这几个 API 彻底捋顺。

在往下看之前,请先记住一句话:这三个 API 并不是平级的三个平行轮子,而是有层次的响应式工具组合。ref 是为了让"基本类型"也能进响应式体系;reactive 是为了让"复杂对象深层属性"能被代理跟踪;toRefs 是为了让"对象解构出来之后的属性"还能继续和源对象保持连接。这三句话是全文的地基,后面的每一个例子、每一个报错场景,都逃不开这个底层分工。

接下来,我会先讲清楚它们存在的必要性,再从源码层面拆解各自的实现,然后用一组可以直接复制的对比实验展示三者行为差异,最后给出项目中的选型建议和我在线上环境排过的几个真实问题。

1. 为什么是三个而不是一个:响应式体系的三层需求

1.1 Vue2 时代的一个痛点:基本类型难以追踪

Vue2 的 data 函数返回一个对象,响应式的核心是 Object.defineProperty 去重定义这个对象的属性。这个方案有两个天然的坑:第一,新增属性不会响应,所以才会有 Vue.set 这种补丁函数;第二,基本类型根本没有"属性"可以劫持,你总不能把一个数字变成 Object.defineProperty 的 target 吧。

所以 Vue2 生态里,一个布尔值、一个字符串,在放进响应式状态时,往往要强行塞进一个容器对象里,比如data() { return { userForm: {...}, loading: false } }。加载状态和用户信息、列表数据全塞在一个扁平对象里,时间一长,代码会非常乱。我见过不少 Vue2 老项目,一个 data 对象里几十个 key,彼此的归属关系全靠命名规范硬撑,开发和维护的人换几茬之后,基本没人敢动那个对象的结构。

Vue3 引入 Composition API 之后,最直接的诉求就是:我要能够把一个简单的数字或者布尔值单独拿出来,让它自己具备响应式能力。于是 ref 出现了,专门解决把基础类型变成响应式节点的问题。这一点看着简单,实际上直接改变了我们组织状态的方式——状态不用再挤在一个 data 对象里,而是可以像搭积木一样按需组合。

1.2 对象层面的深度代理:reactive 接替 data

有了 ref,是不是就不需要 reactive 了?还真不是。当你面对的是一个层级很深的用户信息对象、一份多层的表单数据,如果全程用 ref,整个对象被 ref 包装后,平时取数据要不断写 .value,深度嵌套时写user.value.address.value这种组合会非常别扭,代码的可读性会被 .value 淹没。

reactive 直接返回一个 Proxy 代理对象,你操作代理对象的方式和操作普通对象几乎一模一样。对于日常的表单、用户信息、配置对象,reactive 的体验最接近 Vue2 的 data,但底层已经是 Proxy 而不是 defineProperty,增删属性、数组索引修改这些 Vue2 的遗留问题都被解决了。

拿一个典型场景举例:用户编辑弹窗里有一个三层嵌套的地址结构,用 reactive 包住之后,form.address.city = '北京'这样写,视觉上和操作普通对象没有差别,但响应式更新已经悄悄发生了。从属性增删到嵌套对象替换,Proxy 都能拦下来,Vue2 里需要 Vue.set 的场景在 Vue3 里天然就正常,这套机制的收益是实打实的。这就是 reactive 存在的价值:对象场景下的直觉操作。

1.3 toRefs 解决的是另一个问题:解构剪断响应链

reactive 不是挺完美的吗?但它有一个致命伤:解构。在 setup 里 return 一个 reactive 对象时,很多习惯 Vue2 写法的同学会顺手 return{ ...state },或者直接在 JSX 里const { name, age } = state,结果页面怎么都不更新。

原因很简单,解构出去的基本类型属性,拿到的是一个值拷贝,和源对象再无关联。为了把"结构化的状态"和"方便的解构写法"结合起来,Vue3 配套提供了 toRefs。

我还是用一个生活化类比来说。ref 相当于给每个基本类型单独做了一个可以通电的盒子;reactive 相当于给复杂对象布了一张电网;toRefs 则是把这张电网的接口做成了一打可以随身携带的插头。你从插座面板上拔下来一个插头带出门,电源线并没有断,电网依然实时供电,只是你拿数据的姿势变了。这也是 toRefs 和 reactive 定位最本质的区别:reactive 管"数据在哪",toRefs 管"数据怎么拿"。

注意:toRefs 只对 reactive 对象的顶层属性生效。嵌套的对象属性,比如 state.address.city,toRefs 不会递归处理,你回头该 reactive 还是 reactive。

到这里,我习惯先把三者的一个速查表放出来,后面章节展开细说。

API适用数据类型要解决的核心问题模板中的写法
ref基本类型、单一值、可选复杂对象让基本类型也能进入响应式体系自动解包,直接写变量名
reactive对象、数组、Map 等集合深层属性代理,操作贴近普通对象直接写 state.xxx
toRefsreactive 对象的顶层属性解构之后依然与源对象保持连接解构出的 ref 继续自动解包

2. 源码层面对比:RefImpl、Proxy 与 ObjectRefImpl

2.1 ref 的实现:内部其实还是抱了 reactive 的大腿

为了避免纸上谈兵,我写过一个简化版的 ref 源码来理解它的运作方式:

// Vue3 源码 ref.ts 的简化逻辑 class RefImpl { constructor(value) { this.__v_isRef = true; // toReactive:如果传入的是对象,则交给 reactive 转换 this._value = toReactive(value); } get value() { track(this, 'value'); return this._value; } set value(newVal) { this._value = toReactive(newVal); trigger(this, 'value'); } } function toReactive(value) { return isObject(value) ? reactive(value) : value; }

关键点在这个 toReactive。你以为 ref 只负责基本类型?其实它做了兼容:当你在 ref 里塞一个对象时,ref 内部会调用 reactive 把对象变成代理。也就是说,ref 是站在 reactive 肩膀上多包了一层的产物,而不是和 reactive 平行的另一个实现。每当 ref.value 被替换成新对象,它也会自动走一遍 toReactive,让新对象立刻拥有深层响应式,无需你手动再调一次 reactive。

这个设计带来的直觉是:ref 是"全能选手",基本类型能撑,复杂对象也能撑,但代价是访问路径上多了一个 .value。所以简单值状态用 ref 很顺手,复杂的嵌套结构还是交给 reactive 更符合直觉。

2.2 reactive 的实现:Proxy 代理加惰性递归

reactive 的核心是 Proxy,但真正让我觉得妙的,是嵌套代理的"惰性"设计。看简化的 get 逻辑:

function reactive(target) { const proxy = new Proxy(target, { get(target, key, receiver) { const value = Reflect.get(target, key, receiver); // 依赖收集 track(target, key); // 关键:只有这一层被访问到时,才给嵌套对象做代理 if (isObject(value)) { return reactive(value); } return value; }, set(target, key, value, receiver) { const result = Reflect.set(target, key, value, receiver); // 触发更新 trigger(target, key); return result; } }); return proxy; }

Vue2 是在初始化时递归把所有属性全部 defineProperty,初始化慢;Vue3 是等你真正访问到某个嵌套对象时,才当场给它包一层代理,按需分配。大型用户对象往往有十几个嵌套字段,实际渲染用到的可能只有三四个,惰性递归能省下不少初始化开销。这点在组件变多、数据结构变深之后,体感差异会被逐步放大。

不过惰性也带来一个反直觉的点:如果你访问了一个嵌套对象之后,它的代理会被缓存并复用;但如果嵌套对象是在后续某个时机才被新增的,第一次访问到它时才会建立代理。依赖跟踪是跟着"属性访问"走的,不是跟着"对象存在"走的。

2.3 toRefs 的实现:一个绝不截断的管道

toRefs 源码比大多数人想象得简单:

function toRefs(object) { const result = {}; for (const key in object) { result[key] = toRef(object, key); } return result; } function toRef(object, key) { return { get value() { return object[key]; // 实时从源对象读 }, set value(newVal) { object[key] = newVal; // 直接写回源对象 } }; }

注意它根本没有缓存"解构出来的值",而是每次 get value 都实时从源对象读取。解构出来的 ref 对象,本质上保留了它和那个 reactive 对象的强引用,你要改数据,改的永远是源对象,而源对象的 Proxy 代理会自动触发响应式更新。这就是 toRefs 解构不断链的根本原因——它断掉的只是"一次取值"还是"一条管道"的选择问题。你取值时也许觉得走了弯路,但这条弯路的尽头就是响应式数据的源头。

这个实现也解释了另一个特性:如果你在 toRefs 之后,又往源 reactive 对象上新增了一个属性,这个新属性不会自动出现在之前 toRefs 的结果里。要拿到新属性的 ref,需要重新调用一次 toRefs,或者一开始就用 toRef 手动指名。

3. 一组可以直接跑的对比实验:解构、赋值、替换的差异

3.1 解构场景:reactive 会断,toRefs 不会

这个对比是这三兄弟里面最容易被踩的。看一段可以直接在 setup 里跑起来的表现差异极大的代码:

const state = reactive({ count: 0, name: 'demo' }); // 用法 A:直接解构 reactive const { count, name } = state; count++; // 页面没有任何变化,因为 count 已经变成普通数字,和 state.count 再无关系 // 用法 B:reactive 配合 toRefs const { count: rCount } = toRefs(state); rCount.value++; // state.count 同步变化,任何依赖 state.count 的视图都会更新

这里有个很多文档没强调透的点:直接解构 reactive,丢响应式的主要是基本类型属性。如果你解构出来的是一个嵌套对象属性,比如const { address } = state,这个 address 本身已经是被代理过的对象,继续操作 address.city 依然是响应式的。真正致命的是基本类型和函数返回值,它们拷贝后与源对象彻底断联。很多人在页面上排查半天,"都解构了怎么不响应"的原因就在这里——解构出去的数字和字符串已经变成普通变量了。

所以我的经验法则是:reactive 对象留在原地不动,用来管理;要用解构的地方,统一走 toRefs 的出口。

3.2 整体替换场景:ref 能换边,reactive 换不动

再看整体赋值场景,这是选型时最容易纠结的地方:

// ref 的重新赋值 const counter = ref({ total: 1 }); counter.value = { total: 100 }; // 没问题,新对象也会被 ref 内部转成响应式 // reactive 的重新赋值 let state = reactive({ total: 1 }); state = { total: 100 }; // 不报错,但响应式断了,页面不更新

reactive 返回的代理对象,始终指向初始化时传入的那个原始对象。你把代理对象整个换成新的普通对象,响应式链条自然断掉。所以遇到定时刷新、重置表单、下拉数据整体替换这类"需要整片换掉"的场景,我一般优先考虑 ref 或者 reactive 里的容器设计,比如const data = reactive({ list: [] }),替换时只改 data.list 这一层,而不是替换整个 data 对象。

如果不幸用了 reactive 且确实需要整体重置对象内容,标准的做法是 Object.assign 逐字段回填,保留代理引用不变。这在表单重置场景里很常见。你要记住一个核心区别:ref 重置的是"值",reactive 重置的是"属性",前者是换新,后者是改造。

3.3 模板渲染差异:自动解包行为不完全一样

模板中 ref 会自动解包,大家比较熟;但 toRefs 解构出来的 ref 在模板里怎么用,很多人会下意识想 .value:

<template> <!-- ref 直接写名字 --> <p>{{ count }}</p> <!-- toRefs 解构出的 ref 同样是 ref,模板中也是裸变量名 --> <p>{{ rCount }}</p> <!-- reactive 则需要带属性名 --> <p>{{ state.count }}</p> </template> <script setup> import { ref, reactive, toRefs } from 'vue'; const count = ref(0); const state = reactive({ rCount: 1 }); const { rCount } = toRefs(state); </script>

模板自动解包有个隐藏的行为边界:它只对顶层 ref 生效。如果返回值是类似list[0]这样包在数组里的 ref,或者obj.child.value这样包在深层对象里的 ref,是不会自动解包的。我建议模板里尽量别写 .value 形式的表达式,能用解构、computed 或者干脆换一种数据结构避开的,就换一种。.value 出现在 template 里,阅读和维护的负担都会明显加重。

4. 项目选型判断:什么情况用 ref,什么情况用 reactive

4.1 单一状态单元:统一用 ref

写组件时有大量零散的独立状态,比如 isLoading、keyword、currentPage。我强烈建议用 ref,理由有两个。第一,ref 是全能选手,基本类型可以直接承载,不用为了响应式去创建一个专用容器对象;第二,ref 在模板中自动解包,直接写变量名,不用像 reactive 对象的属性那样需要写 state.isLoading 这种带前缀的写法,代码更清爽。

如果团队里做了组合式函数的封装,我更建议统一 ref 作为返回值规范。因为 useUserData() 这种 hook 返回的东西通常既有数字也有字符串还有嵌套对象,清一色 ref,消费方用 const { user, role, loadUser } = useUserData() 解构出来,每个变量单独赋值、传参、当 watch 源,都是非常自由的。遇到要整体替换数据源的场景,直接 user.value = newUser,简单省事。在 uni-app 这类跨端环境下,ref 的通用性优势也一样成立,template 里写裸变量名,逻辑层里管理 .value,一套代码端内端外都不别扭。

4.2 结构化的大对象:reactive 负责管理,toRefs 负责交付

如果你面对的是后台管理系统那种表单场景——五六个输入项、三级联动地址、动态表格再加一堆校验状态——我强烈建议用 reactive 维护整个表单结构:

const form = reactive({ name: '', age: 18, address: { province: '', city: '', district: '' }, skills: [], submitting: false });

操作和取值都和普通对象一模一样,不会有 .value 嵌套的折磨。等要把 form 的某个字段交给子组件或模板时,再通过 toRefs 把它拆开。这里顺带说一个我的命名习惯:用 reactive 管理的状态,我习惯用 form、state、config 这种偏名词的命名;用 ref 管理的状态,用 isLoading、keyword 这种偏属性的命名。命名一统一,整个文件的语义就清楚很多,别人接手你的代码时不用翻上下文就知道哪些是结构化数据、哪些是零散开关。

4.3 一个真实组件里的组合写法

我写过一个用户编辑弹窗,里面三种 API 是同时出现的,把代码简化之后长这样:

const props = defineProps({ userId: { type: Number, required: true }, visible: { type: Boolean, default: false } }); const { userId, visible } = toRefs(props); const form = reactive({ name: '', age: 20, email: '' }); const saving = ref(false); async function save() { saving.value = true; try { await api.updateUser(userId.value, { ...form }); ElMessage.success('保存成功'); visible.value = false; } finally { saving.value = false; } }

这里 props 用 toRefs,是为了在父组件更新 props 之后,子组件里还能拿到实时的 userId;form 用 reactive,因为它是一个结构化对象,用起来最顺手;saving 用 ref,因为它就是独立一个布尔值,直接当开关用。

这三者在这个弹窗里各司其职,没有替代关系。这也是我理解"三剑客"这个称呼真正的意义:你需要的是组合和分层,不是在一个 API 里撞到黑。

5. 几个高频踩坑的完整排查记录

5.1 maximum recursive updates exceeded:这个报错是怎么来的

很多新手看到这个报错的第一反应是"模板写坏了",但你搜一下报错全文:maximum recursive updates exceeded. This means you have a reactive effect that is mutating its own dependencies。

翻译成人话:你在一个响应式的 effect 里,既读取了某个响应式数据,又改写了它自己。最常见的触发写法:

const count = ref(0); watchEffect(() => { // 读 count,同时写 count,每次 count 变化都会再次触发当前回调 count.value++; });

每次 count.value++ 都会让 watchEffect 重新执行,回调又去执行 count.value++,无限循环,直到 Vue 检测到递归深度超出阈值,抛出这个错误。曾经有一个用户列表页面,我在分页逻辑里这么写过一次,整个页面直接卡死,控制台刷屏。

排查思路我一般按这三步来走:

  1. 看报错堆栈,找到触发递归的那段 effect 是哪个,多半指向一个 watchEffect、computed,或者渲染函数。
  2. 检查那段代码里有没有"读某个数据的同时写同一个数据"的行为。比如 computed 里修改自己的依赖、模板事件里同步修改渲染时读取的数据。
  3. 如果是循环依赖场景(A 依赖 B,B 又依赖 A),考虑调整数据流的方向,或者用 watch 加条件判断打破递归。

5.2 reactive 数组替换:索引更新支持了,但 filter 等方法还是坑

Vue3 用 Proxy 之后,数组arr[0] = 1这种索引更新已经能正确触发响应式,Vue2 时代的老毛病基本没了。真正容易踩的坑出在 map 和 filter 这类返回新数组的方法上。

const list = reactive([{ id: 1, name: 'A' }, { id: 2, name: 'B' }]); // 注意:filter 返回的是新数组,不再是响应式代理 const filtered = list.filter(item => item.id > 1); filtered[0].name = 'C'; // 修改不触发任何更新,因为 filtered 是普通数组

解决办法有三种:第一种,直接在原数组上操作,比如 splice 去掉不需要的项;第二种,把 filter 的返回值用 reactive 包一层再使用;第三种,更推荐的做法,把数组放进 reactive 对象的属性里,比如const state = reactive({ list: [] }),替换时操作 state.list,这样数组本身始终处于代理链上,filter 之后手动回填 state.list 也容易。

5.3 toRefs 只处理顶层属性:嵌套对象的断链风险

toRefs 循环的是对象的顶层 key,嵌套对象里的属性不会自动变成 ref,这是它的直接设计边界。

const state = reactive({ user: { name: '张三', age: 26 } }); const { user } = toRefs(state); user.value.age++; // 有效,user 拿到的是被 reactive 包裹的嵌套对象

但如果你在模板或者 JS 里再对 user 做一次浅解构:const { name } = user.value,name 就会变成普通字符串,后续修改不再响应。我之前在一个个人信息展示卡片上踩过这个坑:解构出来之后,用户名怎么都不更新,排查了半天才发现问题出在二次解构。碰到底层嵌套比较深的数据,我现在的习惯是模板里直接写 state.user.name,或者一层层 toRef 下去,绝不中途剪断代理链。

6. 三剑客之外的隐藏知识:ref 的自动解包和它的兄弟们

6.1 为什么模板里不用写 .value

ref 之所以在模板里能裸用,是因为 Vue3 的模板编译器在生成渲染函数的时候,会自动给 ref 对象做解包处理。这个过程发生在渲染函数内部,你看不到,但一定要清楚它背后的机制:模板里的 count 本质上是拿 RefImpl 对象去访问它的 value。

这个机制带来一个小坑:如果你在模板表达式里写了一个返回 ref 的函数调用,比如{{ getUser().age }},返回的可能是一个 ref,模板会尝试自动解包并拿到 value 的内容。多层嵌套时,行为未必符合直觉。遇到奇怪的模板渲染结果,先怀疑是不是 ref 自动解包导致的,打印一下表达式返回值的类型,经常能一击命中。

6.2 ref 塞进 reactive 会被自动解包

把 ref 放进 reactive 对象时,Vue 会自动解包:

const innerCount = ref(0); const state = reactive({ innerCount }); console.log(state.innerCount); // 0,不是 ref 对象 state.innerCount = 10; // 实际上改的是 innerCount.value innerCount.value++; // 两者互通,页面更新一致

这在大多数时候是好事,能省去来回 .value 的麻烦。但如果你真的想在 reactive 里保存一个 ref 对象本身,这个自动解包就会让你措手不及。解决方案是把 ref 放进数组再放进 reactive,或者用 non-reactive 的容器。其实我自己的经验是:这种需求极其少见,真遇到时先停下来想想,是不是数据结构设计得有问题。自动解包的设计初衷是为了让你少写 .value,而不是为了给你埋雷。

6.3 家族里的其他成员:shallowRef、customRef、readonly

掌握了三剑客之后,建议顺带了解一下深水区的兄弟 API,它们都在家务层里。

shallowRef 不处理深层响应式,适合那种只整体替换、不关心内部字段级修改的大对象,性能上有优势;customRef 允许你自定义 track 和 trigger 的逻辑,写防抖 ref 非常顺手;readonly 则是给响应式数据加一个只读外壳,适合暴露给不受信任的模块读但不允许改的场景。

大多数项目的主流程其实用不上这几个,但知道它们存在,能让你在遇到"ref 不够用、reactive 太沉重"的边界场景时,知道该往哪个方向找答案。

我在项目里沉淀下来的最终经验是:能拆就拆,能聚就聚。零散状态用 ref,结构化数据用 reactive,需要交付解构时套一层 toRefs。三剑客从来不是让你选一个用到死,而是让你根据数据的粒度和场景,自由组合出最顺手的那一套。看到团队里同事一下子用 ref、一下子用 toRefs、一下子又 reactive,先别急着说风格不统一,多问一句数据形态是什么,往往就理解他为什么那么选了。这个组合的边界,值得你在真实项目里慢慢感受。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询