Vue3 中 ref 包装对象:怎么实现、现象是什么、为什么不建议用 ref?
面试速答(30 秒版 TL;DR)
ref(obj)不是“ref 也能代理对象”,而是:ref 内部会把对象转成reactive的 Proxy,放到.value里。- 现象:
- 在
script里访问要走.value(如state.value.count),但在模板里会 自动解包,看起来像state.count。 - 改
state.value.count++会更新视图(因为.value是 reactive Proxy)。 watch(state, cb)默认 不会 因为.value.count这种“深层属性变化”触发(除非deep: true或你显式监听到具体字段)。
- 在
- 不建议用
ref包对象的核心原因:心智模型混乱 +.value额外一层 + watch/解构等行为更容易踩坑。对象状态优先reactive。
心智模型:ref 外壳 + reactive 内核(两层)
一句话:ref 负责“这一格 value 变没变”,reactive 负责“对象里面哪个字段变了”。
里面怎么实现的(机制层面)
Vue 3 的 ref 大致可以理解成下面的伪代码(忽略边角细节):
function ref(value: unknown) {
// 如果是对象:会转成 reactive Proxy
const reactiveValue = isObject(value) ? reactive(value) : value;
return {
get value() {
// track:依赖 ref.value 的副作用
trackRefValue();
return reactiveValue;
},
set value(newValue) {
// compare + trigger:当整个 value 被替换时触发
if (hasChanged(newValue, value)) {
value = newValue;
reactiveValue = isObject(newValue) ? reactive(newValue) : newValue;
triggerRefValue();
}
},
};
}
关键点:
ref遇到对象会走一层toReactive,最终拿到reactive(obj)生成的 Proxy。- 深层字段变化(例如
state.value.count++)触发的是 reactive Proxy 的trigger。 - 整体替换(例如
state.value = { count: 1 })触发的是 ref 自身的triggerRefValue。
现象是什么(你在业务里会看到什么)
1) 模板里“像 reactive”,脚本里“必须 .value”
import { ref } from 'vue';
const state = ref({ count: 0 });
state.value.count++; // OK
// state.count++; // script 里不行(除非你手动 proxyRefs/unref)
模板里:
<template>
<!-- 模板会自动解包 ref,所以看起来像 reactive -->
<button @click="state.count++">{{ state.count }}</button>
</template>
这也是“为什么很多人觉得 ref 在代理对象”:模板自动解包把 .value 隐藏了。
2) watch 的坑:默认不跟踪对象内部属性
import { ref, watch } from 'vue';
const state = ref({ count: 0 });
watch(state, () => {
console.log('changed');
});
state.value.count++; // 很多人以为这里会触发,但默认不会
正确写法(按你想要的监听粒度选一种):
watch(
() => state.value.count,
() => console.log('count changed')
);
或:
watch(state, () => console.log('deep changed'), { deep: true });
对比:如果你用的是 reactive,那么 watch(state, cb) 会默认深度监听(因为它拿到的就是 reactive 对象)。
3) 解构依然会丢响应式(本质不变)
const state = ref({ count: 0 });
const { count } = state.value; // count 变成普通 number
如果你想“解构后仍保持响应式”,应该围绕 reactive + toRefs 设计数据结构,而不是靠 ref(obj) 侥幸。
为什么不建议用 ref 包对象(可直接背诵)
- 可读性差:对象状态却要写
xxx.value.a,团队读代码更费劲。 - 行为更容易误判:模板自动解包导致“脚本要
.value、模板不要”的不一致;watch(refObj)默认不随深层字段变动触发也很反直觉。 - 组合式函数返回值更难用:你如果返回
ref(obj),调用方要么全程.value,要么到处unref,API 体验差;而reactive + toRefs的返回值更稳定。 - 类型心智更复杂:TS 下对象会变成
Ref<{...}>,一层.value贯穿;对象本身的字段类型推导也更绕。
结论:对象状态优先 reactive,基本类型优先 ref。
什么时候 ref 包对象反而合适(边界条件)
下面这些场景用 ref(obj) 是合理的,但要带着“它里面是 reactive”的意识:
- 需要“整体替换语义”:例如
const user = ref<User | null>(null),请求回来直接user.value = data。 - 你要放的是“外部可变对象/大对象”,不希望深层都变响应式:用
shallowRef,需要触发更新时triggerRef。
import { shallowRef, triggerRef } from 'vue';
const chart = shallowRef<any>(null);
function mutateChartInPlace() {
chart.value?.setOption({}); // 内部改了,但 shallowRef 不会追踪深层
triggerRef(chart); // 手动通知依赖刷新
}
常见追问
Q1:那 ref(obj) 和 reactive(obj) 最大区别是什么?
ref(obj):你拿到的是 “Ref 外壳”,对象在.value里;模板可能帮你解包。reactive(obj):你拿到的是 “Proxy 本体”,无需.value;watch(reactiveObj)默认深度。
Q2:如果我就是想用 ref 包对象,怎么规避坑?
- 访问统一:在脚本里坚持
state.value.xxx,不要混用state.xxx。 - 监听明确:监听字段用
watch(() => state.value.xxx, ...);需要深度就显式deep: true。 - 解构慎用:需要解构就改用
reactive + toRefs。