Vue3 中 ref 的原理
面试速答(30 秒版 TL;DR)
ref(x)会返回一个“响应式引用对象”,核心是一个带getter/setter的.value。- 读
ref.value会触发依赖收集(track),写ref.value = ...会触发依赖更新(trigger)。 ref的依赖不挂在target -> key的那套WeakMap结构上,而是 ref 自己维护一个 dep(可以理解为dep = Set<effect>)。ref包装对象时,默认会把对象再转成reactive(深层响应式),所以ref({a:1}).value.a++也是响应式的。- 模板里对
ref有“自动解包”(不用写.value);reactive里嵌套的ref在读取/赋值时也有“自动解包/回写”的特殊处理。
心智模型:ref 是“单个值的响应式容器”
把 ref 讲清楚,关键是对比两套依赖收集入口:
reactive(obj):依赖按 “对象 target + 属性 key” 维度收集(Proxy 的get/set里track(target, key))。ref(value):依赖按 “这个 ref 实例” 维度收集(.value的get/set里trackRefValue(this))。
你可以把 ref 想成:
type Ref<T> = { value: T };
只是 .value 的读写被 Vue 接管了。
关键流程图(简化版)
面试说人话版本:谁在渲染时读了 .value,谁就会在 .value 变化时被重新执行。
原理细化:RefImpl 的 .value 做了什么
下面是接近 Vue 3 响应式实现的“伪代码”,用来回答“ref 到底怎么收集依赖”的追问:
// 伪代码:只保留与 ref 原理强相关的结构
type Effect = () => void;
let activeEffect: Effect | null = null;
class RefImpl<T> {
private _value: T;
// 可以把 dep 理解为 Set<Effect>,Vue 内部会有更丰富的结构与位标记优化
public dep = new Set<Effect>();
constructor(v: T) {
this._value = v;
}
get value(): T {
if (activeEffect) this.dep.add(activeEffect);
return this._value;
}
set value(next: T) {
if (Object.is(next, this._value)) return;
this._value = next;
for (const e of this.dep) e();
}
}
真实 Vue 3 里(@vue/reactivity)会多做这些事(面试点到即可):
ref包装对象时会转成响应式:ref({})内部会做toReactive(value)(也就是走reactive)。- 触发更新不是立刻同步跑一遍,而是交给 scheduler 做 批处理(所以你经常需要
nextTick)。 - 会有位运算/脏标记等优化,避免重复收集与无效触发。
“自动解包”到底是怎么回事(高频追问)
1) 模板自动解包(template ref unwrapping)
在模板里:
<template>
<div>{{ count }}</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const count = ref(0);
</script>
可以不写 .value,原因是模板编译阶段会把 count 访问改写为对 .value 的访问(或等价的解包逻辑)。
2) reactive 里嵌套 ref 的解包与“回写”
import { reactive, ref } from 'vue';
const state = reactive({ count: ref(0) });
state.count; // 读到的是 0(解包后的值),不是 Ref 对象
state.count = 1; // 不是替换属性,而是写回到内部 ref.value
这能解释一个经典面试坑:
- 为什么
reactive({ count: ref(0) })里count看起来像普通 number? - 为什么给它赋值也能触发更新?
本质:Proxy 的 get/set 对 “值是 ref” 的情况做了特殊分支处理。
shallowRef / triggerRef / customRef(什么时候用)
shallowRef:只追踪 .value 本身,不做深层 reactive
import { shallowRef } from 'vue';
const obj = shallowRef({ a: 1 });
obj.value.a++; // 不会因为 a 变化而触发依赖(因为内部对象不是 reactive)
obj.value = { a: 2 }; // 会触发(因为 .value 变了)
适用场景:
- 大对象,内部频繁变更但你不希望深层追踪(性能/可控性)。
- 你希望用不可变数据风格:通过整体替换
.value来触发更新。
triggerRef:强制触发依赖
当你使用 shallowRef 并且内部对象发生了原地变更时,可以手动触发:
import { shallowRef, triggerRef } from 'vue';
const obj = shallowRef({ a: 1 });
obj.value.a++;
triggerRef(obj);
customRef:自定义 track/trigger(防抖输入这类)
核心思路:你自己决定什么时候收集依赖、什么时候触发更新。
典型题与标准答法
题 1:ref 为什么要 .value?
标准答法(简洁但有底层感):
- 因为
ref是“把一个值包装进对象里”,Vue 需要在读写时插入track/trigger。 - JavaScript 里无法拦截“变量本身的读写”,但能拦截“对象属性的读写”,所以用
.value这个属性作为响应式入口。
题 2:ref 包对象时,为什么 ref({}) 也能响应式?
ref内部会把对象转换成reactive(默认深层响应式),所以.value是个 Proxy。- 因此依赖可能来自两处:读
.value(ref 的 dep),读.value.xxx(reactive 的 dep)。
常见追问
ref和reactive怎么选?- 单值、基本类型、需要整体替换:优先
ref - 多字段对象、偏好
state.x:用reactive,需要解构时配合toRef/toRefs
- 单值、基本类型、需要整体替换:优先
ref的依赖存哪?reactive是WeakMap(target) -> Map(key) -> depref是ref.dep(一个 dep 容器)
.value的更新是同步的吗?- 触发依赖是同步发生的,但 effect 的执行通常会被 scheduler 批处理;需要读更新后的 DOM 用
nextTick
- 触发依赖是同步发生的,但 effect 的执行通常会被 scheduler 批处理;需要读更新后的 DOM 用
易错点 / 坑
- 把 ref 解构/传参时忘了
.value:在 JS/TS 逻辑里不会自动解包(模板才会)。 reactive内嵌ref的赋值行为是“写回.value”,这会让你误以为替换了属性,但其实没替换 Ref 对象本身。shallowRef内部对象原地改字段不会触发更新,除非你整体替换或triggerRef。
速记要点(可背)
ref= “值容器 +.valuegetter/setter + dep”- 读
.value:track;写.value:trigger ref(obj)默认把obj转reactive(深层)- 模板自动解包;JS 里要
.value