跳到主要内容

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 本体”,无需 .valuewatch(reactiveObj) 默认深度。

Q2:如果我就是想用 ref 包对象,怎么规避坑?

  • 访问统一:在脚本里坚持 state.value.xxx,不要混用 state.xxx
  • 监听明确:监听字段用 watch(() => state.value.xxx, ...);需要深度就显式 deep: true
  • 解构慎用:需要解构就改用 reactive + toRefs