
如果你是从 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.citytoRefs 不会递归处理你回头该 reactive 还是 reactive。到这里我习惯先把三者的一个速查表放出来后面章节展开细说。API适用数据类型要解决的核心问题模板中的写法ref基本类型、单一值、可选复杂对象让基本类型也能进入响应式体系自动解包直接写变量名reactive对象、数组、Map 等集合深层属性代理操作贴近普通对象直接写 state.xxxtoRefsreactive 对象的顶层属性解构之后依然与源对象保持连接解构出的 ref 继续自动解包2. 源码层面对比RefImpl、Proxy 与 ObjectRefImpl2.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 再无关系 // 用法 Breactive 配合 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 在模板里怎么用很多人会下意识想 .valuetemplate !-- 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什么情况用 reactive4.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 之后子组件里还能拿到实时的 userIdform 用 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 检测到递归深度超出阈值抛出这个错误。曾经有一个用户列表页面我在分页逻辑里这么写过一次整个页面直接卡死控制台刷屏。排查思路我一般按这三步来走看报错堆栈找到触发递归的那段 effect 是哪个多半指向一个 watchEffect、computed或者渲染函数。检查那段代码里有没有读某个数据的同时写同一个数据的行为。比如 computed 里修改自己的依赖、模板事件里同步修改渲染时读取的数据。如果是循环依赖场景A 依赖 BB 又依赖 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.valuename 就会变成普通字符串后续修改不再响应。我之前在一个个人信息展示卡片上踩过这个坑解构出来之后用户名怎么都不更新排查了半天才发现问题出在二次解构。碰到底层嵌套比较深的数据我现在的习惯是模板里直接写 state.user.name或者一层层 toRef 下去绝不中途剪断代理链。6. 三剑客之外的隐藏知识ref 的自动解包和它的兄弟们6.1 为什么模板里不用写 .valueref 之所以在模板里能裸用是因为 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先别急着说风格不统一多问一句数据形态是什么往往就理解他为什么那么选了。这个组合的边界值得你在真实项目里慢慢感受。