前言
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
方法会被调用,引起后续的变化。
计算变量所依赖的响应式变量发生修改时,它也会触发它所收集的 effect,同时触发依赖它的 effect。被触发get value
时,如果计算变量依赖的响应式变量有改变,则对自身的值进行重新计算。
这里说的 effect,常见的一般是由computed
(ComputedEffect)、watch
或者watchEffect
(WatchEffect)等 API 创建和创建虚拟 DOM 时创建的(RenderEffect)。它的定义如下:
ts- 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
- }
- }
- // ...
- }
effect 初始化_dirtyLevel
就是DirtyLevels.Dirty
,当run
方法被调用时变成DirtyLevels.NotDirty
。当被响应式变量触发时,如果不是计算变量,_dirtyLevel
则为DirtyLevels.Dirty
,计算变量的情况见下文。而run
方法会在 effect 被触发后,以某种方式被调用,重新变成DirtyLevels.NotDirty
。个人理解,换句话说,effect 被触发后就是脏的,对响应式变量改变做出处理后就不脏了。
这里有 3 个重要的属性:fn
、trigger
、scheduler
,它们从构造函数传入。
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 3.4 的更新给计算变量和 effect 加上了DirtyLevels
的机制。先来看看定义:
ts- export enum DirtyLevels {
- NotDirty = 0,
- QueryingDirty = 1,
- MaybeDirty_ComputedSideEffect = 2,
- MaybeDirty = 3,
- Dirty = 4,
- }
流程解析
我们假设,计算变量所依赖的响应式变量更新,它收集到的 effect 被触发。
ts- 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(
- 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
- }
- }
此时computed
变量收集到的 effect 的_dirtyLecel
被设置为DirtyLevels.MaybeDirty
或者DirtyLevels.MaybeDirty_ComputedSideEffect
。
ts- 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 中:
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
来决定是否重新计算。
ts- 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
- }
如果它被计算变量收集了的话,它会检查计算变量的值事实上有无变化,这里触发了计算变量computed
的get value
方法。
ts- export class ComputedRefImpl<T> {
- public dep?: Dep = undefined
- private _value!: T
- public readonly effect: ReactiveEffect<T>
- public _cacheable: boolean
- // ...
- 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
,get dirty
返回 true,继续scheduler
的逻辑。
ts- const update: SchedulerJob = (instance.update = () => {
- if (effect.dirty) {
- effect.run()
- }
- })
简单地总结一下:
graph LR A(响应式变量 非计算变量)-->|"① 依赖触发 Dirty"|B(ComputedEffect effect0)-->|"② 依赖触发 MaybeDirty"|C(其他类型的依赖 effect1) C-->|"⑥ if Dirty"|D("effect1.run()") B-->|"⑤ 值改变了 Dirty 没有则 NotDirty"|C F(计算变量)-->|"④ effect0.dirty &&\nself._value = effect0.run()"|B C-->|"③ 执行 run 方法前\n检测值是否改变"|F
举个栗子🌰:
ts- <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。