第 6 章

ref 与 reactive:两套 API 的内部差异、自动解包边界与选择准则

第6章:ref 与 reactive——两套 API 的内部差异、自动解包边界与选择准则

Vue 3 有两套响应式 API 共存,初学者的第一个疑问往往是"为什么不合并成一个?"。答案是:它们解决的是不同层次的问题。reactive 解决的是"如何追踪对象属性的变化",ref 解决的是"如何让基础类型(数字、字符串)也能被追踪"。这两个问题的技术约束是不同的。

本章核心问题ref 为什么需要 .value?自动解包在哪些场景下生效?如何在两者之间做出正确的技术选择?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

6.1 为什么需要两套 API?

JavaScript 中的值分为两类:

这两类类型在 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               // 缓存
  );
}

reactiveref 的本质区别:

特性 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 的性能差异

refreactive 在性能上有几个关键差异:

追踪开销

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 稍微轻量;对于有多个属性的对象,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 shallowRefshallowReactive 的差异

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 }; // 触发!第一层属性变化追踪

适用场景

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 后才触发响应式更新

customReftracktrigger 参数正是 Vue 内部 trackRefValuetriggerRefValue 的封装,给了开发者在自定义响应式逻辑时的完整控制权。


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 中的 countobj.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 中实现。


本章小结

  1. ref 存在的根本原因是 JavaScript 的类型系统约束:Proxy 不能代理基础类型,必须把数字/字符串等包装成对象(RefImpl),通过 getter/setter 拦截 .value 的访问和赋值来实现响应性。.value 不是语法噪音,是这个约束下的必要设计。

  2. 自动解包有精确的边界:模板顶层 ref 自动解包(编译器处理)、reactive 对象内的 ref 自动解包(Proxy get trap 处理)、watch source 自动解包;但数组/Map 内的 ref 不自动解包(保证数据结构语义完整性)。混淆这些边界是 ref 相关 bug 的主要来源。

  3. toRefs()/toRef() 的本质是创建链接而非复制ObjectRefImpl 的 getter 内部访问的是原始 reactive 对象的属性,setter 修改的也是原始 reactive 对象。通过 toRefs 解构出的 ref 与原始对象保持双向同步,不是独立的值拷贝。

  4. reactive 不支持整体替换,ref 支持:这是两者在实际应用中最重要的行为差异。从服务器加载数据(需要从 null 替换成对象)、分页数据切换(整页替换列表)等场景,ref 是唯一正确选择。

  5. 选择准则的核心原则:需要解构的用 ref(解构后是 ref 对象,保持响应性)或 reactive + toRefs;需要整体替换的用 ref;基础类型必须用 ref;两者都能用时,优先考虑代码的可读性和一致性,社区倾向于使用 ref(因为行为更可预测)。

本章评分
4.7  / 5  (59 评分)

💬 留言讨论