Vue 3 中 Ref 实现原理解析
在 Vue 3 中,ref 是组合式 API(Composition API)的核心。很多开发者虽然会用,但对其内部运作机制、ref 与 reactive 的关系、以及为什么在scrit中我们访问 ref 数据需要用 .value, 但是在模板里不需要 .value 往往一知半解。
本文将剥离复杂的边界情况,用最精简的代码还原 Vue 3 源码的核心逻辑,带你彻底搞懂这三个问题:
ref是如何实现的?toRefs是如何解决解构丢失响应性问题的?- 为什么在模板中不需要
.value?
1. Ref 的原理解析
为什么需要 Ref?
在先前的部分中,我们对响应式数据的原理进行了介绍,我们通过 reactive 函数处理一个对象来使其转变为响应式数据,而对于 JavaScript 中的原始类型(String, Number, Boolean, ...)是值传递 的。如果你把一个数字传给一个函数,函数无法追踪这个数字的变化。为了让原始值变成"响应式",我们需要把它包裹在一个对象中(Wrapper Pattern),利用对象的 getter 和 setter 来拦截访问和修改。
核心实现:RefImpl 类
Vue 3 内部通过 RefImpl 类来实现 ref。
typescript
// 伪代码:简化版的 RefImpl
class RefImpl {
private _value: any;
private _rawValue: any;
public dep: Dep; // 依赖容器
public __v_isRef = true; // 标记这是一个 ref 对象
constructor(value) {
this._rawValue = value;
// 如果传入的是对象,则通过 reactive 转换,否则保持原值
this._value = isObject(value) ? reactive(value) : value;
this.dep = new Set(); // 假设这是依赖收集容器
}
get value() {
// 1. 依赖收集 (Track)
trackEffects(this.dep);
return this._value;
}
set value(newVal) {
// 只有值发生改变时才触发
if (hasChanged(newVal, this._rawValue)) {
this._rawValue = newVal;
// 如果新值是对象,同样需要转换
this._value = isObject(newVal) ? reactive(newVal) : newVal;
// 2. 派发更新 (Trigger)
triggerEffects(this.dep);
}
}
}
// 暴露出来的 ref 函数
function ref(value) {
return new RefImpl(value);
}
关键点解析
ref本质上会返回一个类的实例对象,这个对象拥有.value的访问器属性。- __v_isRef :RefImpl 类需要增加一个
__v_isRef属性用于区别 "Ref对象"与"含有 value 属性的普通对象"。ref的本质是一个拥有.value属性的对象,但并不是所有拥有.value的对象都是ref。如果不增加这个标识位,很难区分下面二者的区别:
js
// 真正的 ref
const realRef = ref(1);
// realRef 结构: { value: 1, dep: Set, __v_isRef: true, ... }
// 用户不小心定义的普通对象
const fakeRef = { value: 1 };
// fakeRef 结构: { value: 1 }
当 Vue 的模板系统或者 reactive 尝试"自动解包"(读取 .value)时,如果没有 __v_isRef,系统可能会错误地把用户定义的 fakeRef 也当作响应式对象处理,去尝试读取它的依赖(dep),这会导致报错或逻辑混乱。
- Getter/Setter :
get value():当访问.value时,调用track收集当前副作用函数(Effect)。set value():当修改.value时,比较新旧值,若变化则调用trigger通知视图更新。
- 兼容对象参数 :如果
ref(obj)接收的是一个对象,源码中会调用reactive(obj)将其转化为深层响应式对象。这就是为什么ref可以包裹对象,且对象内部属性变化也能触发更新。
2. toRefs 的原理解析
为什么需要 toRefs?
当我们对一个 reactive 对象进行解构时,会丢失响应性,因为解构出来的只是普通的变量。
javascript
const state = reactive({ count: 1 });
const { count } = state; // count 此时只是一个普通数字 1,与 state 断开联系了
toRefs 的作用就是把 reactive 对象的每一个属性都转换成一个 ref,但这个 ref 比较特殊,它链接到了源对象。
核心实现:ObjectRefImpl 类
toRefs 内部并不是创建标准的 RefImpl,而是创建了 ObjectRefImpl。它不存储值,只是作为源对象属性的"代理"。
typescript
class ObjectRefImpl {
public __v_isRef = true; // 标记为 ref
constructor(
private readonly _object, // 源 reactive 对象
private readonly _key // 指定的 key
) {}
get value() {
// 访问时,直接读取源对象的属性
// 因为 _object 是响应式的,所以这里会自动触发源对象的依赖收集
return this._object[this._key];
}
set value(newVal) {
// 修改时,直接修改源对象的属性
// 这里会自动触发源对象的更新派发
this._object[this._key] = newVal;
}
}
// toRef 函数:针对单个属性
function toRef(object, key) {
return new ObjectRefImpl(object, key);
}
// toRefs 函数:遍历对象所有属性
function toRefs(object) {
const ret = Array.isArray(object) ? new Array(object.length) : {};
for (const key in object) {
// 为每个属性创建一个 ObjectRefImpl
ret[key] = toRef(object, key);
}
return ret;
}
关键点解析
ObjectRefImpl自身没有任何track或trigger的逻辑。它只是把操作转发给了源reactive对象。当我们获取到toRef函数返回的对象时,我们对其 .value 属性的读写实际上会转发到对this._object[this._key]的读写,自然就会触发其track或trigger的逻辑。toRefs返回的是一个普通对象,里面的值全是ref。这个普通对象可以被解构,解构出来的变量依然是ObjectRefImpl实例,依然保持着对源对象的引用。
3. 模板自动解包 (Unwrapping) 原理解析
现象
在 setup 中我们需要用 count.value,但在 <template> 中我们直接写 {{ count }} 即可。这是 Vue 在编译和渲染阶段做了特殊处理。
核心实现:proxyRefs
首先需要介绍两个辅助函数:
js
// 如果是 ref 返回 .value,否则返回原值
function unref(ref) {
return isRef(ref) ? ref.value : ref;
}
// 根据对象 __v_isRef 属性判断其是否是 ref 对象
export function isRef(r: any): r is Ref {
return !!(r && r.__v_isRef === true);
}
unref 函数首先判断传入的是否是 ref 对象,如果是则返回 ref.value, 否则返回 ref 本身,这个函数正是模板自动解包原理的核心。
Vue 在完成对模板的解析之后,将 setup 的返回值传递给渲染函数之前,会通过 proxyRefs 函数对其进行一层代理,在代理中拦截了 get 和 set 操作,并通过 unref 函数
typescript
const shallowUnwrapHandlers = {
get: (target, key, receiver) => {
// 1. 获取真实的值
const value = Reflect.get(target, key, receiver);
// 2. 自动解包:如果是 ref 就返回 value.value,否则直接返回
return unref(value);
},
set: (target, key, value, receiver) => {
const oldValue = target[key];
// 3. 特殊处理:如果旧值是 ref,但新值不是 ref
// 意味着用户想给 ref 赋值:count.value = 1
if (isRef(oldValue) && !isRef(value)) {
oldValue.value = value;
return true;
}
// 其他情况直接替换
return Reflect.set(target, key, value, receiver);
}
};
// Vue 内部会在 setupState 上套这一层 Proxy
function proxyRefs(objectWithRefs) {
return new Proxy(objectWithRefs, shallowUnwrapHandlers);
}
运行流程
- 建立代理 :当
setup()函数返回一个对象(包含 ref)时,Vue 内部调用handleSetupResult,使用proxyRefs包装这个返回对象,生成 render context(渲染上下文)。 - 模板读取 :
- 当模板渲染遇到
{{ count }}时,实际上是去在这个 Proxy 对象上取count。 - 触发
get拦截:发现count是一个ref,Proxy 自动帮你调用.value并返回结果。
- 当模板渲染遇到
- 模板赋值 (例如
v-model):- 如果在模板中写
<input v-model="count">。 - 触发
set拦截:Proxy 发现count原本是ref,而输入的是普通值,它会将新值赋值给count.value。
- 如果在模板中写
总结
| 特性 | 核心实现类/函数 | 关键原理 |
|---|---|---|
| ref | RefImpl |
利用 getter/setter 劫持 .value 属性,通过 track/trigger 管理依赖。若值为对象则借助 reactive。 |
| toRefs | ObjectRefImpl |
不存值,仅仅是对源 reactive 对象属性的代理访问。解决了解构导致的响应性丢失问题。 |
| 模板解包 | proxyRefs |
利用 Proxy 拦截 setup 返回对象的访问,遇到 ref 自动返回 .value,实现由模板到数据的无感读写。 |
通过阅读这部分源码,我们可以看到 Vue 3 在易用性(自动解包)和灵活性(ref/reactive 分离)之间做了非常精妙的设计。