Vue3 源码简介

ref 与 computed 原理:包装对象与懒计算

掌握 Vue3 中 ref 的包装对象原理和 computed 的懒计算与缓存机制,理解 .value 背后的源码实现。

🎯 引言

前面两篇我们讲了 reactiveeffectreactiveProxy 把对象变成响应式,effect 负责记录「谁用到了数据」,数据变化时通知更新。但这里有一个明显的缺口:Proxy 只能代理对象,stringnumber 这些基本类型怎么办? 你没法对一个数字 new Proxy(18)。Vue3 的答案就是 ref。这一篇我们打开 refcomputed 的源码,读完后你能够:

  • 说清楚为什么基本类型需要 ref.value 到底拦截了什么。
  • 读懂 RefImpl 类的实现:读取时登记使用者,赋值时通知更新。
  • 理解 computed 的懒计算和缓存,是怎么靠一个「脏标记」实现的。
  • 明白模板里 ref 为什么不用写 .value,解构 ref 又为什么会丢响应式。

本篇源码基于 Vue 3.5.x(GitHub vuejs/core 仓库),主要涉及 packages/reactivity/src/ref.tspackages/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 没有任何机制能在「读变量」「改变量」时插入代码,所以光靠 Proxycount++ 这种操作 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 就是第四篇讲过的依赖收集,逻辑和 reactivetrack 是同一套。「依赖」这个词不用怕,它就是「用到了这个数据的 effect 函数」,先登记下来,数据变了才知道该通知谁。triggerRefValue 则是反方向的动作:挨个通知登记过的 effect。而 ref() 本身只是一行包装:把值 new 成一个 RefImpl 实例返回。另外有两点值得记住:

  • 赋同一个值不会触发更新hasChanged 先挡一道,这是一个简单实用的优化。
  • 传入对象会被 reactive 转换:所以 ref({ name: 'noxussj' }) 之后,改 userRef.value.name 也能触发更新,靠的是对象那套 Proxy 拦截。
为什么用 getter/setter 而不是再包一层 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

原理一句话:reactiveget 拦截里会检查读到的值是不是 ref,是的话就顺手返回 .valueset 拦截同理。所以模板和 reactive 对象里都能省略 .value。而在普通 JS 代码里,ref 没有经过任何拦截处理,就必须老老实实写 .value

例外情况:ref 作为数组元素或原生集合(Map、Set)的元素时不会解包,reactive([ref(0)])[0] 拿到的还是 ref 本身,要访问 .value

🧱 ComputedRefImpl:懒计算与缓存

理解了 refcomputed 就是顺水推舟的事。这一节回答一个常见疑问: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 直接运行):

demo.js
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 用 Watcherdirty 标记,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 再无关系。想解构又保留响应式,用 toRefstoRef,它们返回的是保持连接的 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 如何把多次修改合并成一次更新。