如果你用 Vue 3 写过一段时间的业务代码,大概率碰到过这种情况:用reactive定义了一个对象,模板里怎么用都正常,结果在 script 里一解构,视图就不跟手了——数据确实变了,页面纹丝不动。我第一次遇到时还以为是 Vue 的生命周期问题,翻了一圈文档才发现,解构出来的变量已经不是原来的响应式属性了。这个问题的标准解药,就是今天要讲的toRefs和toRef。
这两个 API 是 Vue 3 Composition API 里最容易被人拿着当"咒语"用的工具,大家知道它能保住响应性,但很少有人讲清楚它到底是怎么保的、边界在哪、什么时候该用它、什么时候用了反而多余。这篇就围绕它们写透,从原理到实战到坑,一条龙讲明白。
1. 先用一个真实场景,看看 reactive 解构后为什么"变哑"
1.1 十行代码复现响应性丢失
先看一段最典型的"翻车"代码:
import { reactive } from 'vue' const state = reactive({ count: 0, name: 'Vue3' }) // 直接解构 const { count, name } = state // 修改 count,视图不会更新 function increase() { count += 1 console.log('count:', count) console.log('state.count:', state.count) }跑起来之后你会发现很有意思:打印出来的count已经自增了,state.count也自增了,但页面上绑定的count纹丝不动。如果你把页面模板写成{{ count }},那就更直接了——根本不会更新。
原因一句话就能说清:reactive返回的是一个Proxy代理对象,你对它做的读写操作都会被拦截,从而触发依赖收集和更新派发。但解构的行为是——把代理对象上当前属性的值拷贝到新的普通变量里。这个拷贝出来的count是一个普通数字,后续再怎么改,跟原来的代理对象没有任何关系。
为了更直观,你可以在组件里加一个按钮来触发increase,再配合 Vue DevTools 看state.count的变化。你会看到 state 里的数据确实在变,但页面上那个绑定count的地方永远停留在初始值。这其实不是 Bug,而是解构这个 JavaScript 行为本身的特性。
1.2 代理对象和值快照之间的本质差异
要彻底理解这个问题,得把 JavaScript 的"对象访问"机制掰开看。
reactive的本质是拦截对象的属性读取和写入。你可以把它想象成一台带遥控的主控台:遥控器上的按钮连接着台内各条线路,按一个钮,对应线路就通电。而解构操作相当于——你把遥控器面板上的每个按钮"扣下来"当纪念品带走,这些按钮离开了主控台,后面再怎么按,当然是没有任何线路反应的。
从数据层面讲就是:
const { count } = state // 等价于 const count = state.count这一步执行的时候,count拿到的是当前值,之后你就是把金条堆上去,也改变不了state.count的值。反过来,你在state.count上做修改,这个普通变量也感知不到。
更有迷惑性的是,如果你在模板里直接用state.count,它又是好的。因为模板里的state还是那个代理对象,读取时走的是Proxy的get拦截,依赖依然被正确收集。所以很多人一开始会以为"是 reactive 坏了",其实它是被你解构的时候"甩掉了"。
1.3 日常开发中最容易中招的三个位置
结合我自己的项目和身边同事踩过的坑,响应性丢失不是偶发现象,它高频出现在下面几个位置:
组合式函数返回的对象被调用方解构。比如你封装了一个
useUser,内部用reactive存用户信息,返回值里包含userInfo和一些方法。调用方拿到后习惯性地const { userInfo } = useUser(),这时候userInfo已经不是那个响应式代理了,页面上引用它的地方全部"断电"。setup 内部把 reactive 对象的属性拆成普通变量,再塞进
watch或computed里。最常见的是:
const state = reactive({ page: 1, pageSize: 10 }) const { page } = state watch(page, () => { // 永远不触发,因为 page 是个普通数字,不是响应式引用 })- 把对象里的某个值提取出来传给普通函数。比如
getArticle(state.id),如果这个函数内部只是按值使用,那没问题;但如果它内部把这个参数再喂给watch或者存进组合式函数里,响应性就断了。
这三种情况看着不一样,本质完全相同:你把一个原本活在响应系统里的值,复制成了一块"脱离电网"的孤岛。理解到这个层面,后面看toRefs和toRef就会非常清楚——它们做的就是一件事:把孤岛重新接回电网。
2. toRefs:一键保住整个对象的解构能力
2.1 基础用法与联动效果
toRefs的定位很直白:把一个响应式对象的所有属性,都转换成独立的 ref,并让这些 ref 和原对象保持联动。
import { reactive, toRefs } from 'vue' const state = reactive({ count: 0, name: 'Vue3' }) const refs = toRefs(state) // refs 结构: { count: Ref<number>, name: Ref<string> } const { count, name } = refs // 修改 ref,原对象跟着变 count.value = 10 console.log(state.count) // 10 // 修改原对象,ref 也感知得到 state.name = 'Vue4' console.log(name.value) // 'Vue4'这里的核心效果是"双向联动":count.value改的是原对象上的属性,state.count改的时候,count.value读到的也是新值。因为ref本身是 Vue 响应系统的一部分,所以解构出来的每个变量都能在你的模板、watch、computed里作为真正的响应式引用去使用。
我在刚上手 Vue 3 的那段时间,一直把toRefs理解成"给对象安装一个解构安全壳",后来写多了才意识到,更好的理解方式是——toRefs把对象中的每个属性都变成了一条"拉线",线的另一头仍然连在原来的代理对象上,你拉哪条线,主控台对应的线路就跟着动。
2.2 原理:每个属性都是一座"桥"
表面上toRefs做了一件很简单的事:遍历对象的所有属性,对每个属性调用一次toRef。但精髓在于toRef内部构造的那个 ref,并不是简单地复制当前值,而是自定义了读写操作。
Vue 源码里对应的是一个叫ObjectRefImpl的类,它的核心逻辑可以简化为:
class ObjectRefImpl { constructor(_object, _key) { this._object = _object this._key = _key } get value() { return this._object[this._key] } set value(newVal) { this._object[this._key] = newVal } }也就是说,这个 ref 实际上没有自己保存数据,它的value读取永远通过this._object[this._key]去拿,写入也永远是通过它写进原对象。
所以当你const { count } = toRefs(state)之后,count这个 ref 的get value会跑去读state.count,而state.count读取的是代理对象,会触发依赖收集,自然就把响应式链路完整接上了。
这个设计非常巧妙,它把"保留响应性"这个需求,变成了"保留属性访问能力"。你不关心值在哪一步发生变更,只要读写始终落在原代理对象上,响应系统就能全程工作。
2.3 模板与 script 中各自的使用姿势
toRefs解构出来的变量是 ref,所以它同时要遵守两条规则:
模板里自动解包。你不需要写
.value,{{ count }}会直接显示count.value的值。这是因为 Vue 3 模板渲染器遇到 ref 时,会自动读取它的.value。script 里必须手动
.value。count.value = 10、console.log(name.value),漏掉.value你拿到的就是整个 ref 对象,而不是里面的值。
这两条规则刚入门时特别容易混,尤其是从 Vue 2 切过来的朋友,总是默认"对象上直接取属性"是安全的。我自己就出过这样的洋相:在某个方法里写keyword.trim(),结果keyword是 ref,根本没有trim方法,控制台报错报得我一脸懵。
为了减少这类笔误,有个小技巧:如果只在模板里展示数据、在事件处理里通过方法修改状态,那你完全可以放心解构。但如果要在 script 里频繁读取和赋值,要么养成立刻加.value的肌肉记忆,要么就干脆保留原来的state对象,用state.count来访问。
2.4 别忘了它只处理浅层
toRefs触碰的只是对象的第一层属性。如果对象里嵌套了另一个对象,情况要区分看:
const state = reactive({ user: { name: '张三', age: 18 } }) const { user } = toRefs(state) user.value.name = '李四'这里user是一个 ref,它的value指向的是state.user。由于reactive是深度代理,state.user本身也是一个代理对象,所以user.value.name = '李四'依然能触发响应式更新。你不需要对嵌套对象再手动调一次toRefs,它能用的原因是"原对象深层的代理还没断"。
但如果你本来就用的是一个只做了浅层代理的shallowReactive对象,那toRefs解构出来的嵌套对象就不会有深层响应性。这个看场景,业务代码里 90% 用的都是深度reactive,所以大多数情况下你不需要过度担心嵌套问题。
还有一个隐含边界是:toRefs返回的对象并没有被包装成响应式对象,它只是一个普通对象,里面的每个属性都是 ref。这意味着你不能给这个返回对象再添加新属性并且期望它响应式,新增键必须以原对象为基准去初始化。
3. toRef:只保一个属性的响应性
3.1 用法与典型场景
toRef和toRefs是一对孪生工具,区别在于,toRefs一次性处理整个对象,toRef只处理单个键:
import { reactive, toRef } from 'vue' const state = reactive({ count: 0, name: 'Vue3' }) const countRef = toRef(state, 'count') countRef.value = 5 console.log(state.count) // 5toRef最典型的使用场景是:你手里已经有一个响应式对象,但你只需要把其中一个属性传给别的地方去用,又不想传整个对象。比如下面这个封装:
function useCountUp(sourceRef) { const doubled = computed(() => sourceRef.value * 2) function increase() { sourceRef.value += 1 } return { doubled, increase } } const state = reactive({ count: 0 }) const countRef = toRef(state, 'count') const { doubled, increase } = useCountUp(countRef)如果把state.count这个普通数值直接传进去,里面基于它做的computed、watch全部失效。而传toRef(state, 'count')进去,外部函数可以安全地读取、监听,甚至可以在函数内修改这个 ref,修改会直接反映到原对象上。
3.2 props 与组合式函数:toRef 的黄金搭档
如果说toRefs是"组合式函数返回值解构"的标准答案,那么toRef就是 "props 单属性传递"的默认姿势。
在<script setup>里,props本身是响应式的,但如果你把它解构成普通变量,就会切断响应链:
// 错误示范:解构 props 导致响应性丢失 const { id, visible } = defineProps(['id', 'visible'])正确的做法是:
const props = defineProps(['id', 'visible']) // 需要用响应式引用时,用 toRef 单独取 const idRef = toRef(props, 'id') const visibleRef = toRef(props, 'visible') watch(idRef, (val) => { // 有效 })这个模式在你封装通用组件时非常有用。比如写一个分页组件,父组件传currentPage进来,子组件内部要监听它的变化去请求数据,就可以用toRef(props, 'currentPage')接住,然后传给内部封装的组合式函数。
需要提醒一点:props在 Vue 3 中是只读的单向数据流约束。用toRef包装后,虽然技术上你可以给它赋值,因为ObjectRefImpl的setter会写回原对象,但这样做会破坏组件数据流的约定,应该通过emit通知父组件去修改。toRef在这里的正确用法是读取和监听,而不是改造。
3.3 toRef 和 ref 到底哪里不同
这是很多人都会搞混的一对概念。ref创建一个"自有"的响应式变量,它内部自己存值;toRef创建一个"桥接"的响应式引用,它的值始终来自目标对象。
举一个生活化的例子:ref相当于你自己办了一张独立的银行卡,钱在自己账户里;toRef相当于办理了一张主卡的副卡,主卡账户动一毛钱,副卡余额就变一毛钱。
具体到代码:
const state = reactive({ num: 1 }) const refNum = ref(state.num) // 独立变量,初始值是 1,后续与 state.num 无关 const toRefNum = toRef(state, 'num') // 桥接引用,始终对应 state.num state.num = 100 console.log(refNum.value) // 1,不变 console.log(toRefNum.value) // 100,跟着变这个差异的实践意义很大。如果你在某个组件里用ref(state.count)去"拷贝"一个响应式值,后面原对象更新了,你的 ref 还停留在旧值上,容易引发隐蔽的数据不一致问题。判断标准很简单:你希望这个新变量跟原对象保持联动吗?希望,就用toRef;不希望,说明你本来就要一个独立副本,那就用ref。
4. 两者放在一起:选型对比与源码关系
4.1 一张表把差异说清楚
与其记一堆零散结论,不如直接看下面这张对比表:
| 维度 | toRefs | toRef |
|---|---|---|
| 输入 | 一个响应式对象 | 一个响应式对象 + 一个键名 |
| 输出 | 新对象,所有属性均为 ref | 单个 ref |
| 典型场景 | 组合式函数返回对象后整体解构 | 从对象中取某一个属性单独传递/监听 |
| 对原对象的引用 | 所有生成的 ref 都保持桥接 | 生成的单个 ref 保持桥接 |
| 数组支持 | 支持,返回等长数组 | 支持,key 为数字索引 |
| 是否改变原对象结构 | 不改变 | 不改变 |
用一句话总结选型逻辑:要解构一个对象的多个属性,用toRefs;只需要拿走一个属性并保持响应性,用toRef。后者本质上也是前者内部实现的基本单位。
4.2 源码视角:toRefs 就是循环调用 toRef
前面提到,toRefs的核心实现就是遍历对象所有可枚举属性,逐一调用toRef。你可以在 Vue 的 reactivity 源码里看到非常直接的代码,去掉类型标注和边界处理后的简化版大概是这样:
export function toRefs(object) { const ret = Array.isArray(object) ? new Array(object.length) : {} for (const key in object) { ret[key] = toRef(object, key) } return ret }所以你在使用时完全可以认为:toRefs(obj)等价于"对 obj 的每个 key 手动执行toRef然后把结果放进一个新对象"。这也解释了为什么说"第四章"里的对比表其实根本没有冲突——toRefs是toRef的批量升级版。
再看toRef本身的实现逻辑,它有一个很贴心的处理:如果目标属性本身已经是 ref,它会直接把它原样返回,不会套一层再套一层。所以你在业务里反复调用toRef不会产生额外的性能损耗,因为获得的引用可能是同一个。这也意味着,toRefs和toRef在组合使用时很安全。
4.3 用在普通对象上会发生什么
toRefs和toRef并不要求输入必须是reactive创建的对象,普通对象也一样能接收。区别在于,普通对象上没有 Proxy 拦截,桥接的 ref 修改值时,虽然原对象会被同步改动,但不会触发任何视图更新——因为没有响应系统在背后监听。
const plain = { a: 1 } const refA = toRef(plain, 'a') refA.value = 2 console.log(plain.a) // 2,但没有任何组件会因为这次修改而更新这在某些场景下是有用的,比如你想让两个普通变量共享同一份数据,又不想引入 Vue 的响应系统干预。但业务代码里这种需求非常少,大多数时候你还是希望"修改触发页面更新"的。所以请记住一句:toRef / toRefs 的价值,主要绑定在响应式对象上;用在普通对象上,它们退化成普通的共享引用工具。
5. 实战重构:一个搜索模块的演进过程
5.1 原始版本:组合式函数返回值被解构后失效
为了把这两个 API 用活,我拿一个真实项目中常见的搜索模块来完整走一遍。这是我认为最有教学价值的例子,因为它几乎涵盖了所有和toRefs、toRef相关的问题形态。
最初我写的版本是这样:
// composables/useSearch.js import { reactive } from 'vue' import { fetchList } from '@/api' export function useSearch() { const state = reactive({ keyword: '', page: 1, pageSize: 10, total: 0, loading: false, list: [] }) async function search() { state.loading = true try { const res = await fetchList({ keyword: state.keyword, page: state.page, pageSize: state.pageSize }) state.list = res.list state.total = res.total } finally { state.loading = false } } function reset() { state.keyword = '' state.page = 1 state.pageSize = 10 state.list = [] state.total = 0 } return { state, search, reset } }页面组件里我一开始是这样用的:
const { state, search, reset } = useSearch() const { keyword, page, pageSize, loading, list, total } = state这段代码跑起来之后,页面初始化能正常渲染一次,但只要search()跑完,loading也好、list也好,全都不会驱动视图更新。我当初排查了很久才发现,问题就出在最后这行解构——它把state这个代理对象里的一堆普通值拷了出来,导致后面search里改state.list,页面读的list却还是旧拷贝。
5.2 第一次重构:toRefs 保留整体响应性
修复思路非常直接:把返回值里的状态部分,改成由toRefs处理之后展开。
import { reactive, toRefs } from 'vue' export function useSearch() { const state = reactive({ keyword: '', page: 1, pageSize: 10, total: 0, loading: false, list: [] }) async function search() { /* 保持不变 */ } function reset() { /* 保持不变 */ } return { search, reset, ...toRefs(state) } }组件里就可以安全解构了:
const { keyword, page, pageSize, loading, list, total, search, reset } = useSearch() watch([keyword, page], () => { search() })注意这里keyword、page都是 ref,所以它们可以直接放进watch的数组里。loading、list在模板里也都是自动解包的,页面上完全不用写.value,只有 script 逻辑中操作它们时要留意。
这套重构之后,搜索模块的响应式链路就通了。改动其实就一处:返回值从{ state }变成了{ ...toRefs(state) },但体验差异巨大。现在组件里不再需要到处抱着state这个累赘,解构出来的每个状态都直接可读可监听。
5.3 第二次重构:toRef 接管外部参数
模块化往往会在这一步遇到新需求:useSearch需要接收外部传入的默认关键字。你希望父组件传一个 ref 进来,子模块既能读它的初始值,又能监听它后续变化。这时候就轮到toRef出场。
export function useSearch(externalKeywordRef) { const state = reactive({ keyword: externalKeywordRef.value || '', // ...其他状态 }) // 外部传入的响应式引用发生变化时,同步到内部状态 watch(externalKeywordRef, (val) => { state.keyword = val }) // ... }更常见的组合是把toRef用在父子组件 props 上。比如搜索框子组件接收父组件传的modelValue:
// SearchInput.vue const props = defineProps({ modelValue: String }) const emit = defineEmits(['update:modelValue']) // 拿到响应式引用,便于内部逻辑监听 const valueRef = toRef(props, 'modelValue') watch(valueRef, (val) => { // 每次父组件传入的 modelValue 变化时,做一些联动 console.log('外部值变为:', val) }) function onInput(e) { emit('update:modelValue', e.target.value) }这个场景里如果直接const value = props.modelValue,监听就失效了;但如果用toRefs(props)再解构,语法上没问题,却略显冗余。toRef(props, 'modelValue')在语义上更精准:我只需要这一个属性保持响应性,那恰好就是toRef的价值所在。
6. 容易踩的坑和我现在的使用习惯
6.1 几个文档边角里写着但不醒目的坑
这两个 API 表面上简单,实际使用中还是有几个隐蔽的坑,我逐个列出来,都是真实项目中触发过的。
坑一:对 ref 对象调用 toRefs 会得到空结果。ref实例和普通对象不同,它的value属性是定义在原型上的访问器,for...in遍历不到,所以toRefs(ref({ a: 1 }))的结果往往是空对象。如果你需要把 ref 内部的对象拆开用,应该先toRefs(refObj.value)。
坑二:toRef 创建的 ref,在模板中自动解包。这一点和普通ref一致,但很多人会误以为toRef返回的是一个"对象属性引用",在模板里写{{ user.name }}却拿到undefined。实际上user是 ref,模板里应该写{{ user.value.name }},除非你把toRef的结果直接包一层解包。Vue 3 的模板对顶层 ref 自动解包,嵌套在对象内的 ref 则不一定解包,这个要小心。
坑三:toRefs 不会转换对象中原本不是响应式的属性。比如你在一个 reactive 对象里塞入了普通对象,toRefs只会把这个普通对象整体变成一个 ref,它内部如果再变,是检测不到的。要深层次响应,你必须保证原始对象里的结构是用reactive或ref层层包裹过的。
坑四:用 toRef 给 props 赋值要克制。前面写过,props的事后赋值会绕过单向数据流,虽然不是技术上做不到,但代码 review 阶段一定会被红字标出。规范做法始终是emit告知父组件去改。
6.2 我实践下来的推荐写法
踩过这些坑之后,我现在写项目的习惯基本稳定成了一套规则:
第一,组合式函数对外返回状态时,默认就...toRefs(state),不再整体返回state。这样调用方可以自由选择解构粒度,也不会踩响应性丢失的雷。
第二,单属性的响应性传递,一律用toRef,不为省一行代码去给整个对象做无谓转换。比如某个组件只需要props.visible的响应性,那就toRef(props, 'visible')就够,不需要toRefs(props)然后把其它属性也拉出来。
第三,我会刻意区分"需要联动的引用"和"一次性快照"两种需求。外部传入一个值,后续要监听它的变化,就用toRef;只需要一个初始快照,用它初始化本地状态就行,这时候用ref(props.xxx)反而是正确的——因为我不希望本地修改回过头污染父组件的数据。
第四,如果是 Vue 3.3+ 的项目,可以关注一下toRefs在泛型推导上的增强,类型上更精确了,但用法没有变化。老项目的写法在过去完全通用。
最后再分享一个我在代码 review 中常用的快速判断法:看到有人const x = state.xxx然后把x用于watch、computed或模板绑定,我就会多问一句:"这个x后面需要跟着响应式联动吗?如果需要,请写成toRef(state, 'xxx');如果不需要,那你为什么要从一个响应式对象里取值?考虑是不是应该用ref独立声明。" 这一问在绝大多数情况下都能提前挡住响应性丢失的隐患。
这两个 API 其实没有多少魔法,核心就是"把属性访问桥接回原对象"这一件事。你只要记住这个原理,再遇到任何响应性断裂的问题,都能沿着这条线索自己推出来答案。