无论是面试过程还是日常业务开发,相信大多数前端开发者对于 Vue 的应用已经熟能生巧了。
今天我们就来聊聊 Vue 中的 Computed 是如何被实现的。
文章会告别枯燥的源码,从用法到原理层层拨丝与你一起来看看在 Vue 中 Computed 是如何被实现的。
首先,文章中的源码思路是基于最新稳定的 Vue@3.2.37 版本进行解读的。
其次,Computed 相关原理需要一些 Effect 相关的原理。如果你不是很清楚 Effect 是什么,推荐你优先阅读我的这篇 Vue3中的响应式是如何被JavaScript实现的。
当然,在文章中也会针对于一些额外的知识点稍微进行基础的讲解。
首先,我们先来聊聊 computed 的一些用法特性。
关于 Computed 大家都了解 Computed 是作为懒计算的,比如:
<script setup lang="ts">
import { computed, reactive } from "vue";
const firstName = reactive({ name: "wang" });
const lastName = reactive({ name: "haoyu" });
const fullName = computed(() => {
console.log("generator fullname.");
return firstName.name + "." + lastName.name;
});
</script>
<template>
<p>Hello</p>
<p>My Name is wang.haoyu</p>
</template>
上述的一段代码,在我们打开页面时虽然我们定义了名为 fullName 的 computed 计算属性。
但是由于我们并没有在模板或者逻辑中使用它,所以它是不会进行任何计算的。换言之,fullName 中的 console.log('generator fullname')
是不会被执行的。
当然,如果我们使用到了定义的 computed 比如:
<template>
<p>Hello</p>
<p>My Name is {{ fullName }}</p>
</template>
此时打开页面后会计算依赖属性,浏览器会输出:
App.vue:8 generator fullname.
同时,Computed 最大的特点还是它具有缓存性质。
对于依赖的值如果未发生变化,那么 Computed 是不会重新进行计算的。比如:
<script setup lang="ts">
import { computed, reactive } from "vue";
const firstName = reactive({ name: "wang" });
const lastName = reactive({ name: "haoyu" });
const fullName = computed(() => {
console.log("generator fullname.");
return firstName.name + "." + lastName.name;
});
</script>
<template>
<p>Hello</p>
<p>My Name is {{ fullName }}</p>
<p>My Name is {{ fullName }}</p>
<p>My Name is {{ fullName }}</p>
</template>
上述的代码中,即使我在模板中调用多次 fullName ,fullName 中的计算逻辑也仅仅只会执行一次。
也就是浏览器仅会打印一次 generator fullname.
。
只有当计算属性(fullName)中依赖的响应式数据 发生改变时,计算属性才会重新执行从而计算出最新的值。
大多数小伙伴利用 Computed 时,无非是使用了它的计算以及缓存两个特点。
因为 Computed 的原理导致,其实它是可以缓存任意值得,比如说你可以返回一个函数:
<script setup lang="ts">
import { computed, ref } from "vue";
const nickName = ref("19Qingfeng");
const computedName = computed(() => {
return (first: string, last: string) => {
return first + "." + last + "-" + "nickName" + nickName.value;
};
});
</script>
<template>
<p>Hello</p>
<p>My Name is {{ computedName("wang", "haoyu") }}</p>
</template>
此时,页面初始化时会正常渲染 My Name is wang.haoyu-nickNamewang
,理论上你可以在 computed 中返回一切值它都会帮你进行缓存。
当然有以下两种情况需要大家额外留意:
// 情况1
const computedName = computed(() => {
return (first: string, last: string) => {
return first + "." + last + "-" + "nickName" + nickName.value;
};
});
// 情况下2
const computedName = computed(() => {
const _nickName = nickName.value
return (first: string, last: string) => {
return first + "." + last + "-" + "nickName" + _nickName;
};
});
虽然说情况1和情况2在页面上展示的效果是完全一致的,但是他们内部的原理是完全不同的。
在视图中依赖了 computedName 变量时:
简单来说,当 nickName 发生变化时,情况2会导致 computed 重新计算而情况1则不会。
上述提到的 Effect 意味副作用,简单来说在 Vue 中所有的响应式数据变化都会导致 Effect 执行。而 Effect 执行后会导致页面进行重新渲染。
如果有兴趣了解 Vue 中的响应式和 Effect 的同学可以移步这片文章。
上述,我们提到的 Computed 基本上都是基于值的获取不涉及为 computed 重新赋值。
其实关于 computed 的设置我相信大家也都是耳熟能详了,我们在之前的写法:
const fullName = computed(() => {
return firstName.name + "." + lastName.name;
});
// 相当于
const fullName = computed({
get() {
return firstName.name + "." + lastName.name;
},
set() {},
});
当然 computed 也支持通过 value 属性结合 setter 来重新设置值,比如:
<script setup lang="ts">
import { computed, ref, reactive } from "vue";
const firstName = reactive({ name: "wang" });
const lastName = reactive({ name: "haoyu" });
let nickName = ref("");
// 相当于
const fullName = computed({
get() {
return firstName.name + "." + lastName.name;
},
set(value: string) {
nickName.value = value;
},
});
// 为 computed 重新赋值,进入 setter
fullName.value = "19Qingfeng";
</script>
<template>
<p>Hello</p>
<p>My Name is {{ fullName }}</p>
<p>{{ nickName }}</p>
</template>
get/set 的用法我就不过于累赘了,这次基础用法不是特别熟悉 Vue 的小伙伴可以翻阅下官方文档。
上边我们大概聊了聊 Computed 用法上的一些特点,这里我们简单归纳一下。
Vue 中实现的 computed 需要缓存、懒计算、以及它本身收集它内部依赖的响应式数据,当响应式发生改变时 computed 会重新计算当前内部缓存的值从而更新缓存值。
了解了这些基础特点后,我们在控制台来打印一下 computed 来看看它是什么东西:
// ... 省略上文中的代码
console.log(fullName)
我们可以清楚的看到,所谓的 computed 对象是一个类的实例对象。
当然,稍后我们会详细来实现一下它。我们先来看看所谓实例上的一些属性代表的含义:
dep
上边我们提到过,一个 computed 本质上需要进行依赖收集。也就说当 computed 发生变化时(重新计算),需要通知模板上依赖该 Computed 的对象进行重新渲染。 所以这里的 dep
正是存储哪些 Effect 依赖了该 computed。effect
同时我们说到过,除了 computed 发生改变时依赖的 computed 页面需要重新渲染,另一个有一个重要的点:计算属性中依赖的响应式数据发生改变时,该 computed 就会进行重新计算。 所以不难想到,简单来说一个 computed 也是一个 effect ,它同样对于它内部使用到的响应式数据进行依赖收集。_value
上边我们提到了,computed
是具有缓存的特点的。那么每次变化后计算的值一定是需要存储的,这里的 _value 就是 computed 存储缓存值的地方。_dirty
正如它的名字那般,这个属性代表的意思是脏的。当我们每次访问 computed 时,正是通过 _dirty 来判断本次 computed 是否需要重新计算,如果不需要则直接返回 _value 属性即可。__v_isReadonly
这个属性表示该 computed 是否可写,通常情况下如果一个 computed 仅具有 getter 函数(或者仅传入一个函数时)那么它既是仅具有 getter 。那么,此时该 computed 是不可写的因为它并不具有 setter 。反之,get/set 都具有时该属性为 false。上述的属性就是一个 Computed 中我们需要关心的属性,大概了解了各个属性代表的含义接下来就让我们一起来看看 computed 是如何被 Vue 实现的。
首先,我们来看看源代码中的 computed 函数:
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 传入
getter = getterOrOptions
// 同时 setter 会默认在开发环境下赋为一个禁止修改的 log 函数
setter = __DEV__
? () => {
console.warn('Write operation failed: computed value is readonly')
}
: NOOP
} else {
// 其他情况就直接将传入的 getter 和 setting 进行赋值就可以了
getter = getterOrOptions.get
setter = getterOrOptions.set
}
// 通过传入的 getter 和 setter 进行初始化
const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter, isSSR)
if (__DEV__ && debugOptions && !isSSR) {
cRef.effect.onTrack = debugOptions.onTrack
cRef.effect.onTrigger = debugOptions.onTrigger
}
return cRef as any
}
我们可以看到,computed 函数将传入的参数进行格式化后本质上是调用了ComputedRefImpl这个类进行实例 computed 实例对象。
接下来我们重点来看看所谓的 computedRefImpl
的实现:
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
public _dirty = true
public _cacheable: boolean
constructor(
getter: ComputedGetter<T>,
private readonly _setter: ComputedSetter<T>,
isReadonly: boolean,
isSSR: boolean
) {
this.effect = new ReactiveEffect(getter, () => {
if (!this._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)
trackRefValue(self)
if (self._dirty || !self._cacheable) {
self._dirty = false
self._value = self.effect.run()!
}
return self._value
}
set value(newValue: T) {
this._setter(newValue)
}
}
上述是 ComputedRefImpl 的所有源代码,所谓的 computed 对象本质上就是 ComputedRefImpl 的实例对象。
首先,我们可以看到 ComputedRefImpl 上拥有 get value 和 set value 两个属性。
说一点题外话,关于 class 上的 get/set(访问器属性) 在编译后是会添加到类的原型上而非作为实例属性。具体你可以查看这里。
同时,我们也提到过所谓的 computed 自身就是一个 Effect,对应 ComputedRefImpl 中的构造函数在初始化时候会为实例上挂载一个:
this.effect = new ReactiveEffect(getter, () => {
if (!this._dirty) {
this._dirty = true
// triggerRefValue 你可以先忽略这个逻辑,避免混淆我们稍后再来了解它
triggerRefValue(this)
}
})
scheduler
,不传入第二个参数时当当前 Effect 依赖的响应式数据发生变化后 Effect 中传入的第一个函数会立即执行。当传入第二个函数时,当第一个参数中依赖的响应式数据变化并不会执行传入的 getter 而是回执行对应的第二个参数 scheduler。_dirty
的值将它变为脏的也就是 true
**。Effect 我已经在前置文章 Vue3中的响应式是如何被JavaScript实现的 中介绍过它的实现,有兴趣深入了解的同学可以移步查阅。
同理,当我们首次访问该计算属性时。不难想到需要计算当前计算属性中的值:
get value() {
const self = toRaw(this)
trackRefValue(self)
// 上述的代码你可以暂时忽略
if (self._dirty || !self._cacheable) {
self._dirty = false
self._value = self.effect.run()!
}
return self._value
}
getter 中做的事情也非常简单,当 this._dirty
为 false 时。访问 computed 的 value 时,会调用self.effect.run()
会执行当前 Effect 中的传入的函数(Effect 中第一个参数)。
同时获取返回值保存进入 self._value
中,也就是说当我们访问 computed 的 value 时,会触发 getter 中的逻辑。
getter 中首先会根据 self._dirty
判断当前 computed 是否需要重新计算,这一层起到了缓存的作用。
之后,如果 self._dirty
为 true 时,会直接返回 self._value
。反之,如果为 false 则会重新调用 self.effct.run()
重新计算 self._value
的值同时对于该 computed 依赖的数据重新进行依赖收集。
当前,setter 的实现就更加简单了:
set value(newValue: T) {
this._setter(newValue)
}
_setter
是我们传入 computed 的 setter
,当调用 setter 时仅仅是执行传入的 setter 即可。
这一步,我们实现了computed 中的缓存以及 computed 中依赖的响应式数据发生改变时 Effect 会重新计算 computed 的值。
接下来还遗留一些反向的逻辑,computed 中的数据发生变化时 computed 不仅会重新计算同时也要通知依赖于该 computed 的 Effct 会重新执行从而造成页面渲染之类的数据响应式效果。
其实依赖下的功能,简单来讲也就是说所谓的 computed 计算属性不仅仅拥有收集自身依赖的数据的特点。同时也需要收集依赖于它的 Effect 的相关功能。
当 computed 发生变化时,同样需要通知依赖于它的 Effect 重新执行。从而导致页面重新渲染。
我们围绕上述的功能来分析源代码中是如何实现的:
首先在 getter 中我们遗失的逻辑:
// #3376 在 Vue 3.0.7 前在 readonly() 中包装 computed() 会破坏计算的功能,这里是为了解决在 readonly 包裹 computed 时保留计算属性的特殊处理。
const self = toRaw(this)
// 在访问getter时进行依赖收集
trackRefValue(self)
if (self._dirty || !self._cacheable) {
self._dirty = false
self._value = self.effect.run()!
}
return self._value
getter 中的重点在于 trackRefValue(self)
中,简单来说在每次获取 computed 的 value 值时,首先会进行 trackRefValue(self)
的调用。
会将当前正在运行的 Effect 关联到 computed 中的 dep
属性上(依赖收集),所谓正在运行的 Effect 指的是比如当前某个组件的模板中依赖了某个 computed 。
我们清楚所有的组件最终会被编译为 render 函数进行渲染,同样所有的组件最外层都会被 Effect 包裹处理。换句话说,当前组件渲染时,全局正在运行的 Effect 即是当前组件的渲染 Effect (被称为 activeEffect)。
所以,每次访问 computed 时会收集当前 activeEffect 将它保存进入当前 computed 中的 dep(Set) 中。
我们来看看所谓的 trackRefValue
(packages/reactivity/src/ref.ts
) 逻辑:
export function trackRefValue(ref: RefBase<any>) {
// 当前 activeEffect 存在,并且允许收集
if (shouldTrack && activeEffect) {
// 将传入的 ref ,也就是 computed 对象转为原始对象
ref = toRaw(ref)
if (__DEV__) {
trackEffects(ref.dep || (ref.dep = createDep()), {
target: ref,
type: TrackOpTypes.GET,
key: 'value'
})
} else {
// createDep 会创建一个 Set
// trackEffects 的作用即是将当前 activeEffect 加入到 ref.dep 中
trackEffects(ref.dep || (ref.dep = createDep()))
}
}
}
// packages/reactivity/src/effect.ts
export function trackEffects(
dep: Dep,
debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
let shouldTrack = false
if (effectTrackDepth <= maxMarkerBits) {
if (!newTracked(dep)) {
dep.n |= trackOpBit // set newly tracked
shouldTrack = !wasTracked(dep)
}
} else {
// Full cleanup mode.
shouldTrack = !dep.has(activeEffect!)
}
if (shouldTrack) {
// 重点是这里
dep.add(activeEffect!)
activeEffect!.deps.push(dep)
if (__DEV__ && activeEffect!.onTrack) {
activeEffect!.onTrack({
effect: activeEffect!,
...debuggerEventExtraInfo!
})
}
}
}
trackEffects
方法,你只需要重点关注 dep.add(activeEffect!)
即可。本质上还是我们刚才提到的,当我们访问 computed 的值时,会依次调用 getter
=> trackRefValue
=> trackEffects
。
最终将当前 activeEffect 添加进入当前 computed 实例中的 dep 中。
上述过程中,在 computed 中我们完成了依赖收集的过程,会将使用到 computed 的相关 Effect 添加进入当前 computed 的 dep 属性中。
之后,每当 computed 中依赖的响应式数据发生变化时。我们在之前提过到每当 computed 中依赖的数据发生变化时会执行自身 Effect 中的 scheduler
:
// ...
constructor(
getter: ComputedGetter<T>,
private readonly _setter: ComputedSetter<T>,
isReadonly: boolean,
isSSR: boolean
) {
this.effect = new ReactiveEffect(getter, () => {
if (!this._dirty) {
this._dirty = true
// triggerRefValue 派发更新
triggerRefValue(this)
}
})
this.effect.computed = this
this.effect.active = this._cacheable = !isSSR
this[ReactiveFlags.IS_READONLY] = isReadonly
}
当 computed 中依赖的数据发生变化时,如果 this._dirty
为 false 会调用 triggerRefValue
进行派发更新。
// packages/reactivity/src/ref.ts
export function trackRefValue(ref: RefBase<any>) {
if (shouldTrack && activeEffect) {
ref = toRaw(ref)
if (__DEV__) {
trackEffects(ref.dep || (ref.dep = createDep()), {
target: ref,
type: TrackOpTypes.GET,
key: 'value'
})
} else {
trackEffects(ref.dep || (ref.dep = createDep()))
}
}
}
trackRefValue 中逻辑处理同时也非常简单,它会首先将当前传入的 value 转化为原始的 object。之后会调用 trackEffects(ref.dep)
去依次触发当前 computed 中的 Effects(ref.dep
)。
export function triggerEffects(
dep: Dep | ReactiveEffect[],
debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
// spread into array for stabilization
const effects = isArray(dep) ? dep : [...dep]
for (const effect of effects) {
if (effect.computed) {
// computed 进入以下逻辑
triggerEffect(effect, debuggerEventExtraInfo)
}
}
for (const effect of effects) {
if (!effect.computed) {
triggerEffect(effect, debuggerEventExtraInfo)
}
}
}
function triggerEffect(
effect: ReactiveEffect,
debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
if (effect !== activeEffect || effect.allowRecurse) {
if (__DEV__ && effect.onTrigger) {
effect.onTrigger(extend({ effect }, debuggerEventExtraInfo))
}
if (effect.scheduler) {
effect.scheduler()
} else {
effect.run()
}
}
}
上述的逻辑其实我相信对于大家来说都是小菜一碟了,其实就是遍历 deps 中的各个 Effect 进行依次调用即可。
这样,也就达到了我们刚才的需求:当 computed 中依赖的数据发生改变时会触发自身的 Effect 执行,在自身 Effect 中的处理函数同时会通知依赖于当前 computed 的 Effect 依次执行(达成重新渲染视图等效果)从而实现响应式特性。
可以看到 computed 的实现还是非常简单的,我们稍微来总结下这个过程。
所谓的计算属性 computed 本身就是一个 Effect,默认情况下 computed 是不会进行计算的。
当我们使用了该 computed 时,访问 computed 的 getter 属性。会发生:
this.effect.run()
执行当前 computed 的 getter 方法,获得返回值保存进入 this._value
记录。this._dirty
重置为 false,利用 _dirty
和 _value
实现缓存的特性。trackRefValue
收集当前 activeEffect ,将当前活跃的 Effect 存储到 computed 的 dep 属性中,进行依赖收集。之后,如果 computed 依赖的响应式数据发生改变,会发生:
简单来说,所谓 computed 的核心实现思路就是如此。
当前,如果对某个细节不是特别清楚的小伙伴可以在评论区留下你的问题,或者自行查阅源代码。
比起Vue2,Vue3 的源码其实显得非常见解和易读。
有兴趣的小伙伴可以关注我的专栏,之后会为大家带来更多关于](https://juejin.cn/column/7048582588327264286%29%EF%BC%8C%E4%B9%8B%E5%90%8E%E4%BC%9A%E4%B8%BA%E5%A4%A7%E5%AE%B6%E5%B8%A6%E6%9D%A5%E6%9B%B4%E5%A4%9A%E5%85%B3%E4%BA%8E) Vue
的见解。
大家加油!