💡 [本系列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官网
[2] Vuejs设计与实现
[3] Vue3源码