
💡 [本系列Vue3源码解读文章基于3.3.4版本](https://github.com/vuejs/core/tree/v3.3.4) 欢迎关注公众号:《前端Talkking》
1、前言
在Vue3中,有一个effect函数,它用来注册副作用函数,同时它也允许指定一些选项参数options,例如指定scheduler调度器来控制副作用函数的执行时机和方式;也介绍了用来追踪和收集依赖的track函数,以及用来触发副作用函数重新执行的trigger函数。实际上,综合这些内容,我们就可以实现 Vue.js 中一个非常重要并且非常有特色的能力---计算属性 computed。
在深入讲解计算属性之前,我们需要先来聊聊关于懒执行的effect,即lazy的effect。这是什么意思呢?举个例子,现在我们所实现的effect函数会立即执行传递给它的副作用函数,例如:
javascript
effect(
() => {
console.log("123")
}
)
但在有些场景下,我们并不希望它立即执行,而是希望它在需要的时候才执行,例如计算属性。这时我们可以通过在options中添加lazy属性来达到目的,如下面的代码所示:
javascript
effect(
// 如果指定了lazy 选项,那么这个函数不会立即执行
() => {
console.log()
},
// options
{
lazy: true
}
)
在effect源码实现中,如果指定了options.lazy为true,则不立即执行副作用函数,而是将副作用函数effect作为返回值返回,如下面的代码所示:
javascript
export function effect<T = any>(
fn: () => T,
options?: ReactiveEffectOptions
): ReactiveEffectRunner {
// 省略部分代码
// 如果不是延迟执行的,则立即执行一次副作用函数
if (!options || !options.lazy) {
_effect.run()
}
// 通过bind函数返回一个新的副作用函数
const runner = _effect.run.bind(_effect) as ReactiveEffectRunner
// 将副作用添加到新的副作用函数上
runner.effect = _effect
// 返回这个新的副作用函数
return runner
}
上面的源码我们可以得知,计算属性computed实际上就是一个通过指定lazy属性而实现的懒执行的副作用函数。
2、computed源码实现
2.1 computed签名实现
javascript
// 方式1
export function computed<T>(
getter: ComputedGetter<T>,
debugOptions?: DebuggerOptions
): ComputedRef<T>
// 方式2
export function computed<T>(
options: WritableComputedOptions<T>,
debugOptions?: DebuggerOptions
): WritableComputedRef<T>
// 方式3
export function computed<T>(
getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>,
debugOptions?: DebuggerOptions,
isSSR = false
)
从以上源码实现中,我们可以看出computed计算属性有3种实现方式:
- 方式1:只读的,接收一个
getter,并返回ComputedRef类型的值; - 方式2:可读写,接收一个具有
get和set函数的options对象,并返回一个可写的ref对象; - 方式3:是方式1和方式2的结合,此时,既可以接收一个
getter函数,又可以接受具有get和set函数的options对象。
第1种方式,我们可以这样使用计算属性:
javascript
const count = ref(0)
// computed 接受一个 getter 函数
const plusTwo = computed(() => count.value + 2)
console.log(plusTwo.value) // 2
plusTwo.value++ // 错误
第2种方式,我们可以这样使用计算属性:
javascript
const count = ref(2)
const plusTwo = computed({
// computed 函数接受一个具有 get 和 set 函数的 options 对象
get: () => count.value + 2,
set: val => {
count.value = val - 2
}
})
plusTwo.value = 2
console.log(count.value) // 0
2.2 computed参数标准化处理
javascript
export function computed<T>(
getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>,
debugOptions?: DebuggerOptions,
isSSR = false
) {
let getter: ComputedGetter<T>
let setter: ComputedSetter<T>
// 入参是函数类型
const onlyGetter = isFunction(getterOrOptions)
if (onlyGetter) {
getter = getterOrOptions
setter = __DEV__
? () => {
console.warn('Write operation failed: computed value is readonly')
}
: NOOP
} else {
// 入参是对象类型
getter = getterOrOptions.get
setter = getterOrOptions.set
}
const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter, isSSR)
// onTrack 和 onTrigger 仅开发模式下生效
if (__DEV__ && debugOptions && !isSSR) {
cRef.effect.onTrack = debugOptions.onTrack
cRef.effect.onTrigger = debugOptions.onTrigger
}
return cRef as any
}
由于 computed函数有三种使用方法,因此在该函数中做了标准化处理:
- 如果传入的值
getterOrOptions是getter函数,则直接将传入的参数值赋值给getter函数,此时,计算属性是只读的,并且在开发环境中设置setter会报出警告; - 如果传入的值
getterOrOptions是对象(包含get、set方法),则将get和set方法分别赋值给computed函数的getter和setter方法; - 创建
ComputedRefImpl实例,该实例是一个ref对象,定义了get和set两个方法,最后将该实例返回,因此计算属性取值和赋值的时候需要带上.value。
2.3 ComputedRefImpl源码实现
javascript
export class ComputedRefImpl<T> {
public dep?: Dep = undefined
private _value!: T
public readonly effect: ReactiveEffect<T>
public readonly __v_isRef = true
public readonly [ReactiveFlags.IS_READONLY]: boolean = false
// 数据是否更新的标识:缓存标识、脏数据标识,默认应该取值计算,所以是true
public _dirty = true
public _cacheable: boolean
constructor(
getter: ComputedGetter<T>,
private readonly _setter: ComputedSetter<T>,
isReadonly: boolean,
isSSR: boolean
) {
// 将用户的getter放到effect中,这样能对getter函数进行依赖收集,activeEffect会变为getter生成的effect
// 传入scheduler调用函数,稍后 依赖的属性变化会调用此方法
this.effect = new ReactiveEffect(getter, () => {
// 稍后依赖属性变化会执行此调度函数
if (!this._dirty) {
// 2、依赖的值变化会更新dirty并触发更新
this._dirty = true
// 触发更新
triggerRefValue(this)
}
})
this.effect.computed = this
this.effect.active = this._cacheable = !isSSR
this[ReactiveFlags.IS_READONLY] = isReadonly
}
get value() {
// 获取原始对象
// the computed ref may get wrapped by other proxies e.g. readonly() #3376
const self = toRaw(this)
// 1、取值的时候进行依赖收集!!!
trackRefValue(self)
// 第一次是true,开关开启,说明是脏值,执行函数,然后关闭开关
if (self._dirty || !self._cacheable) {
self._dirty = false
// 其实执行的是scheduler调度函数在其中触发更新triggerEffects,此处的run方法其实就是computed传入的匿名方法
self._value = self.effect.run()!
}
return self._value
}
set value(newValue: T) {
this._setter(newValue)
}
}
在上面的源码中,拆解实现步骤如下:
- 定义了
_value私有变量用于缓存上一次计算的值,定义了_dirty变量用于表示是否需要重新计算值,为true时表示需要重新计算,默认是true; - 在构造函数中,定义了effect,
ReactiveEffect第二个参数称为scheduler调度器,当依赖属性的值发生变化时会触发该方法的执行; - 在
get方法中,读取值的时候使用trackRefValue(self)方法进行依赖收集,由于_dirty默认是true,开关开启,先将开关关闭,然后执行self.effect.run()方法后进行赋值,effect.run()方法实际上是computed传入的匿名方法getter; - 当计算属性依赖的属性发生变化时,就会执行
scheduler调度器,即:
javascript
() => {
// 稍后依赖属性变化会执行此调度函数
if (!this._dirty) {
// 2、依赖的值变化会更新dirty并触发更新
this._dirty = true
// 触发更新
triggerRefValue(this)
}
}
此时,this._dirty是false,因此会先设置dirty的值为true,表示依赖的属性有更新,需要重新计算了,然后触发更新。触发更新会重新获取value值,此时dirty为true,因此重新执行effect.run方法(即computed传入的匿名函数),最后获取到了最新的值。
3、示例调试
下面我们用一个示例来熟悉一下computed的整个流程。
3.1 修改build命令
开启sourcemap支持,末尾增加:-s
javascript
"build": "node scripts/build.js -s"
3.2 运行命令打包命令
javascript
pnpm run build
执行完毕后会在 packages/vue/dist目录下生成打包后的文件,如下图所示:

3.3 添加demo示例
在 packages/vue/examples目录下新建computed目录以及computed.html文件,内容如下:
javascript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>computed</title>
<script src="../..dist/vue.global.js"></script>
</head>
<body>
<div id="app"></div>
<script>
const { reactive, effect, computed } = Vue
// 创建响应式数据
const obj = reactive({
name: 'Vue2'
})
// 计算属性 触发 obj.name 的 get 行为
const computedObj = computed(() => {
return '版本:' + obj.name
})
// effect 函数中 触发 计算属性的 get 行为
effect(() => {
document.querySelector('#app').innerHTML = computedObj.value
})
// 修改响应式数据的 name 值 触发 set 行为
setTimeout(() => {
obj.name = 'Vue3'
}, 2000)
</script>
</body>
</html>
首先computed接收了一个匿名函数getterOrOptions,它在底层传给了getter函数:

然后执行effect函数,执行赋值操作:document.querySelector('#app').innerHTML = computedObj.value ,触发到了computed的get value方法。在get方法内,先调用了 trackRefValue(self)方法进行依赖收集。此时_dirty变量默认是true,会执行effect.run()方法,而 effect.run()方法其实就是computed的匿名函数() => { return '版本:' + obj.name }。 
执行完毕后,页面渲染出:版本:Vue2

2秒后会触发obj的setter方法,它根据name属性获取到对应的effects,然后执行triggerEffects方法。由于此时执行的effect含有computed 属性,且存在scheduler,则会执行 effect.scheduler()方法。

在执行effect.scheduler()方法中,执行triggerRefValue(this)依赖触发。此时,scheduler为null,会执行 effect.run方法,也就是efffect传入的匿名函数。

之后执行document.querySelector('#app').innerHTML = computedObj.value赋值操作,再次触发 computed的get value方法。
javascript
get value() {
// 获取原始对象
// the computed ref may get wrapped by other proxies e.g. readonly() #3376
const self = toRaw(this)
// 1、取值的时候进行依赖收集!!!
trackRefValue(self)
// 第一次是true,开关开启,说明是脏值,执行函数,然后关闭开关
if (self._dirty || !self._cacheable) {
self._dirty = false
// 其实执行的是scheduler调度函数在其中触发更新triggerEffects,此处的run方法其实就是computed传入的匿名方法
self._value = self.effect.run()!
}
return self._value
}
接着执行self._value = self.effect.run()!,又再次执行computed传入的匿名函数() => { return '版本:' + obj.name }重新赋值: 
执行完毕后,页面会呈现更新后的值,这样就是最新的值了。

4、总结
本文介绍了computed的实现流程,计算属性computed实际上是一个懒执行的副作用函数,我们通过lazy选项使得副作用函数可以 懒执行。被标记为懒执行的副作用函数可以通过手动方式让其执行。 利用这个特点,我们设计了计算属性,当读取计算属性的值时,只需要手动执行副作用函数即可。当计算属性依赖的响应式数据发生变化 时,会通过scheduler将 dirty 标记设置为true,代表"脏"。这样,下次读取计算属性的值时,我们会重新计算真正的值,从而完成了值的实时更新。
5、参考资料
1\] [Vue官网](https://link.juejin.cn?target=https%3A%2F%2Fcn.vuejs.org%2F "https://cn.vuejs.org/") \[2\] [Vuejs设计与实现](https://link.juejin.cn?target=https%3A%2F%2Fwww.ituring.com.cn%2Fbook%2F2953 "https://www.ituring.com.cn/book/2953") \[3\] [Vue3源码](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fvuejs%2Fcore%2Fblob%2Fv3.3.4 "https://github.com/vuejs/core/blob/v3.3.4")