前言
在 Vue3 中对 computed 源码的分析需要有一个前提,那就是需要理解 Vue3 的响应式数据的原理,例如:ref、reactive 的原理认识,可查看另一篇文章《Vue3 响应式原理剖析》
computed 在使用上也很清楚,也是响应式的,那当然和 Vue 基础的响应式原理的一个流程是相关了。
开发中,经常用到 computed 的时候,会很好奇,为什么 computed 中依赖的响应式数据发生变更后能够监听到并且更新对应 computed 实例所依赖的文档节点呢?
本人带着这个疑问翻阅了源码,以此记录~
源码分析
computed
路径:vue-next/packages/reactivity/src/computed.ts
export function computed<T>(getter: ComputedGetter<T>): ComputedRef<T>
export function computed<T>(
options: WritableComputedOptions<T>
): WritableComputedRef<T>
export function computed<T>(
getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>
) {
let getter: ComputedGetter<T>
let setter: ComputedSetter<T>
if (isFunction(getterOrOptions)) {
getter = getterOrOptions
setter = __DEV__
? () => {
console.warn('Write operation failed: computed value is readonly')
}
: NOOP
} else {
getter = getterOrOptions.get
setter = getterOrOptions.set
}
return new ComputedRefImpl(
getter,
setter,
isFunction(getterOrOptions) || !getterOrOptions.set
) as any
}
computed 函数进行了重载,看最后一个函数,参数 getterOrOptions 有两种方式,一种是传入一个方法,另一种是传入一个带有 set、get 的对象。
- 第一种
computed传入了方法,那这个方法会直接作为计算属性实例的get方法,set方法是一个空操作NOOP,这个NOOP就是一个() => {} - 第二种
computed传入和包含了set、get的对象,会作为计算属性实例的get、set属性
往下看 computed 在最后会 return 一个实例 new ComputedRefImpl, 接下来看看 ComputedRefImpl 到底是什么?
ComputedRefImpl
路径:vue-next/packages/reactivity/src/computed.ts
class ComputedRefImpl<T> {
private _value!: T
private _dirty = true
public readonly effect: ReactiveEffect<T>
public readonly __v_isRef = true;
public readonly [ReactiveFlags.IS_READONLY]: boolean
constructor(
getter: ComputedGetter<T>,
private readonly _setter: ComputedSetter<T>,
isReadonly: boolean
) {
this.effect = effect(getter, {
lazy: true,
scheduler: () => {
if (!this._dirty) {
this._dirty = true
trigger(toRaw(this), TriggerOpTypes.SET, 'value')
}
}
})
this[ReactiveFlags.IS_READONLY] = isReadonly
}
get value() {
if (this._dirty) {
this._value = this.effect()
this._dirty = false
}
track(toRaw(this), TrackOpTypes.GET, 'value')
return this._value
}
set value(newValue: T) {
this._setter(newValue)
}
}
new ComputedRefImpl 的时候调用自身的构造函数 constructor,构造函数中有一个重要的一点,调用了 effect,如果看过 reactive 原理或者了解过 Vue3 响应式原理的对这个函数应该不陌生,该函数会对副作用更新函数进行包装,并且返回一个携带信息的副作用函数 effect。
调用 effect 方法传入了 computed 的参数 getter, 第二个参数传入了一个 options 选项,包含 lazy 这个属性是代表着调用 effect 的时候不会触发 getter 的方法,其实他的一个更深层的含义是不需要马上对 getter 里面相关依赖的响应式数据进行依赖绑定(后面在 effect 方法中会讲)。
此外 options 还传入了另一个属性 schedule,从直观上来看,这是一个调度方法,该方法会对 _dirty 进行设置,_dirty 变量是用来判断是否要重新计算的,因为计算属性是会将计算结果存储在 this._value 中进行一个缓存的,其次调用了 trigger 这个方法名应该熟悉,明显是一个触发执行更新函数的作用,类似于 Vue2 中的 notify
到此 ComputedRefImpl 构造函数主要就是调用了 effect 函数,接下来看看 effect 函数做了什么
effect
路径:vue-next/packages/reactivity/src/effect.ts
export function effect<T = any>(
fn: () => T,
options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
if (isEffect(fn)) {
fn = fn.raw
}
const effect = createReactiveEffect(fn, options)
if (!options.lazy) {
effect()
}
return effect
}
function createReactiveEffect<T = any>(
fn: () => T,
options: ReactiveEffectOptions
): ReactiveEffect<T> {
const effect = function reactiveEffect(): unknown {
if (!effect.active) {
return options.scheduler ? undefined : fn()
}
if (!effectStack.includes(effect)) {
cleanup(effect)
try {
enableTracking()
effectStack.push(effect)
activeEffect = effect
return fn()
} finally {
effectStack.pop()
resetTracking()
activeEffect = effectStack[effectStack.length - 1]
}
}
} as ReactiveEffect
effect.id = uid++
effect.allowRecurse = !!options.allowRecurse
effect._isEffect = true
effect.active = true
effect.raw = fn
effect.deps = []
effect.options = options
return effect
}
首先,effect 函数调用了 createReactiveEffect,该函数就进行了对响应式数据所依赖的副作用更新函数进行包装了。
进入 createReactiveEffect 方法,方法中定义了 reactiveEffect,reactiveEffect 里面有几个重要的变量:effectStack 用于存在副作用函数入栈中、activeEffect 用于记录当前的副作用函数,以便后续响应数据收集依赖所用。createReactiveEffect 其后进行了相关信息的收集,其中就包含了前面 ComputedRefImpl 构造函数调用 effect 传入的 options 选项,后续这将是非常有用的东西。
createReactiveEffect 执行完后,回到 effect 可以看到:
if (!options.lazy) {
effect()
}
对于计算属性,设置了 lazy 不执行 effect,意义上就是先不进行计算属性中所依赖属性的 activeEffect 的进行收集,因为这时候,没必要这么快进行收集,因为收集的函数也是一个 computed 传入的方法,目前这个方法就算收集了,变更响应式数据,也不会去更新视图,仅仅就重新执行一个 computed 传入的方法。
最后返回了 effect 给 ComputedRefImpl 的实例属性上 this.effect 进行保存。
注意:到目前为止都只是走过了 computed 的一个代码流程,在经历了以上的代码流程后,脑子里有了一个大概的模样,computed 执行干了什么,接下来就是见证神奇的地方,如何对 computed 所依赖的响应数据和计算属性实例还有文档节点进行绑定的。
compile 编译
这里先假设定义一段基本代码:
<template>
<div>
<button @click="addAge"> + </button>
<p>{{doubleAge}}</p>
</div>
</template>
<script>
import { reactive, computed } from 'vue'
export default {
name: 'App',
setup(){
const info = reactive({
age: 18
});
const fnB = () => info.age * 2;
const doubleAge = computed(fnB);
function addAge(){
double.age += 1;
}
return {
addAge,
doubleAge
}
},
}
</script>
Vue 的基本流程 其中包括 Observer -> Compile、在 Compile 编译阶段,处理 <p>计算属性:{{doubleAge}}</p> 的时候,假设会产生一个副作用函数 fnA (没概念的先去了解一下 Vue 的响应式原理):
// fnA
function (node, key, vm) {
// 其实就是变更 <p>{{doubleAge}}</p> 的内容,key 就是 doubleAge
node.parentNode.innerHTML = vm._data[key];
}
经过 Compile 编译后,会调用 effect 对 fnA 进行副作用函数的包装,根据前面了解到,lazy 将用于进行依赖属性的副作用手机,在编译的时候 lazy 为 false,进而会执行 effect,其中 effectStack 栈就会压入 fnA,然后执行 return fn() 其实就是执行了 fnA,但要注意的是,Vue 的基本流程是先进行了 Observer,也就是对应案例中,先执行了 reactive() 和 computed(),所以执行了 fnA 的时候,就是对计算属性 doubleAge 进行取值,以此进入 ComputedRefImpl 实例的 get 方法:
get value() {
if (this._dirty) {
this._value = this.effect()
this._dirty = false
}
track(toRaw(this), TrackOpTypes.GET, 'value')
return this._value
}
_dirty 一开始默认是 true,初始化肯定需要计算结果的,然后调用了计算属性实例的 this.effect(在执行 computed 时,构造函数所保存在实例中的 effect),执行这个 effect 后,变量 effectStack 再次进栈 fnB,activeEffect 赋值为 fnB,并且执行了 fnB,这时候执行了 fnB 就会对 reactive 响应式数据的 age 进行了取值操作,进入 age 的 get 方法:
function createGetter(isReadonly = false, shallow = false) {
return function get(target: Target, key: string | symbol, receiver: object) {
...
const res = Reflect.get(target, key, receiver)
if (!isReadonly) {
track(target, TrackOpTypes.GET, key)
}
...
if (isObject(res)) {
// Convert returned value into a proxy as well. we do the isObject check
// here to avoid invalid value warning. Also need to lazy access readonly
// and reactive here to avoid circular dependency.
return isReadonly ? readonly(res) : reactive(res)
}
return res
}
}
这时候就会在 get 方法中进行 track 收集依赖 activeEffect,也就是 effect (包装着 fnB),需要注意的一点,这里的 effect (包装着 fnB) 具备这一些信息,比如:options.scheduler, 到此依赖 targetMap 键值对中,保存的数据结构如下:
/*
* targetMap:[
* info: {
* age: [
* effect(包装着 fnB)
* ],
* },
* ]
*/
再回顾一下,这时 effectStack 栈的数据结构为:
/**
* effectStack = [effect(fnA), effect(fnB)];
*/
执行完上面的上面的 fnB 后会进行 try 的 finally:
try {
enableTracking()
effectStack.push(effect)
activeEffect = effect
return fn()
} finally {
effectStack.pop()
resetTracking()
activeEffect = effectStack[effectStack.length - 1]
}
这时候 effectStack 栈的数据结构为:
/**
* effectStack = [effect(fnA)];
*/
activeEffect 为 effect(fnA)
走完这一步,继续看到计算属性的 get 方法:
get value() {
if (this._dirty) {
this._value = this.effect()
this._dirty = false
}
track(toRaw(this), TrackOpTypes.GET, 'value')
return this._value
}
可以看到,后面会对计算属性实例进行 track 收集依赖,收集的依赖就是此刻的 activeEffect 其实就是 effect(fnA), 因为前面 finally 中会执行 activeEffect = effectStack[effectStack.length - 1],再来看一下目前 targetMap 键值对的数据结果:
/*
* targetMap:[
* info: {
* age: [
* effect(包装着 fnB)
* ],
* },
* doubleAge: {
* value: [
* effect(fnA) 真正更新 dom 的方法,fnA 从 compile 而来
* ]
* }
* ]
*/
到此刻,计算属性的依赖就做完了,接下来通过变更 age 看看是如何进行视图变更的。
数据变更
按照上面的例子,当点击 addAge 的时候,age 发生了变更,这时候会触发 proxy 代理后的 set 方法,触发 trigger 迭代执行 targetMap 中的 info[age] 的 Set 数组,执行逻辑如下:
export function trigger(
target: object,
type: TriggerOpTypes,
key?: unknown,
newValue?: unknown,
oldValue?: unknown,
oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
const depsMap = targetMap.get(target)
if (!depsMap) {
// never been tracked
return
}
const effects = new Set<ReactiveEffect>()
const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
if (effectsToAdd) {
effectsToAdd.forEach(effect => {
if (effect !== activeEffect || effect.allowRecurse) {
effects.add(effect)
}
})
}
}
if (type === TriggerOpTypes.CLEAR) {
// collection being cleared
// trigger all effects for target
depsMap.forEach(add)
} else if (key === 'length' && isArray(target)) {
depsMap.forEach((dep, key) => {
if (key === 'length' || key >= (newValue as number)) {
add(dep)
}
})
} else {
// schedule runs for SET | ADD | DELETE
if (key !== void 0) {
add(depsMap.get(key))
}
// also run for iteration key on ADD | DELETE | Map.SET
switch (type) {
case TriggerOpTypes.ADD:
if (!isArray(target)) {
add(depsMap.get(ITERATE_KEY))
if (isMap(target)) {
add(depsMap.get(MAP_KEY_ITERATE_KEY))
}
} else if (isIntegerKey(key)) {
// new index added to array -> length changes
add(depsMap.get('length'))
}
break
case TriggerOpTypes.DELETE:
if (!isArray(target)) {
add(depsMap.get(ITERATE_KEY))
if (isMap(target)) {
add(depsMap.get(MAP_KEY_ITERATE_KEY))
}
}
break
case TriggerOpTypes.SET:
if (isMap(target)) {
add(depsMap.get(ITERATE_KEY))
}
break
}
}
const run = (effect: ReactiveEffect) => {
if (__DEV__ && effect.options.onTrigger) {
effect.options.onTrigger({
effect,
target,
key,
type,
newValue,
oldValue,
oldTarget
})
}
if (effect.options.scheduler) {
effect.options.scheduler(effect)
} else {
effect()
}
}
effects.forEach(run)
}
trigger 代码中会将 info 的 age 所有依赖放到
const effects = new Set<ReactiveEffect>()
然后通过 run 方法去迭代执行,关键点来了,在 run 方法中会判断 effect.options.scheduler,而此时在执行 effect(包装着 fnB) 的时候,就存在 scheduler:
scheduler: () => {
if (!this._dirty) {
this._dirty = true
trigger(toRaw(this), TriggerOpTypes.SET, 'value')
}
}
schedule 中再次进行 trigger 通知订阅者,这个订阅者就是计算属性实例在 targetMap 的 doubleAge,然后再次执行 run,执行 targetMap[doubleAge][value] 中副作用函数 effect(fnA) 最后终于会去执行 fnA 更新视图了。
总结
computed 的响应式依赖绑定十分的巧妙,这其中有一个 effectStack 栈起到了关键的作用。对于这块原理的认知,需要对 Vue 不管是 2.x 还是现在的 3.x版本,都需要对基础的响应式原理,从数据绑定到视图更新这一流程有一定的认识,才能更好的理解 computed 的流程。