前言
Vue3探秘系列文章链接:
不止响应式:Vue3探秘系列— 虚拟结点vnode的页面挂载之旅(一)
不止响应式:Vue3探秘系列— diff算法的完整过程(三)
计算属性:Vue3探秘系列— computed的实现原理(六)
Hello~大家好。我是秋天的一阵风
在上一课中,我们探讨了响应式机制的基本原理。现在,我们将转向一个在 Vue.js 开发中极为常见的响应式 API —— 计算属性。
计算属性 computed
是一种强大的工具,它允许开发者定义一个计算方法,该方法可以根据依赖的响应式数据计算出新的值并返回。当依赖的数据发生变化时,计算属性会自动重新计算结果,从而简化了数据处理的流程。
既然计算属性本质上是对依赖数据的计算,那么为什么不直接使用普通的函数呢?
Vue.js 3.0
中的计算属性 API
是如何实现的?接下来,让我们一起探索这些问题的答案,并深入了解计算属性的实现原理。
一、使用例子
const count = ref(1);
const plusOne = computed(() => count.value + 1);
console.log(plusOne.value); // 2
count.value++;
console.log(plusOne.value); // 3
除了给 computed
传入一个回调函数以外,你还可以传入一个包含get、set函数
的对象,如下面的例子:
const count = ref(1);
const plusOne = computed({
get: () => count.value + 1,
set: (val) => {
count.value = val - 1;
},
});
plusOne.value = 1;
console.log(count.value); // 0
在这个例子中,我们可以看到,computed
函数接收了一个包含 getter
和 setter
函数的对象。
getter
函数与之前相同,返回 count.value + 1
。
值得注意的是 setter
函数:当我们修改 plusOne.value
的值时,就会触发 setter
函数。
实际上,setter
函数内部会根据传入的参数来修改计算属性所依赖的值 count.value
。
一旦依赖的值发生变化,再次获取计算属性时就会重新执行 getter
函数,因此获取到的值也会随之改变。
二、 源码实现
function computed(getterOrOptions) {
// getter 函数
let getter;
// setter 函数
let setter;
// 标准化参数
if (isFunction(getterOrOptions)) {
// 表面传入的是 getter 函数,不能修改计算属性的值
getter = getterOrOptions;
setter =
process.env.NODE_ENV !== "production"
? () => {
console.warn("Write operation failed: computed value is readonly");
}
: NOOP;
} else {
getter = getterOrOptions.get;
setter = getterOrOptions.set;
}
// 数据是否脏的
let dirty = true;
// 计算结果
let value;
let computed;
// 创建副作用函数
const runner = effect(getter, {
// 延时执行
lazy: true,
// 标记这是一个 computed effect 用于在 trigger 阶段的优先级排序
computed: true,
// 调度执行的实现
scheduler: () => {
if (!dirty) {
dirty = true;
// 派发通知,通知运行访问该计算属性的 activeEffect
trigger(computed, "set" /* SET */, "value");
}
},
});
// 创建 computed 对象
computed = {
__v_isRef: true,
// 暴露 effect 对象以便计算属性可以停止计算
effect: runner,
get value() {
// 计算属性的 getter
if (dirty) {
// 只有数据为脏的时候才会重新计算
value = runner();
dirty = false;
}
// 依赖收集,收集运行访问该计算属性的 activeEffect
track(computed, "get" /* GET */, "value");
return value;
},
set value(newValue) {
// 计算属性的 setter
setter(newValue);
},
};
return computed;
}
1.标准化参数
// getter 函数
let getter;
// setter 函数
let setter;
// 标准化参数
if (isFunction(getterOrOptions)) {
// 表面传入的是 getter 函数,不能修改计算属性的值
getter = getterOrOptions;
setter = process.env.NODE_ENV !== "production"
? () => { console.warn("Write operation failed: computed value is readonly"); }
: NOOP; }
else {
getter = getterOrOptions.get;
setter = getterOrOptions.set;
}
-
首先声明了两个变量
getter
和setter
-
onlyGetter
变量用于判断getterOrOptions
是否仅仅包含getter函数。这是通过调用isFunction
函数实现的。 -
如果
onlyGetter
为真,即getterOrOptions
仅包含一个函数,那么将这个函数赋值给getter
,同时根据开发模式设置setter
。在开发模式下,setter
会输出一个警告信息,提示“写操作失败:计算属性值是只读的”; -
而在生产模式下,
setter
则被设置为NOOP
,即不执行任何操作。 -
如果
onlyGetter
为false
,即getterOrOptions
包含get
和set
属性,那么分别将getterOrOptions
的get
和set
属性赋值给getter
和setter
。
2.创建副作用函数
// 创建副作用函数
const runner = effect(getter, {
// 延时执行
lazy: true,
// 标记这是一个 computed effect 用于在 trigger 阶段的优先级排序
computed: true,
// 调度执行的实现
scheduler: () => {
if (!dirty) {
dirty = true;
// 派发通知,通知运行访问该计算属性的 activeEffect
trigger(computed, "set" /* SET */, "value");
}
},
});
接着是创建副作用函数 runner
。computed
内部通过 effect
创建了一个副作用函数,它是对 getter
函数做的一层封装,另外我们这里要注意第二个参数,也就是 effect
函数的配置对象。
其中 lazy
为 true
表示 effect
函数返回的 runner
并不会立即执行;
computed
为 true
用于表示这是一个 computed effect
,用于 trigger
阶段的优先级排序,scheduler
表示它的调度运行的方式.
3.创建 computed 对象
// 创建 computed 对象
computed = {
__v_isRef: true,
// 暴露 effect 对象以便计算属性可以停止计算
effect: runner,
get value() {
// 计算属性的 getter
if (dirty) {
// 只有数据为脏的时候才会重新计算
value = runner();
dirty = false;
}
// 依赖收集,收集运行访问该计算属性的 activeEffect
track(computed, "get" /* GET */, "value");
return value;
},
set value(newValue) {
// 计算属性的 setter
setter(newValue);
},
};
最后是创建 computed
对象并返回,这个对象也拥有 getter
和 setter
函数。
当 computed
对象被访问的时候会触发 getter,然后会判断是否 dirty
,如果是就执行 runner
,然后做依赖收集;当我们直接设置 computed
对象时会触发 setter
,即执行 computed
函数内部定义的 setter
函数。
以下是computed的执行流程图:
三、computed的两个特性
computed
对象具有两个特性,一个是延迟执行,一个是缓存。我们利用一个简单的例子来探究这两个特性。
<template>
<div>
{{ plusOne }}
</div>
<button @click="plus">plus</button>
</template>
<script>
import { ref, computed } from 'vue'
export default {
setup() {
const count = ref(0)
const plusOne = computed(() => {
return count.value + 1
})
function plus(){
count.value++
}
return {
plusOne,
plus
}
}
}
</script>
1. lazy 延迟执行
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')
}
}
})
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
}
-
computed
中 将 getter函数(() => { return count.value + 1 }
)和一个配置对象options
传入内部的effec
t 函数生成一个副作用函数, 注意这个配置对象中的lazy
为true
,表示是懒执行的 -
effect
函数中又创建一个effect
函数,为了区分,我们把它叫reactiveEffect函数
。 接着会判断传入的选项的lazy
是否为true
-
如果是
false
,则会马上执行这个reactiveEffect函数
-
如果是
true
, 则不会执行,而是将它return
出去。 这个reactiveEffect函数
对于computed
来说就是传入的getter函数
-
回到
computed对象
,将return
出来的reactiveEffect函数
赋值给了runner函数
-
访问
plusOne
变量就是在访问computed
对象的value
属性。 -
只有在访问computed对象value属性时,才会执行runner函数进行求值 。
2. 缓存值
在文章开头的时候我们提出过一个问题:既然计算属性本质上是对依赖数据的计算,那么为什么不直接使用普通的函数呢?
computed
与普通函数的最大区别就是具有缓存功能,它的内部会缓存上次的计算结果,只要计算结果不变,就不会重新执行函数进行计算。我们来梳理一下缓存的实现流程
computed = {
__v_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
-
我们知道,访问
plusOne
变量其实就是在访问computed
对象的value
属性 -
在
value
函数中,首先会判断dirty
是否为true
-
在第一次访问
plusOne变量
时,dirty
默认为true
, 所以会通过执行runner函数
进行求值, 然后会马上将dirty
修改为false
-
在
plusOne
进行依赖收集后,将这个value
返回 -
在第二次访问
plusOne
时,此时dirty
为false
,并不会再次执行runner
函数进行求值,将上次的缓存值返回
那么问题来了?从第二次开始都是dirty
为false
,返回的是缓存值,什么时候dirty
才会变回true
进行重新计算呢? 答案是在count ++
的时候
- 执行 plus函数 时,
count ++
,这时候count
的trigger函数
执行,并通知依赖进行更新,count
的依赖就是plusOne
的runner函数
。
// core/packages/reactivity/src/computed.ts
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')
}
}
})
// core/packages/reactivity/src/effect.ts
const run = (effect) => {
// 调度执行
if (effect.options.scheduler) {
effect.options.scheduler(effect)
}
else {
// 直接运行
effect()
}
}
- 当
trigger函数
执行时,会将count
的依赖都取出来,循环调用run
方法 - 在
run
方法中,由于computed
创建时传入scheduler
属性,所以会调用scheduler
方法。
请注意:这里对于computed的情况并不会直接执行,而是执行scheduler函数,这就相等于将控制权交出去
scheduler
在执行时发现dirty
为false
,就会将他赋值为true
。这样在下一次获取时就会重新执行runner函数
进行求值
四、计算属性的执行顺序
我们先来回顾一下runner函数
,在通过effect函数
创建副作用函数的时候,options选项
里还有一个computed:true
的配置。
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')
}
}
})
这个配置决定了 trigger函数
执行effect
的顺序,我们看看trigger函数
内执行effects
的过程:
// core/packages/reactivity/src/effect.ts
const add = (effectsToAdd) => {
if (effectsToAdd) {
effectsToAdd.forEach(effect => {
if (effect !== activeEffect || !shouldTrack) {
if (effect.options.computed) {
computedRunners.add(effect)
}
else {
effects.add(effect)
}
}
})
}
}
const run = (effect) => {
if (effect.options.scheduler) {
effect.options.scheduler(effect)
}
else {
effect()
}
}
computedRunners.forEach(run)
effects.forEach(run)
在添加待运行的 effects
的时候,我们会判断每一个 effect
是不是一个 computed effect
,如果是的话会添加到 computedRunners
中,在后面运行的时候会优先执行 computedRunners
,然后再执行普通的 effects
。
问题又来了,为什么computed effect
需要优先执行呢?
我们先来看一道输出题:请问下面的代码会打印几次,分别打印什么呢?
import { ref, computed } from 'vue'
import { effect } from '@vue/reactivity'
const count = ref(0)
const plusOne = computed(() => {
return count.value + 1
})
effect(() => {
console.log(plusOne.value + count.value)
})
function plus() {
count.value++
}
plus()
//1
//3
//3
-
首先 effect 函数会先执行,此时运行
console.log(plusOne.value + count.value)
,count.value
是 0,plusOne.value
是 1。 所以第一次输出 1。 -
然后执行
plus函数
,count.value ++
,count.value
变为1。同时count
的trigger函数
触发,通知所有count
的依赖也就是副作用函数进行执行。 -
count
有两个依赖,一个是plusOne
的runner函数
,一个是effect 函数
。
注意:
computed effect
会先执行,也就是plusOne
的runner函数
会先执行- 由于在
effect函数
中访问了plusOne
,plusOne
的依赖也有这个effect
函数
-
plusOne
的runner函数
先执行,dirty
会设置为true
,然后通知plusOne
的依赖effect
执行,也就是plusOne.value + count.value
,因为dirty已经被改为true
了,获取plusOne
的时候,会重新求值,plusOne
就是2,所以第二次打印是3 -
执行完
plusOne
的runner
以及依赖更新之后,再去执行count
的普通effect
依赖。effect
函数再次执行,但是由于此时pluseOne
的dirty
为false
,所以拿的是缓存值2。count.value
为1 ,plusOne.value
为2,所以这时会打印第三次,打印3
知道了具体执行流程后,我们再回过头看例子就清楚了,让computed effect
优先执行主要是为了处理特殊场景。因为 effect 函数
依赖了 plusOne
和 count
,所以 plusOne
先计算会更合理。
那问题又来了,如果想要输出以下值,应该怎么做呢?
1
2
3
其实很简单,我们只需要把 computed runner
和 effect
的执行顺序换一下,让effect
先执行就可以了。
如果将这两个执行顺序交换后,我们重新来走一遍流程:
-
首先 effect 函数会先执行,此时运行
console.log(plusOne.value + count.value)
,count.value
是 0,plusOne.value
是 1。 所以第一次输出 1。 -
然后执行plus函数,
count.value ++
,count.value
变为1.同时count
的trigge
r函数触发,通知所有count
的依赖也就是副作用函数执行。 -
这一次
effec
t 先执行plusOne.value + count.value
,那么就会访问plusOne.value
,但由于plusOne
的runner
还没执行,所以此时dirty
为false
,得到的值还是上一次的计算结果 1。 所以 1 + 1 = 2,第二次打印2 -
接着再执行
plusOne
的runner
函数,把plusOne
的dirty
设置为true
,然后通知它的依赖 effect 执行plusOne.value + count.value
。 -
这个时候由于
dirty
为true
,就会再次执行plusOne
的getter
计算新值,拿到了 2,然后再加上 1 就得到 3。所以第三次打印3
总结
好了,到这次我们就学习完了computed
的实现原理机制,我们知道了计算属性有两大特点:延时计算和缓存。还知道了在effects
执行时会优先执行computed effect
,这是为了处理特殊场景而进行的处理。下一篇我们继续探究 侦听属性 watch
的源码实现,谢谢你的阅读~