ref 与 reactive:两套 API 的内部差异、自动解包边界与选择准则
第6章:ref 与 reactive——两套 API 的内部差异、自动解包边界与选择准则
Vue 3 有两套响应式 API 共存,初学者的第一个疑问往往是"为什么不合并成一个?"。答案是:它们解决的是不同层次的问题。
reactive解决的是"如何追踪对象属性的变化",ref解决的是"如何让基础类型(数字、字符串)也能被追踪"。这两个问题的技术约束是不同的。
本章核心问题:ref 为什么需要 .value?自动解包在哪些场景下生效?如何在两者之间做出正确的技术选择?
读完本章你将理解:
ref()内部的RefImpl类与reactive()内部的 Proxy 的实现差异- 自动解包的三个生效场景和两个例外场景,以及每个场景的技术原因
toRefs()/toRef()的工作原理,以及何时必须使用- 一个明确的决策树:给定场景下该选
ref还是reactive
Level 1 · 你需要知道的(1-3年经验)
6.1 为什么需要两套 API?
JavaScript 中的值分为两类:
- 基础类型(primitives):number、string、boolean、null、undefined、Symbol、BigInt
- 引用类型(objects):普通对象、数组、Map、Set、函数等
这两类类型在 JavaScript 中的行为根本不同:
// 引用类型:传递的是引用,可以追踪变化
const obj = { count: 0 };
const anotherRef = obj; // anotherRef 指向同一个对象
obj.count = 1;
console.log(anotherRef.count); // 1,因为是同一个对象
// 基础类型:传递的是值的拷贝,无法追踪
let num = 0;
let copy = num; // copy 是 0 的独立拷贝
num = 1;
console.log(copy); // 0,num 的变化不影响 copy
Proxy 只能代理对象,因为 Proxy 的第一个参数必须是对象:
const proxy = new Proxy(0, {}); // TypeError: Cannot create proxy with a non-object as target
这就是 ref 存在的根本原因:把基础类型包装成对象,然后用 getter/setter 追踪对这个包装对象的访问。
// ref 的核心思想
const count = ref(0);
// 等价于创建一个特殊对象:
// { value: 0 } ← 但这个对象的 .value 是带有 get/set 拦截的
count.value; // 触发 get,调用 track
count.value = 1; // 触发 set,调用 trigger
6.2 ref() 的内部实现:RefImpl
ref() 内部创建了一个 RefImpl 对象:
class RefImpl<T> {
private _value: T; // 实际存储的值
private _rawValue: T; // 原始值(用于比较,避免响应式包装干扰)
public dep?: Dep; // 依赖的 dep Set(直接存在实例上,不用三层 Map)
public readonly __v_isRef = true; // 标记这是一个 ref
constructor(value: T, public readonly __v_isShallow: boolean) {
this._rawValue = toRaw(value);
// 如果是对象,转换为 reactive;如果是基础类型,直接存储
this._value = __v_isShallow ? value : toReactive(value);
}
get value() {
trackRefValue(this); // 追踪依赖
return this._value;
}
set value(newVal) {
const useDirectValue = this.__v_isShallow || isShallow(newVal) || isReadonly(newVal);
newVal = useDirectValue ? newVal : toRaw(newVal);
if (hasChanged(newVal, this._rawValue)) {
this._rawValue = newVal;
this._value = useDirectValue ? newVal : toReactive(newVal);
triggerRefValue(this, newVal); // 触发更新
}
}
}
注意 ref 的 dep 直接存储在 RefImpl 实例上(this.dep),而不是存在全局的 targetMap 中。这是因为 ref 只有一个"key"(就是 .value),不需要额外的 Map 层。
6.3 reactive() 的内部实现:Proxy
// reactive 核心:返回 Proxy
export function reactive<T extends object>(target: T): UnwrapNestedRefs<T> {
// 如果已经是 readonly 对象,直接返回
if (isReadonly(target)) {
return target as any;
}
return createReactiveObject(
target,
false,
mutableHandlers, // 普通对象
mutableCollectionHandlers, // Map/Set 等
reactiveMap // 缓存
);
}
reactive 与 ref 的本质区别:
| 特性 | ref |
reactive |
|---|---|---|
| 实现方式 | RefImpl 类(getter/setter) | Proxy |
| 适用类型 | 基础类型 + 对象 | 只能是对象 |
| 访问方式 | .value(基础类型)、.value.prop(对象) |
直接 .prop |
| 模板中 | 自动解包,不需要 .value |
直接使用 |
| 解构 | 安全(ref 本身是对象,解构出来的是 ref) | 不安全(解构出基础类型值,丢失响应性) |
6.4 为什么 ref 需要 .value?
// 假设 JavaScript 允许我们做这样的事(实际上不允许)
let count = 0;
// 对 count 变量的赋值触发响应式更新
// 问题:JavaScript 的变量赋值是语言级别的操作
// 没有任何 API 可以拦截 "count = 1" 这个操作
// 因为赋值操作的对象是变量(词法绑定),不是对象
ref 的 .value 设计是一个精巧的语言约束下的工程解决方案:
// JavaScript 的约束:
// 不能拦截:count = 1 ← 普通赋值
// 可以拦截:count.value = 1 ← 对象属性赋值,可以用 getter/setter
const count = ref(0);
// count 本身是一个对象(RefImpl),它不会变
// count.value 是它的一个属性访问,这个可以被 getter/setter 拦截
count.value++; // 等价于 count.value = count.value + 1
// 先触发 get(track),再触发 set(trigger)
这也解释了为什么 ref 对象本身是 const(你不应该重新赋值 count = ref(1)),而应该修改 count.value。
6.5 自动解包的三个生效场景
场景一:模板中的顶层 ref 自动解包
<script setup>
import { ref } from 'vue';
const count = ref(0);
const user = ref({ name: 'Vue' });
</script>
<template>
<!-- 自动解包:不需要 count.value -->
<p>{{ count }}</p> <!-- 等价于 {{ count.value }} -->
<p>{{ user.name }}</p> <!-- 等价于 {{ user.value.name }} -->
<!-- 注意:只有顶层的 ref 才自动解包 -->
<!-- 如果 count 是某个对象的属性,需要访问方式不同 -->
</template>
场景二:reactive 对象内嵌套的 ref 自动解包
import { ref, reactive } from 'vue';
const count = ref(0);
const state = reactive({ count }); // 把 ref 嵌套进 reactive 对象
// state.count 自动解包:不需要 .value
console.log(state.count); // 0(不是 RefImpl,是直接的值)
state.count = 1; // 自动更新 count.value(等价于 count.value = 1)
console.log(count.value); // 1(同步更新!)
场景三:watchEffect/watch 回调中访问 ref
// watchEffect 中的 ref 需要 .value
watchEffect(() => {
console.log(count.value); // 需要手动 .value
});
// 但是用作 watch 的 source 时,可以直接传 ref
watch(count, (newVal) => {
console.log(newVal); // newVal 是已经解包的值
});
6.6 自动解包的边界陷阱
例外一:数组内的 ref 不自动解包
import { ref, reactive } from 'vue';
const count = ref(0);
const arr = reactive([count]); // 数组内的 ref
// 数组内不自动解包:
console.log(arr[0]); // RefImpl,不是 0
console.log(arr[0].value); // 0(需要手动 .value)
// 对比 reactive 对象:
const obj = reactive({ count });
console.log(obj.count); // 0(自动解包)
例外二:Map 内的 ref 不自动解包
import { ref, reactive } from 'vue';
const count = ref(0);
const map = reactive(new Map([['count', count]]));
// Map 内不自动解包:
console.log(map.get('count')); // RefImpl,不是 0
console.log(map.get('count').value); // 0(需要手动 .value)
这两个例外的设计原因是性能和一致性:在数组和 Map 这类数据结构中,自动解包会导致运行时无法区分"存了一个 ref"和"存了一个普通值",破坏了数据结构的语义。
6.7 toRefs() 和 toRef() 的作用
toRefs():把 reactive 对象的每个属性转为 ref
import { reactive, toRefs } from 'vue';
const state = reactive({ count: 0, name: 'Vue' });
// 解构 reactive 对象:丢失响应性
const { count } = state; // count 是普通数字 0,不响应
// 使用 toRefs 再解构:保持响应性
const { count, name } = toRefs(state);
// count 是 ref,count.value === state.count
// name 是 ref,name.value === state.name
// 双向绑定:修改任意一边都会同步
count.value = 99;
console.log(state.count); // 99
state.count = 100;
console.log(count.value); // 100
toRef():把 reactive 对象的单个属性转为 ref
import { reactive, toRef } from 'vue';
const state = reactive({ count: 0 });
const countRef = toRef(state, 'count');
// countRef 是一个 ref,但链接到 state.count
countRef.value = 99;
console.log(state.count); // 99
toRef 的一个重要用途是把可选属性转为 ref,即使属性不存在:
const state = reactive({});
const undeclaredRef = toRef(state, 'undeclared');
// undeclaredRef.value === undefined(不会报错)
// 这在处理组件 props 时非常有用
6.8 选择决策树
选 ref 还是 reactive?
┌─────────────────────────────────────────────────────┐
│ 是基础类型(number/string/boolean)? │
└─────────────────────────────────────────────────────┘
│ 是 │ 否
▼ ▼
ref() 需要解构吗?
│ 是 │ 否
▼ ▼
ref() + toRefs() 可以用 reactive()
或直接用 ref() 但也可以用 ref()
(多个 ref 代替对象)
特殊情况:
├── 需要传递响应式数据给函数/composable?→ 用 ref(可以直接传 ref 对象)
├── 从服务器获取 JSON 数据?→ 用 ref(因为初始值是 null,然后赋值整个对象)
├── 需要替换整个对象?→ 用 ref(reactive 不能整体替换)
└── 大型固定结构的状态(store 级别)?→ 可以用 reactive
Level 2 · 它是怎么运行的(3-5年经验)
6.9 RefImpl vs Proxy 的性能差异
ref 和 reactive 在性能上有几个关键差异:
追踪开销:
ref 的追踪路径(简短):
count.value 读取
→ RefImpl.get()
→ trackRefValue(this)
→ track(this, 'value')
→ dep.add(activeEffect) // dep 直接存在 RefImpl 上
reactive 的追踪路径(稍长):
state.count 读取
→ Proxy get trap
→ Reflect.get(target, 'count', receiver)
→ track(target, 'count')
→ targetMap.get(target).get('count').add(activeEffect) // 三层查找
ref 的追踪路径更短,因为 dep 直接存在 RefImpl 实例上,不需要全局的三层 Map 查找。但这个差异通常在微基准测试中才可见,实际应用中两者性能差异可忽略。
内存占用:
ref(0)创建一个 RefImpl 对象(约 5 个属性)reactive({ count: 0 })创建一个 Proxy 对象 + WeakMap 条目
对于单个原始值,ref 稍微轻量;对于有多个属性的对象,reactive 更高效(一个 Proxy 追踪多个属性,而多个 ref 需要多个 RefImpl 对象)。
6.10 reactive 的整体替换问题
import { reactive, ref } from 'vue';
// 问题:reactive 对象不能整体替换
let state = reactive({ count: 0 });
// 错误:这样做断开了响应性
state = reactive({ count: 1 }); // state 现在指向一个全新的 reactive 对象
// 但之前持有 state 引用的地方(模板/其他变量)依然指向旧的 reactive 对象
// 视图不会更新!
// 正确方案1:修改属性,不替换整体
Object.assign(state, { count: 1 });
// 正确方案2:用 ref 包装 reactive 对象
const state = ref({ count: 0 });
state.value = { count: 1 }; // 可以!ref 的 setter 会处理新值的响应式化
reactive 整体替换的问题:
const state = reactive({ count: 0 });
组件模板引用的是这个 Proxy 对象:
template → state (Proxy A)
state = reactive({ count: 1 });
state 变量现在指向新的 Proxy B,但:
template 仍然持有对 Proxy A 的引用 → 视图不更新!
对比 ref:
const state = ref({ count: 0 });
template → state.value (Proxy A for inner object)
state.value = { count: 1 };
RefImpl 的 setter:
1. 新对象经过 reactive() 变为 Proxy B
2. state._value = Proxy B
3. triggerRefValue(state) → 通知所有依赖了 state.value 的 effect
4. 模板重新访问 state.value → 得到 Proxy B → 视图更新!
6.11 toRefs 的内部实现
// packages/reactivity/src/ref.ts
export function toRefs<T extends object>(object: T): ToRefs<T> {
if (__DEV__ && !isProxy(object)) {
console.warn(`toRefs() expects a reactive object but received a plain one.`)
}
const ret: any = isArray(object) ? new Array((object as any).length) : {}
for (const key in object) {
ret[key] = toRef(object, key)
}
return ret
}
export function toRef<T extends object, K extends keyof T>(
object: T,
key: K,
defaultValue?: T[K]
): ToRef<T[K]> {
const val = object[key]
return isRef(val)
? val // 如果已经是 ref,直接返回
: (new ObjectRefImpl(object, key, defaultValue) as any)
}
class ObjectRefImpl<T extends object, K extends keyof T> {
public readonly __v_isRef = true
constructor(
private readonly _object: T,
private readonly _key: K,
private readonly _defaultValue?: T[K]
) {}
get value() {
const val = this._object[this._key]
// 通过访问原始 reactive 对象的属性来触发追踪
return val === undefined ? (this._defaultValue as T[K]) : val
}
set value(newVal) {
// 通过修改原始 reactive 对象的属性来触发更新
this._object[this._key] = newVal
}
}
ObjectRefImpl 的精妙之处:它的 get value() 内部访问了 this._object[this._key],这是一次 Proxy 属性访问,会触发响应式追踪。所以通过 toRef 创建的 ref 的响应性来自原始的 reactive 对象,而不是独立存在。
6.12 自动解包的完整实现逻辑
模板中的自动解包发生在两个地方:
编译时处理(模板中的顶层 ref):
// 模板编译产物(简化)
// <template>{{ count }}</template>
// 其中 count 是 <script setup> 中的 ref
// 编译后:
function render(ctx) {
return createVNode('span', null, _toDisplayString(ctx.count))
// _toDisplayString 会调用 unref(value),自动解包
}
运行时处理(reactive 对象中嵌套的 ref):
在 Proxy 的 get trap 中:
// packages/reactivity/src/baseHandlers.ts
function createGetter() {
return function get(target, key, receiver) {
const res = Reflect.get(target, key, receiver)
// ...(其他处理)
if (isRef(res)) {
// 注意:数组中的 ref 不解包(isIntegerKey(key) 为 true 时)
const shouldUnwrap = !targetIsArray || !isIntegerKey(key)
return shouldUnwrap ? res.value : res
// ↑ 自动解包 ↑ 不解包(数组/Map 场景)
}
// ...
}
}
Level 3 · 设计文档与源码(资深开发者)
6.13 shallowRef 和 shallowReactive 的差异
Vue 3 提供了两个"浅层"变体,理解它们对于性能优化非常重要:
// shallowRef:只追踪 .value 的替换,不追踪内部对象的变化
const state = shallowRef({ count: 0 });
watchEffect(() => {
console.log(state.value.count);
});
state.value.count = 1; // 不触发!shallowRef 内部对象的变化不追踪
state.value = { count: 1 }; // 触发!整体替换 .value
// shallowReactive:只追踪第一层属性,不追踪嵌套对象
const state = shallowReactive({ nested: { count: 0 } });
watchEffect(() => {
console.log(state.nested.count);
});
state.nested.count = 1; // 不触发!nested 内部变化不追踪
state.nested = { count: 1 }; // 触发!第一层属性变化追踪
适用场景:
shallowRef:存储大型的不可变数据结构(如从服务器获取的完整列表),只需要追踪整体替换shallowReactive:状态对象的某些嵌套属性频繁变化但不需要响应式(避免深层代理开销)
6.14 ref 的类型系统设计
Vue 3 的 ref 有非常精密的 TypeScript 类型推断:
// Ref<T> 类型(简化)
export interface Ref<T = any> {
value: T
[RefSymbol]: true
}
// ref() 的类型重载
export function ref<T extends Ref>(value: T): T // 已是 ref,直接返回
export function ref<T>(value: T): Ref<UnwrapRef<T>> // 包装类型
export function ref<T = any>(): Ref<T | undefined> // 无初始值
// UnwrapRef<T>:递归解包嵌套 ref
type UnwrapRef<T> =
T extends ShallowRef<infer V> ? V
: T extends Ref<infer V> ? UnwrapRefSimple<V>
: UnwrapRefSimple<T>
这套类型系统确保了:
const count = ref(0);
// count: Ref<number>
// count.value: number
const nested = ref({ inner: ref(1) });
// nested: Ref<{ inner: number }> // 注意:inner 被自动解包为 number,不是 Ref<number>
// nested.value.inner: number(不需要 .value)
// 这对应了 reactive 中 ref 自动解包的运行时行为
const state = reactive({ count: ref(0) });
// state: { count: number }(TypeScript 知道会自动解包)
// state.count: number
6.15 customRef 的高级用法
customRef 允许你完全控制 ref 的追踪和触发逻辑:
import { customRef } from 'vue';
// 防抖 ref:修改后等待 delay 毫秒再触发更新
function useDebouncedRef<T>(value: T, delay = 200) {
let timeout: ReturnType<typeof setTimeout>;
return customRef((track, trigger) => {
return {
get() {
track(); // 手动调用 track,建立依赖
return value;
},
set(newValue: T) {
clearTimeout(timeout);
timeout = setTimeout(() => {
value = newValue;
trigger(); // 手动调用 trigger,触发更新
}, delay);
}
};
});
}
// 使用
const text = useDebouncedRef('', 300);
// <input v-model="text" /> 在用户停止输入 300ms 后才触发响应式更新
customRef 的 track 和 trigger 参数正是 Vue 内部 trackRefValue 和 triggerRefValue 的封装,给了开发者在自定义响应式逻辑时的完整控制权。
Level 4 · 边界与陷阱(全体适用)
陷阱1:模板中嵌套 ref 不自动解包
<script setup>
import { ref } from 'vue';
const obj = ref({ count: ref(0) }); // 嵌套 ref
</script>
<template>
<!-- 错误理解:以为会自动解包到底层 -->
<p>{{ obj.count }}</p> <!-- 实际显示的是 RefImpl 对象,不是数字! -->
<!-- 正确:需要手动 .value -->
<p>{{ obj.value.count.value }}</p> <!-- 0 -->
<!-- 或者用 reactive 包装,让 ref 在 reactive 对象中自动解包 -->
</template>
根本原因:模板中只有顶层 <script setup> 绑定的 ref 才自动解包。通过访问链访问到的 ref(obj.count 中的 count 是 obj.value(一个普通对象,不是 reactive)的属性,不在 reactive 的自动解包逻辑中)不会自动解包。
陷阱2:reactive 数组解构
import { reactive, watchEffect } from 'vue';
const list = reactive([1, 2, 3]);
// 错误:解构数组同样丢失响应性
const [first, second] = list; // first=1, second=2,是普通数字
watchEffect(() => {
console.log(first); // 只打印初始值 1,不响应变化
});
list[0] = 99; // first 不更新
// 正确做法:直接用 list[0],或者用 ref 数组
const list = ref([1, 2, 3]);
// list.value[0] 可以追踪
实际场景:在 v-for 中解构 reactive 数组中的元素是一个常见陷阱:
<template>
<!-- 正确:item 是 reactive 数组的元素,通过索引访问,可以响应变化 -->
<div v-for="item in list" :key="item.id">{{ item.name }}</div>
<!-- 如果 list 是 reactive 数组,item 是其元素的快照,
如果元素是基础类型,则不响应;如果是对象,则可以响应(因为 reactive 的懒代理) -->
</template>
陷阱3:ref 的 isRef 检测
import { ref, isRef } from 'vue';
const count = ref(0);
console.log(isRef(count)); // true
// 常见误用:用 typeof 或 instanceof 检测
console.log(typeof count); // 'object'(不区分 ref 和普通对象)
console.log(count instanceof RefImpl); // 报错!RefImpl 不对外导出
// 正确:用 isRef()
function processValue(val) {
const value = isRef(val) ? val.value : val;
// ...
}
// 或者用 unref()(更简洁)
function processValue(val) {
const value = unref(val); // 如果是 ref,返回 .value;否则直接返回
// ...
}
陷阱4:reactive 嵌套 ref 时的赋值行为
import { ref, reactive } from 'vue';
const count = ref(0);
const state = reactive({ count }); // 嵌套 ref
// 通过 reactive 对象赋值:实际修改的是 ref.value
state.count = 99; // 等价于 count.value = 99!
console.log(count.value); // 99
// 通过 reactive 对象替换 ref 本身(注意区别!)
state.count = ref(100); // 这会替换 state.count 为新的 ref
// 原来的 count ref 不再与 state.count 关联!
console.log(state.count); // 100(自动解包)
console.log(count.value); // 99(原来的 ref 没有变)
// 验证:
state.count = 200;
console.log(count.value); // 仍然是 99,state.count 已经是新 ref 了
根本原因:reactive 的 set trap 中有专门的处理逻辑:当旧值是 ref、新值不是 ref 时,把新值赋给旧 ref 的 .value(保持 ref 的关联);当新值是 ref 时,替换 ref 本身(断开旧 ref 的关联)。这个行为在 vue 源码中的 createSetter 中实现。
本章小结
-
ref存在的根本原因是 JavaScript 的类型系统约束:Proxy 不能代理基础类型,必须把数字/字符串等包装成对象(RefImpl),通过 getter/setter 拦截.value的访问和赋值来实现响应性。.value不是语法噪音,是这个约束下的必要设计。 -
自动解包有精确的边界:模板顶层 ref 自动解包(编译器处理)、reactive 对象内的 ref 自动解包(Proxy get trap 处理)、watch source 自动解包;但数组/Map 内的 ref 不自动解包(保证数据结构语义完整性)。混淆这些边界是 ref 相关 bug 的主要来源。
-
toRefs()/toRef()的本质是创建链接而非复制:ObjectRefImpl的 getter 内部访问的是原始 reactive 对象的属性,setter 修改的也是原始 reactive 对象。通过toRefs解构出的 ref 与原始对象保持双向同步,不是独立的值拷贝。 -
reactive不支持整体替换,ref支持:这是两者在实际应用中最重要的行为差异。从服务器加载数据(需要从 null 替换成对象)、分页数据切换(整页替换列表)等场景,ref是唯一正确选择。 -
选择准则的核心原则:需要解构的用
ref(解构后是 ref 对象,保持响应性)或reactive + toRefs;需要整体替换的用ref;基础类型必须用ref;两者都能用时,优先考虑代码的可读性和一致性,社区倾向于使用ref(因为行为更可预测)。