ref 与 computed 原理:包装对象与懒计算
🎯 引言
前面两篇我们讲了 reactive 和 effect:reactive 用 Proxy 把对象变成响应式,effect 负责记录「谁用到了数据」,数据变化时通知更新。但这里有一个明显的缺口:Proxy 只能代理对象,string、number 这些基本类型怎么办? 你没法对一个数字 new Proxy(18)。Vue3 的答案就是 ref。这一篇我们打开 ref 和 computed 的源码,读完后你能够:
- 说清楚为什么基本类型需要
ref,.value到底拦截了什么。 - 读懂
RefImpl类的实现:读取时登记使用者,赋值时通知更新。 - 理解
computed的懒计算和缓存,是怎么靠一个「脏标记」实现的。 - 明白模板里
ref为什么不用写.value,解构ref又为什么会丢响应式。
本篇源码基于 Vue 3.5.x(GitHub vuejs/core 仓库),主要涉及 packages/reactivity/src/ref.ts 和 packages/reactivity/src/computed.ts。文中示例可以直接用 Node.js v22 运行。
🧱 为什么需要 ref
这一节先回答一个问题:一个普通的数字,Vue 是怎么感知到它变了的?
回顾第三篇的结论:reactive 的底层是 Proxy,而 Proxy 只能拦截对象上的操作。这里说的「拦截」,指的是当你读取或修改数据时,Vue 能在这个过程中插入自己的代码,从而感知到这次操作。看下面的对比:
// 对象:可以被 Proxy 拦截
const state = reactive({ count: 0 });
state.count++; // 读写属性会经过 Proxy,Vue 能感知到
// 基本类型:没有可拦截的入口
let count = 0;
count++; // 只是一次普通变量赋值,Vue 感知不到
基本类型的值直接存在变量里,JavaScript 没有任何机制能在「读变量」「改变量」时插入代码,所以光靠 Proxy,count++ 这种操作 Vue 发现不了。Vue3 的解法很直接:拦不住值本身,就把值包进一个对象,改为拦截这个对象上的属性读写。这个对象带一个 .value 属性:
// ref 的本质:一个带 .value 属性的对象
const count = ref(0);
count.value++; // 读 .value、改 .value,这两步都能被拦截
这样一来,「读取 count.value」和「修改 count.value」都成了对象属性的读写。而属性读写是可以被拦截的,第三篇用 Proxy 做到,这一篇你会看到用 class 的 getter/setter 也能做到。包上这层对象之后,响应式那套机制就又能用了。
🧱 RefImpl 的实现
知道了「包一层对象」的思路,这一节就看 Vue 怎么实现这个对象。ref() 做的事就是创建它,源码里这个类叫 RefImpl。下面分两步看教学简化版(去掉了 TypeScript 类型注解和边缘分支,保留核心逻辑)。第一步,创建时把值存进去:
// 简化自 packages/reactivity/src/ref.ts
class RefImpl {
constructor(value) {
// 传进来的是对象,就交给 reactive 转成响应式对象
this._value = isObject(value) ? reactive(value) : value;
// 同时保存一份原始值,以后赋值时用来对比新旧
this._rawValue = value;
}
}
第二步,拦截 .value 的读写。这里用了 class 的访问器(getter/setter):它的作用是,读 .value 时自动执行 get value(),写 .value 时自动执行 set value(),Vue 就把拦截代码写在这两个函数里:
get value() {
// 收集依赖:把当前正在执行的 effect 登记到这个 ref 名下
trackRefValue(this);
return this._value;
}
set value(newVal) {
// hasChanged 内部用 Object.is 对比新旧值,没变就直接结束
if (hasChanged(newVal, this._rawValue)) {
this._rawValue = newVal;
// 新值是对象的话,同样转成响应式
this._value = isObject(newVal) ? reactive(newVal) : newVal;
// 触发更新:通知登记过的所有 effect 重新执行
triggerRefValue(this);
}
}
这里的 trackRefValue 就是第四篇讲过的依赖收集,逻辑和 reactive 的 track 是同一套。「依赖」这个词不用怕,它就是「用到了这个数据的 effect 函数」,先登记下来,数据变了才知道该通知谁。triggerRefValue 则是反方向的动作:挨个通知登记过的 effect。而 ref() 本身只是一行包装:把值 new 成一个 RefImpl 实例返回。另外有两点值得记住:
- 赋同一个值不会触发更新:
hasChanged先挡一道,这是一个简单实用的优化。 - 传入对象会被
reactive转换:所以ref({ name: 'noxussj' })之后,改userRef.value.name也能触发更新,靠的是对象那套Proxy拦截。
Proxy?因为只需拦截一个固定属性,getter/setter 更直接、开销更小。了解即可,不影响理解主线。🧱 ref 在 reactive 中的自动解包
这一节解释一个你天天在用、但可能没注意过的「自动」行为:当 ref 作为 reactive 对象的属性时,访问它会自动取出 .value,这叫自动解包,就是 Vue 帮你省掉了写 .value 这一步:
const count = ref(0);
const state = reactive({ count });
console.log(state.count); // 0,不用写 state.count.value
state.count = 1; // 等价于 count.value = 1
原理一句话:reactive 的 get 拦截里会检查读到的值是不是 ref,是的话就顺手返回 .value,set 拦截同理。所以模板和 reactive 对象里都能省略 .value。而在普通 JS 代码里,ref 没有经过任何拦截处理,就必须老老实实写 .value。
reactive([ref(0)])[0] 拿到的还是 ref 本身,要访问 .value。🧱 ComputedRefImpl:懒计算与缓存
理解了 ref,computed 就是顺水推舟的事。这一节回答一个常见疑问:computed 为什么比模板里的普通方法「省」?
先用大白话说 computed 是什么:它包着一个计算函数,没人用的时候不执行,执行过一次就把结果存起来,只要用到的数据没变,之后直接返回存好的结果,不再重复执行。这里说的「缓存」,就是把算过的结果存起来,下次直接用。
源码里 computed 返回的也是一个类实例,叫 ComputedRefImpl。整个机制只靠一个标记:_dirty(脏标记),它是一个布尔值,true 表示「上次存的结果已经过期,需要重新计算」。创建实例时只把标记置为 true,不计算。注意下面把计算函数包进了一个 effect,它是第四篇讲的「会自动重跑的函数包裹器」,能记住自己用到了哪些响应式数据:
// 简化自 packages/reactivity/src/computed.ts
class ComputedRefImpl {
constructor(getter) {
this._dirty = true; // 脏标记:true 表示结果过期,需要重算
this._value = undefined; // 缓存上次的计算结果
// 依赖变化时不急着重算,只把脏标记置回 true
this.effect = new ReactiveEffect(getter, () => {
if (!this._dirty) {
this._dirty = true;
// 通知「用到了这个 computed 的地方」更新
triggerRefValue(this);
}
});
}
}
读取 .value 时:标记为 true 才重新计算,否则直接返回缓存:
get value() {
// 和 ref 一样,读取时先登记使用者
trackRefValue(this);
// 只有脏了才重新执行 getter,否则直接返回缓存
if (this._dirty) {
this._dirty = false; // 计算完成,清除脏标记
this._value = this.effect.run();
}
return this._value;
}
computed() 同样只是一行包装:new ComputedRefImpl(getter) 返回实例。把「懒」字拆开,就是三条规则:
- 创建时不计算:只有首次读
.value才真正执行 getter,这叫懒计算。 - 读完就缓存:结果存进
_value,之后再读直接返回缓存。 - 依赖变了只改标记:getter 里用到的数据变化时,不急着重算,只把
_dirty置回true,等下次有人读才算。
用一段代码验证缓存确实生效(保存为 .js 文件,Node.js v22 直接运行):
let calls = 0;
const count = ref(1);
// computed:依赖不变时只算一次
const double = computed(() => {
calls++;
return count.value * 2;
});
double.value; // 首次读取,真正计算,calls = 1
double.value; // 返回缓存,calls 还是 1
double.value; // 返回缓存,calls 还是 1
连续读三次只算了一次。换成普通方法 getDouble(),调三次就执行三次。计算量大、读取频繁的时候,这个差距就很可观了。
⚡ 与 Vue2 computed 对比
如果你读过 Vue2 源码课程 的 computed 篇,会发现思想完全一样:都是「脏标记 + 缓存 + 惰性求值」(惰性求值和前面说的懒计算是同一个意思:用到的时候才计算)。变化的只是实现工具:Vue2 用 Watcher 加 dirty 标记,Vue3 换成 ReactiveEffect 加 _dirty 标记,依赖收集和触发更新复用了响应式统一的 track / trigger 机制。学过一个,另一个很容易看懂。
🧾 小节总结
Proxy无法拦截基本类型,ref的解法是包一层对象:把值装进带.value的对象,拦截.value的读写。RefImpl用 class 的 getter/setter 实现:读取时trackRefValue登记使用者,赋值时经hasChanged判断后triggerRefValue通知更新。ref传入对象时内部会被reactive转换,所以改userRef.value.name也能触发更新。ref作为reactive对象的属性会自动解包,但作为数组或原生集合的元素时不解包。computed的核心是_dirty脏标记:创建时不算,首次读取才算并缓存,依赖变了只改标记,下次读取才重算。
❓ 知识问答
Q1:为什么模板里的 ref 不用写 .value?
A:模板编译后的渲染代码访问顶层 ref 时会自动解包(取 .value),ref 作为 reactive 对象的属性时同理。但在普通 JS 代码里没有这层处理,必须自己写 .value。
Q2:解构 ref 为什么会丢响应式?
A:const { value } = countRef 是把解构那一刻 .value 的当前值复制成一个普通变量,之后它和 ref 再无关系。想解构又保留响应式,用 toRefs 或 toRef,它们返回的是保持连接的 ref 对象。
Q3:computed 为什么有缓存,普通方法没有?
A:computed 内部有 _dirty 标记和 _value 缓存,依赖没变就直接返回上次的结果;普通方法每次调用都是一次全新的执行。所以派生数据适合用 computed,需要传参或每次都必须重新执行的场景用方法。
Q4:computed 里能修改它的 value 吗?
A:默认不能,只读的 computed 没有 set value(),强行赋值在开发环境会收到警告。确实需要时可以用 computed({ get, set }) 的可写形式,日常开发中用得很少。
Q5:ref 和 reactive 平时该怎么选?
A:基本类型只能用 ref;对象两者都可以。团队里常见的约定是统一用 ref,心智负担小(访问都带 .value),也方便在函数间传递而不丢响应式。详细区别可回顾 Vue3 使用课程 的响应式章节。
🧪 小练习
不查资料,补全下面的 MyRef 类,实现一个迷你 ref:读取 .value 时打印「收集了依赖」,修改 .value 且值确实变化时打印「触发了更新」,赋相同的值则不打印。用 Node.js v22 保存为 .js 文件直接运行验证。
class MyRef {
constructor(value) {
this._value = value;
}
get value() {
// 请在这里编写代码:打印「收集了依赖」,然后返回 this._value
}
set value(newVal) {
// 请在这里编写代码:新旧值不同才更新 _value 并打印「触发了更新」
}
}
const count = new MyRef(0);
// 期望输出:
// 收集了依赖
// 触发了更新
// 收集了依赖
console.log(count.value);
count.value = 1;
count.value = 1; // 值没变,这一行不应有任何打印
console.log(count.value);
完成后给自己加个挑战:参考 ComputedRefImpl 的思路,给 MyRef 写一个 myComputed(getter),要求依赖不变时重复读取只执行一次 getter。想一想,需要新增哪个标记?
🎉 恭喜你已经掌握 ref 与 computed 的底层原理啦!响应式数据会变了、依赖也收齐了,那「更新」到底是怎么安排执行顺序的?下一篇我们讲调度器与 nextTick,看看 Vue3 如何把多次修改合并成一次更新。
