前言
Vue 3.4 更新优化了响应式系统,解决了所依赖的响应式变量改变时,即使计算变量的变量值没有改变,也会触发后续的副作用/依赖(下称 effect)的问题,例如页面多次渲染、watch
被多次触发、其他计算变量重新计算等等。
effect 回顾
ref
、reactive
、computed
等 API 可以创建响应式变量,当响应式变量被 effect 触发get
方法时,effect 就会被响应式变量记录,例如记录在refImpl
和computedImpl
的dep
属性。同时 effect 也会在自身的deps
中记录响应式变量的dep
。
像reactive
的子属性、ref
的.value
及其子属性被修改,触发set
方法,effect 被触发,trigger
和schedule
方法会被调用,引起后续的变化。
以模板渲染为例,画个图不太严谨地描述一下:
sequenceDiagram
effect->>ref: ref.value
Note left of effect: 假设在模板中使用了 ref.value
Note right of ref: 触发 get value()
ref->>effect: trace 收集依赖
ref->>effect: trigger 触发依赖
Note right of ref: ref.value 被修改,触发 set value()
Note left of effect: 重新渲染页面
另外,计算变量所依赖的响应式变量发生修改时,它也会触发它所收集的 effect,同时触发依赖它的 effect。被触发get value
时,如果计算变量依赖的响应式变量有改变,则对自身的值进行重新计算。
这里说的 effect,常见的一般是由computed
(ComputedEffect)、watch
或者watchEffect
(WatchEffect)等 API 创建和创建虚拟 DOM 时创建的(RenderEffect)。它的定义如下:
export class ReactiveEffect<T = any> {
// ...
active = true
deps: Dep[] = []
this._dirtyLevel = DirtyLevels.Dirty
// 如果是 computed 创建的,指向对应的 computed 变量
computed?: ComputedRefImpl<T>
constructor(
public fn: () => T,
public trigger: () => void,
public scheduler?: EffectScheduler,
scope?: EffectScope,
) {
recordEffectScope(this, scope)
}
// ...
run() {
this._dirtyLevel = DirtyLevels.NotDirty
if (!this.active) {
return this.fn()
}
let lastShouldTrack = shouldTrack
let lastEffect = activeEffect
try {
shouldTrack = true
activeEffect = this
this._runnings++
preCleanupEffect(this)
return this.fn()
} finally {
postCleanupEffect(this)
this._runnings--
activeEffect = lastEffect
shouldTrack = lastShouldTrack
}
}
// ...
}
我们可以注意到,_dirtyLevel
,是用来标记 effect 是不是脏的,换句话说,就是有没有被响应式变量触发。当被响应式变量触发时,_dirtyLevel
会变成DirtyLevels.Dirty
(计算变量的情况见下文)。而run
方法会在 effect 被触发后,以某种方式被调用,使得 effect 变成DirtyLevels.NotDirty
。个人理解,换句话说,effect 被触发后就是脏的,对响应式变量改变做出处理后就不脏了。另外,effect 初始化的时候_dirtyLevel
就是DirtyLevels.Dirty
,初始化后,会调用一次run
方法,把 effect 设置回DirtyLevels.NotDirty
。
这里的run
方法,调用时可以时 effect 被响应式变量收集。fn
通常会被 effect 的run
方法调用,此时 effect 会被记录在全局变量activeEffect
中,触发了get
方法的响应式变量可以将之收集。同时也有其它用途。例如 RenderEffect 的fn
用于挂载组件。WatchEffect 的话,在watch(valGetter, cb)
中,就是第一个参数,而在watchEffect(cb)
API 中即为其回调函数,也就是用来让监听的变量收集。ComputedEffect 的fn
是传入的函数,可以让依赖的变量收集。
我们还可以看到两个入参:trigger
、schedule
,它们通常在依赖被触发时调用,会以某种方式调用run
方法回应响应式变量的更新。
trigger
通常用于引起其它 effect 状态的改变。 ComputedEffect 的trigger
将会触发其收集到的 effect。RenderEffect 和 WatchEffect,则为空。
scheduler
通常包含异步的业务逻辑,例如 RenderEffect 的scheduler
会执行run
方法引起组件更新。WatchEffect 的会执行run
方法,以及watch
的回调函数。ComputedEffect 没有scheduler
,但是它将会触发其收集的 effect 的trigger
和schedule
,最终调用自身的get
方法并执行run
计算变量的新值。
语言逐渐混乱,总之,不太严谨地说,effect 的大体机制如下所示。effect 被计算变量触发的机制请继续看下一节。
graph TB
A(非计算变量的响应式变量被修改)-->H("triggerRefValue(self, DirtyLevels.Dirty)")-->|"①"|G(收集的 effect._dirtyLevel = DirtyLevels.Dirty)
H-->|"②"|B("收集的 effect 的 trigger()")-->|"WatchEffect 和 RanderEffect"|C(无事发生)
B-->|ComputedEffect|D("triggerRefValue(self, DirtyLevels.MaybeDirty 或者 \nMaybeDirty_ComputedSideEffect)")-.->M(Vue 3.4 的新机制)-.->|"后续 effect 的 run() 获取计算变量的值,\n触发 get value 方法"|N("effect.run()\neffect._dirtyLevel = DirtyLevels.NotDirty\neffect.fn()")
H-->|"③"|E("收集的 effect 的 scheduler()")-->|"RanderEffect if effect.dirty"|F("effect.run()\neffect._dirtyLevel = DirtyLevels.NotDirty\neffect.fn()")-->I("更新 VNode 渲染页面")
E-->|"WatchEffect if effect.dirty"|J("effect.run()\neffect._dirtyLevel = DirtyLevels.NotDirty\neffect.fn()")-->K("执行 watch 回调函数")
E-->|"ComputedEffect"|L(无事发生)
Vue 3.4 的计算变量脏检查
稍微回顾了一下 Vue 的响应式系统,Vue 3.4 减少了计算变量的不必要更新,从而减少因此引发的watch
系列 API 回调函数不必要的调用和不必要的页面更新。这次更新给计算变量和 effect 加上了DirtyLevels
的机制。先来看看枚举值定义:
export enum DirtyLevels {
NotDirty = 0,
QueryingDirty = 1,
MaybeDirty_ComputedSideEffect = 2,
MaybeDirty = 3,
Dirty = 4,
}
再来看看computed
的实现:ComputedRefImpl
:
export class ComputedRefImpl<T> {
// ...
constructor(
getter: ComputedGetter<T>,
private readonly _setter: ComputedSetter<T>,
isReadonly: boolean,
isSSR: boolean,
) {
this.effect = new ReactiveEffect(
// 它的 fn 就是 computed 传入的函数
() => getter(this._value),
// 这里是 trigger 方法,triggerRefValue 函数触发后续的依赖
() =>
triggerRefValue(
this,
this.effect._dirtyLevel === DirtyLevels.MaybeDirty_ComputedSideEffect
? DirtyLevels.MaybeDirty_ComputedSideEffect
: DirtyLevels.MaybeDirty,
),
)
this.effect.computed = this
this.effect.active = this._cacheable = !isSSR
this[ReactiveFlags.IS_READONLY] = isReadonly
}
}
我们假设,计算变量所依赖的响应式变量更新,它收集到的 effect 被触发。ComputedEffect 的trigger
被执行。
() =>
triggerRefValue(
this,
this.effect._dirtyLevel === DirtyLevels.MaybeDirty_ComputedSideEffect
? DirtyLevels.MaybeDirty_ComputedSideEffect
: DirtyLevels.MaybeDirty,
)
进入triggerRefValue
和triggerEffects
方法,computed
变量收集到的 effect 的_dirtyLecel
被设置为DirtyLevels.MaybeDirty
或者DirtyLevels.MaybeDirty_ComputedSideEffect
。
export function triggerRefValue(
ref: RefBase<any>,
dirtyLevel: DirtyLevels = DirtyLevels.Dirty,
newVal?: any,
) {
ref = toRaw(ref)
const dep = ref.dep
if (dep) {
triggerEffects(
dep,
dirtyLevel,
void 0,
)
}
}
export function triggerEffects(
dep: Dep,
dirtyLevel: DirtyLevels,
debuggerEventExtraInfo?: DebuggerEventExtraInfo,
) {
pauseScheduling()
for (const effect of dep.keys()) {
// dep.get(effect) is very expensive, we need to calculate it lazily and reuse the result
let tracking: boolean | undefined
if (
effect._dirtyLevel < dirtyLevel &&
(tracking ??= dep.get(effect) === effect._trackId)
) {
// 只有 NotDirty 的才能被触发依赖,避免了多个响应式变量同时改变,多次触发依赖的情况
effect._shouldSchedule ||= effect._dirtyLevel === DirtyLevels.NotDirty
effect._dirtyLevel = dirtyLevel
}
if (
effect._shouldSchedule &&
(tracking ??= dep.get(effect) === effect._trackId)
) {
effect.trigger()
if (
(!effect._runnings || effect.allowRecurse) &&
effect._dirtyLevel !== DirtyLevels.MaybeDirty_ComputedSideEffect
) {
effect._shouldSchedule = false
if (effect.scheduler) {
queueEffectSchedulers.push(effect.scheduler)
}
}
}
}
resetScheduling()
}
当后面的 effect 触发scheduler
时,这里以 RenderEffect 为例,在 packages/runtime-core/src/renderer.ts 中:
const effect = (instance.effect = new ReactiveEffect(
// 这个函数也就是 effect 的 fn,将会触发组件挂载或者更新
componentUpdateFn,
NOOP,
() => queueJob(update),
instance.scope, // track it in component's effect scope
))
const update: SchedulerJob = (instance.update = () => {
if (effect.dirty) {
effect.run()
}
})
effect 的scheduler
将判断自身的 effect 是否dirty
。然后,触发ActiveEffect
的dirty
get
方法。
WatchEffect 对是否触发
scheduler
的处理也类似。但是 ComputedEffect 的情况有所不同。在计算变量被其他 effect 的fn
执行时使用,触发get value
方法后才根据计算变量自身的 ComputedEffect 是否dirty
来决定是否重新计算。
export class ReactiveEffect<T = any> {
// ...
deps: Dep[] = []
computed?: ComputedRefImpl<T>
_dirtyLevel = DirtyLevels.Dirty
_depsLength = 0
//...
public get dirty() {
if (
this._dirtyLevel === DirtyLevels.MaybeDirty_ComputedSideEffect ||
this._dirtyLevel === DirtyLevels.MaybeDirty
) {
this._dirtyLevel = DirtyLevels.QueryingDirty
pauseTracking()
for (let i = 0; i < this._depsLength; i++) {
const dep = this.deps[i]
if (dep.computed) {
triggerComputed(dep.computed)
if (this._dirtyLevel >= DirtyLevels.Dirty) {
break
}
}
}
if (this._dirtyLevel === DirtyLevels.QueryingDirty) {
this._dirtyLevel = DirtyLevels.NotDirty
}
resetTracking()
}
return this._dirtyLevel >= DirtyLevels.Dirty
}
// ...
}
function triggerComputed(computed: ComputedRefImpl<any>) {
return computed.value
}
这里算是 Vue 3.4 计算变量更新的关键内容,当 effect 判断是否脏了的时候,如果它被计算变量收集了的话,它会检查计算变量的值事实上有无变化,triggerComputed
触发了计算变量computed
的get value
方法。
get value() {
const self = toRaw(this)
if (
(!self._cacheable || self.effect.dirty) &&
hasChanged(self._value, (self._value = self.effect.run()!))
) {
// 触发 triggerEffects 的时候,effect._dirtyLevel === DirtyLevels.NotDirty 为 false
// 因为上文被触发的 effect get dirty 的时候将其设置为 DirtyLevels.QueryingDirty
// 不会再次触发后续的 effect
triggerRefValue(self, DirtyLevels.Dirty)
}
trackRefValue(self)
// 这种情况只有在计算变量的函数中修改了计算变量依赖的响应式变量才会触发
if (self.effect._dirtyLevel >= DirtyLevels.MaybeDirty_ComputedSideEffect) {
triggerRefValue(self, DirtyLevels.MaybeDirty_ComputedSideEffect)
}
return self._value
}
如果这个计算变量的 effect 被非计算变量触发,则它的self.effect.dirty
为DirtyLevels.Dirty
,如果是被计算变量触发,重复上述流程。如果计算变量的值有变化则调用函数triggerRefValue(self, DirtyLevels.Dirty)
,把后续的 effect 的_dirtyLecel
设置为DirtyLevels.Dirty
。在上面的 RenderEffect 中,get dirty
返回 true,继续scheduler
的逻辑。
const update: SchedulerJob = (instance.update = () => {
if (effect.dirty) {
effect.run()
}
})
不严谨地简单地总结一下:
sequenceDiagram
计算变量-->>ComputedEffect: this.effect 指针
ComputedEffect-->>计算变量: this.computed 指针
Note left of ComputedEffect: 依赖的非计算变量<br>响应式变量改变<br>trigger 被调用
ComputedEffect->>其他 effect: triggerEffects
ComputedEffect->>其他 effect: _dirtyLevel = MaybeDirty
ComputedEffect->>其他 effect: 调用 scheduer
其他 effect ->> 其他 effect: if (this.dirty)
其他 effect ->> 其他 effect: get dirty
其他 effect ->> 计算变量: triggerComputed 也就是 get value
计算变量 ->> 计算变量: run 重新计算计算变量的值
alt 计算变量改变了
计算变量->>其他 effect: triggerEffects
计算变量->>其他 effect: _dirtyLevel = Dirty
其他 effect ->> 其他 effect: this.dirty 为 true,执行 run 方法
else 计算变量没有变
其他 effect ->> 其他 effect: this.dirty 为 false,无事发生
end
举个栗子🌰:
<script src="../../dist/vue.global.js"></script>
<div id="demo">
<h1 @click="handler">{{ data }}</h1>
</div>
<script>
const { createApp, ref, computed, watch } = Vue
createApp({
setup() {
const test = ref(0)
const data = computed(() => {
return Math.floor(test.value / 2)
})
const handler = () => {
console.log('click')
test.value++
}
watch(data, () => {
console.log('watch');
})
return {
handler, data
}
}
}).mount('#demo')
</script>
在点击了按钮后,第一次点击不会触发watch
,因为计算变量data
的值没有改变,第二次点击才会触发watch
。我们在组件的 RenderEffect log 一下也会发现第一次点击不会触发组件更新。
结语
本文对 effect 的机制进行了回顾,介绍了 Vue 3.4 的计算变量脏检查机制。在 Vue 3.4 中,计算变量值没有改变,不会重复触发后续的 effect,减少了没有必要的渲染和计算,提高了响应式系统的性能。
有什么不足请批评指正...... 大家的阅读是我发帖的动力。
另外,这是我的个人博客:deerblog.gu-nami.com/