Vue3.0Reactivity之computed

前言

这次讲讲computed这个API

computed

computed在Vue2也有体现,基本上是一个缓存比较大的计算量的值的一种方法,并且能在依赖的值发生变化的时候自动的计算更新后的值。

在我们前面的翻译API的文章中知道了Vue3中computed接收一个getter函数,返回了一个ref的对象。

可以用一个例子来简单的看下Vue3中computed的使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
const {reactive, effect, computed} = VueReactivity;

const o = {
name: "lwf"
};

const r = reactive(o);

const c = computed(() => r.name + " --- computed");

effect(() => {
console.log(r.name);
});

这时候我们尝试改变r.name的值,然后再输出c.value的值

在更新了响应式对象rname属性之后,它的依赖,也就是我们写的effect便执行了,然后再打印c.value,发现它的值已经发生了改变。

接下来,我们就来探究这个API是如何做到这种效果的。

首先,老套路,找到它的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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
}

let dirty = true
let value: T
let computed: ComputedRef<T>

const runner = effect(getter, {
lazy: true,
// mark effect as computed so that it gets priority during trigger
computed: true,
scheduler: () => {
if (!dirty) {
dirty = true
trigger(computed, TriggerOpTypes.SET, 'value')
}
}
})
computed = {
_isRef: true,
// expose effect so computed can be stopped
effect: runner,
get value() {
if (dirty) {
value = runner()
dirty = false
}
track(computed, TrackOpTypes.GET, 'value')
return value
},
set value(newValue: T) {
setter(newValue)
}
} as any
return computed
}

这个函数很长,但总体上可以分成三个部分

  • 匹配参数
  • 建立依赖
  • 返回构建的computed对象(也就是ref对象)

匹配参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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
}
// ...
}

这里判断了传进来的参数是不是一个函数,如果是,那么这个函数就作为一个getter(此时就是只读的),如果不是,那就分别取getset属性做为getter和setter。

一般而言,使用computed时,都是直接传递一个函数进去,也就是上面我举的例子一样,基本上用不到setter。

建立effect(依赖)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export function computed<T>(
getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>
) {
// ...
let dirty = true
let value: T
let computed: ComputedRef<T>
const runner = effect(getter, {
lazy: true,
// mark effect as computed so that it gets priority during trigger
computed: true,
scheduler: () => {
if (!dirty) {
dirty = true
trigger(computed, TriggerOpTypes.SET, 'value')
}
}
})
// ...
}

这里构建了一个effect,把传进来的getter传进了effect这个API,通过之前reactiveAPI的学习,我们知道,

effect传进一个函数,把这个函数和这个函数内部使用的响应式变量建立关联,当这些响应式变量发生改变时,就重新执行这个传进来的函数。

但是这个构建的effect和我们之前似乎有点不同,额外的传入了一个参数。

首先是lazy,这个参数的意思是,不马上执行(也就是自动)这个函数来建立依赖,而是通过返回的函数来手动的来收集依赖。

这里的lazyeffect的源码中非常简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export function effect<T = any>(
fn: () => T,
options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
if (isEffect(fn)) {
fn = fn.raw
}
const effect = createReactiveEffect(fn, options)
// 下面为lazy参数
if (!options.lazy) {
effect()
}
return effect
}

可以简单的理解,lazy为假,就自动帮你执行了这个在函数内部创建出来的effect,也就是自动的收集依赖。如果为真,直接返回,可以由用户自己执行来改变收集依赖的时机。

第二个参数为computed,这个参数上面有一个注释,翻译过来为:给这个effect添加一个标记,以至于它可以在trigger时,优先的执行。

第三个参数为scheduler,传入的为一个函数。

这里我们可以回到前面看trigger函数的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
export function trigger(
target: object,
type: TriggerOpTypes,
key?: unknown,
newValue?: unknown,
oldValue?: unknown,
oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
// ...
const effects = new Set<ReactiveEffect>()
// 这是一个计算属性的effect的Set
const computedRunners = new Set<ReactiveEffect>()
const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
if (effectsToAdd) {
effectsToAdd.forEach(effect => {
if (effect !== activeEffect || !shouldTrack) {
// 根据computed这个参数来分类effect
if (effect.options.computed) {
computedRunners.add(effect)
} else {
effects.add(effect)
}
} else {
// the effect mutated its own dependency during its execution.
// this can be caused by operations like foo.value++
// do not trigger or we end in an infinite loop
}
})
}
}

// ...

const run = (effect: ReactiveEffect) => {
if (__DEV__ && effect.options.onTrigger) {
effect.options.onTrigger({
effect,
target,
key,
type,
newValue,
oldValue,
oldTarget
})
}
// 如果scheduler有值,就执行scheduler,否则就执行effect。
if (effect.options.scheduler) {
effect.options.scheduler(effect)
} else {
effect()
}
}

// Important: computed effects must be run first so that computed getters
// can be invalidated before any normal effects that depend on them are run.
// 重点:计算effect需要提前运行。这样可以使得计算effect的getter的值在任何一般的effect运行之前失效
computedRunners.forEach(run)
effects.forEach(run)
}

具体的重点都写在代码的注释中了,简单讲就是分effect为两类,一类是一般effect,一类是计算effect,当一个响应式对象发生变化时,先执行它的计算effect,再执行它的一般effect。

为啥要先执行计算effect呢?这个我们最后再说。

返回构建的computed对象(也就是ref对象)

我们再看最后一段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
export function computed<T>(
options: WritableComputedOptions<T>
): WritableComputedRef<T>
export function computed<T>(
getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>
) {
// ...
computed = {
_isRef: true,
// expose effect so computed can be stopped
effect: runner,
get value() {
if (dirty) {
value = runner()
dirty = false
}
track(computed, TrackOpTypes.GET, 'value')
return value
},
set value(newValue: T) {
setter(newValue)
}
} as any
return computed
}

最后就是构建一个ref对象,和一般的ref对象不同的是,计算ref有一个effect的属性,暴露了通过参数生成的effect对象,可以让我们停止这个effect。

这个构建的ref对象和我们之前通过ref构建的ref对象基本很像,但是在getter中出现了一个判断。判断了dirty的值来执行runner也就是effect。

那这个dirty是干什么用的呢?我们可以用开头举的例子。

1
2
3
4
5
6
7
8
9
10
11
const {reactive, effect, computed} = VueReactivity;
const o = {
name: "lwf"
};

const r = reactive(o);
const c = computed(() => r.name + " --- computed");

effect(() => {
console.log(r.name);
});

当我们打印c.value时,这是触发了value的getter,也就是到了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export function computed<T>(
options: WritableComputedOptions<T>
): WritableComputedRef<T>
export function computed<T>(
getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>
) {
// ...
computed = {
// ...
get value() {
// 此时运行到这里
if (dirty) {
value = runner()
dirty = false
}
track(computed, TrackOpTypes.GET, 'value')
return value
},
// ...
} as any
// ...
}

接下来我们判断了dirtydirty初始化是true的,所以会执行if块。

这时候执行了runner,也就是响应式对象r会收集到传入computed的函数。

runner的返回值也就是传入函数的返回值,赋给了value变量,然后置dirty变量为false,接着便是收集依赖,返回value变量了。

到这,我们就获得了computed对象的value属性了

如果现在我们再一次的运行c.value,依然会调用这个属性的getter,但是由于dirty变量为false,直接返回了value对象。

聪明的人可能明白了,dirty是用来判断依赖到的响应式对象是否发生改变的。

没改变的话,直接返回缓存的值,也就避免了多次的计算。

ok,这时,我执行了r.name = &#39;index&#39;,也就是会触发响应式对象r的所有依赖effect执行。

这时,响应式对象r的依赖中是有我们传入computed的函数所构成的effect的。

trigger函数中,有一个run函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export function trigger(
// ...
) {
// ...

const run = (effect: ReactiveEffect) => {
// ...
// 如果scheduler有值,就执行scheduler,否则就执行effect。
if (effect.options.scheduler) {
effect.options.scheduler(effect)
} else {
effect()
}
}

// Important: computed effects must be run first so that computed getters
// can be invalidated before any normal effects that depend on them are run.
// 重点:计算effect需要提前运行。这样可以使得计算effect的getter的值在任何一般的effect运行之前失效
computedRunners.forEach(run)
effects.forEach(run)
}

这个run函数就是执行我们的effect的,其中判断了effect.options.scheduler,为真就传入effect对象并执行,也就是effect.options.scheduler(effect),为假就直接执行effect对象。

还记得我们之前通过传入函数生成一个计算effect时传入的额外参数吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export function computed<T>(
getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>
) {
const runner = effect(getter, {
// ...
scheduler: () => {
//这里就是当计算effect依赖的响应式对象发生改变时,会执行的代码段。
if (!dirty) {
dirty = true
trigger(computed, TriggerOpTypes.SET, 'value')
}
}
})
}

这里判断了dirty变量,如果为false,就置为true并触发这个计算ref的依赖,dirty为假的意思就是此时计算ref所依赖的响应式对象已经发生改变了。

前面我们已经打印了c.value的值了,这时的dirty就是false了,进入了if的代码块。置为dirtytrue,触发了计算ref的依赖。

如果一直没有取value属性的值,那么就没有必要去触发计算ref的依赖。在第一次获取计算ref的值(.value)之前,它的值都是不确定的(也不能说不确定,就是会根据它所依赖响应式对象的最新值来进行计算并返回)。

这时我们再次打印c.value,由于响应式对象r的改变使得dirty变为了true,就又要再执行一次runner来获取最新的值。

最后,为什么要先执行响应式对象的计算effect呢?

很简单,我们用下面的代码来理解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const {reactive, effect, computed} = VueReactivity;

const o = {
name: "lwf"
};

const r = reactive(o);

const c = computed(() => r.name + " --- computed");

effect(() => {
console.log(r.name);
console.log(c.value);
});

这时运行r.name = &#39;index&#39;的话,这时会打印两次effect

1
2
3
4
5
6
// r对象改变触发了effect
'index'
'index --- computed'
// c对象改变触发了effect
'index'
'index --- computed'

如果你没有先执行计算effect的话,此时对于该计算ref的dirty还是false,也就是没有通知到计算ref对象此时依赖的响应式对象发生了变化,使用到了旧的值。也就是打印了

1
2
3
4
5
6
7
// r对象改变触发了effect
'index'
// c的getter中dirty还是为false,使用到了旧的值。
'lwf --- computed'
// c对象改变触发了effect
'index'
'index --- computed'

至此,computedAPI的基本流程基本上就讲完了。不得不说实现上确实很巧妙,用到了很多的闭包。

后记

还差最后一个readonlyAPI了,这个和reactive差不多,这几天应该就可以写出来了,如果我写的有错,希望可以指出,非常感谢!~