本文主要根据vue3中的dirty机制做详细的介绍。
本文依据的vue版本是3.4.27。
这里需要对vue的响应式原理和vue的渲染器有一定的了解。如果不了解可以关注我后续的文章。
先说结论吧。vue3的dirty机制主要是用来解决computed在属性值没有发生变化的时候。对应的一些ReactiveEffect函数发现变化而做出的改变。简单来说就是增加一个标志位。是effect实例的一个属性。这个标志位目前主要是用来针对computed对应的effect函数(后续可能会针对其他)。这个标志位在effect初始化的时候是Dirty。当运行完副作用函数之后这个值会变成NotDirty。当在没有computed-effect的影响作用下。这个值会在
Dirty和NotDirty的之间来回切换。当这个effect被trigger的时候就会设置成Dirty。运行完之后就会变成NotDirty。
下面是dirty的具体取值
//
export enum DirtyLevels {
//运行完成之后的值
NotDirty = 0,
//查询dirty。在运行之前需要根据这个值去判断是否重新执行
QueryingDirty = 1,
// 嵌套的computed
MaybeDirty_ComputedSideEffect = 2,
//目前主要用作computed-trigger
MaybeDirty = 3,
//默认值。和被除了computed-trigger触发之外的情况
Dirty = 4,
}
本文我主要根据vue issue中的例子来进行介绍。链接地址:issues。 下面是这个例子的具体实现。
1: const count = ref(0);
2: const isEven = computed(() => count.value % 2 === 0);
3: watchEffect(() => {
4: console.log(isEven.value);
5: });
6: count.value = 2;
在介绍之前首先介绍几个函数的具体实现(删去不重要的逻辑,####表示)。
RefImpl
//ref函数的定义
export function ref(value?: unknown) {
//value就是传递初始值0。false是是否是shallow这里先不用关注
return createRef(value, false)
}
function createRef(rawValue: unknown, shallow: boolean) {
if (isRef(rawValue)) {
return rawValue
}
//通过RefImplclass去实例化一个ref对象。
return new RefImpl(rawValue, shallow)
}
//响应式对应的key或者ref对应的effect依赖组成的类型。是一个map结构。key是对应的effect。value是对应的effect_id。
export type Dep = Map<ReactiveEffect, number> & {
cleanup: () => void
computed?: ComputedRefImpl<any>
}
//初始化key的effect依赖项的方法。
export const createDep = (
cleanup: () => void,
computed?: ComputedRefImpl<any>,
): Dep => {
const dep = new Map() as Dep
dep.cleanup = cleanup
dep.computed = computed
return dep
}
//ref的构造器函数
class RefImpl<T> {
private _value: T
private _rawValue: T
//当前ref函数对应的依赖项。
public dep?: Dep = undefined
constructor(
value: T,
public readonly __v_isShallow: boolean,
) {
//constructor函数
}
//获取
get value() {
trackRefValue(this)
return this._value
}
set value(newVal) {
//#####
//判断value是都放生变化。发生变化的时候才去trigger
if (hasChanged(newVal, this._rawValue)) {
const oldVal = this._rawValue
this._rawValue = newVal
this._value = useDirectValue ? newVal : toReactive(newVal)
//如果发生变化就去触发当前ref所依赖的effect。注意这个触发是Dirty等级
triggerRefValue(this, DirtyLevels.Dirty, newVal, oldVal)
}
}
}
triggerRefValue
//ref函数对应的trigger依赖项的函数。可以看到这个函数的作用就是获取当前ref的dep属性。然后调用triggerEffects函数。如果有的话。
export function triggerRefValue(
ref: RefBase<any>,
//dirtyLevel等级。默认都是Dirty
dirtyLevel: DirtyLevels = DirtyLevels.Dirty,
newVal?: any,
oldVal?: any,
) {
ref = toRaw(ref)
const dep = ref.dep
if (dep) {
triggerEffects(
dep,
dirtyLevel,
__DEV__
? {
target: ref,
type: TriggerOpTypes.SET,
key: 'value',
newValue: newVal,
oldValue: oldVal,
}
: void 0,
)
}
}
triggerEffects
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)
) {
//当前effect需要执行的前提是当前的effect的_dirtyLevel是NotDirty
effect._shouldSchedule ||= effect._dirtyLevel === DirtyLevels.NotDirty
effect._dirtyLevel = dirtyLevel
}
if (
effect._shouldSchedule &&
(tracking ??= dep.get(effect) === effect._trackId)
) {
if (__DEV__) {
// eslint-disable-next-line no-restricted-syntax
effect.onTrigger?.(extend({ effect }, debuggerEventExtraInfo))
}
//从执行顺序可以看到trigger和scheduler的执行顺序
effect.trigger()
if (
(!effect._runnings || effect.allowRecurse) &&
effect._dirtyLevel !== DirtyLevels.MaybeDirty_ComputedSideEffect
) {
effect._shouldSchedule = false
if (effect.scheduler) {
queueEffectSchedulers.push(effect.scheduler)
}
}
}
}
resetScheduling()
}
这个函数的作用就是循环dep得到每一个effect。然后给循环的effect设置为参数传递的dirtyLevel。并判断是否需要更新。如果需要更新的话就会先触发effect的trigger方法。在把effect的scheduler放在执行队列中执行。从trigger和scheduler的执行时机可以得出。trigger要比scheduler要提前执行。主要是要派发一些信号。比如设置effect的dirty等级。
watchEffect
这个函数并不在reactivity这个包里面,而是在runtime-core->apiWatch。这里只看下他创建的Effect语句就行
export const NOOP = () => {}
scheduler = () => queueJob(job)
const effect = new ReactiveEffect(getter, NOOP, scheduler)
const job: SchedulerJob = () => {
if (!effect.active || !effect.dirty) {
return
}
//#####
effect.run()
//#####
}
这里的trigger是NOOP,是一个空函数,表示不需要trigger。
scheduler就是给当前的更新函数放入一个队列。这个队列具体怎么执行这里不做赘述。反正就是按照添加顺序依次执行。job就是watchEffect事件触发的函数。这里面的很多细节已经去掉。主要看函数刚开始执行的时候会判断effect.dirty的值是否是false。如果是的话就返回不需要重新执行。effect的dirty是一个get value属性。每次获取的时候都需要执行一部分逻辑。
ComputedRefImpl
//comput函数的具体实现
export class ComputedRefImpl<T> {
public dep?: Dep = undefined
private _value!: T
public readonly effect: ReactiveEffect<T>
constructor(
private getter: ComputedGetter<T>,
private readonly _setter: ComputedSetter<T>,
isReadonly: boolean,
isSSR: boolean,
) {
//在初始化的时候会初始化一个Effect
this.effect = new ReactiveEffect(
() => getter(this._value),
() =>
triggerRefValue(
this,
this.effect._dirtyLevel === DirtyLevels.MaybeDirty_ComputedSideEffect
? DirtyLevels.MaybeDirty_ComputedSideEffect
: DirtyLevels.MaybeDirty,
),
)
this.effect.computed = this
}
get value() {
// the computed ref may get wrapped by other proxies e.g. readonly() #3376
const self = toRaw(this)
if (
(!self._cacheable || self.effect.dirty) &&
hasChanged(self._value, (self._value = self.effect.run()!))
) {
triggerRefValue(self, DirtyLevels.Dirty)
}
trackRefValue(self)
if (self.effect._dirtyLevel >= DirtyLevels.MaybeDirty_ComputedSideEffect) {
if (__DEV__ && (__TEST__ || this._warnRecursive)) {
warn(COMPUTED_SIDE_EFFECT_WARN, `\n\ngetter: `, this.getter)
}
triggerRefValue(self, DirtyLevels.MaybeDirty_ComputedSideEffect)
}
return self._value
}
set value(newValue: T) {
this._setter(newValue)
}
}
这个函数是computed函数的具体实现细节。和ref不太一样。这个函数有自己的effect,因为需要监控计算属性的回调函数中的数据是否发生变化,如果发生变化需要把对应的发生变化的ref数据添加当前的effect到对应的dep。同时还有自己的dep,因为计算属性的值会被其他的effect依赖。
初始化的时候就是实例化一个ReactiveEffect。其中可以看到第二个参数。第二个参数是trigger。他的作用就是如果一个effect依赖的deps数组中有computed属性值的依赖的时候,需要比scheduler先触发。去派发信号。需要提前给依赖的computed值的effect打上一个标记。这个标记就是把对应的effect函数的dirty设置为MaybeDirty_ComputedSideEffect或者是MaybeDirty。MaybeDirty_ComputedSideEffect主要是用来解决嵌套,可以先不管。当打了这个标记之后。在运行对应的effect之前就先判断对应的dirty等级是否是dirty。小于dirty都是false。MaybeDirty小于false。在获取这个dirty属性的同时。会重新去获取当前的effect是否有computed属性。如果有的话就获取下对应的value。重新触发一次对应的get逻辑。如果重新触发的时候两次两次的值没有发生变化,就不需要更改。但是如果有变化的时候就重新设置对应effect的dirty等级是dirty。所以这个effect的dirty属性就大于等于dirty。重新触发。
以上只是希望有一个大致的理解。后续会发布这些函数中每一个属性的具体含义。
具体过程
为了解释起来方便。我这里给不同的effect做一个命名。isEven对应的computed函数的effect称为effect1。
watchEffect称为effect2。
初始化
从这个watchEffect中打印日志开始。也就是初始化收集effect2的依赖
当执行到console.log的语句的时候就会触发
computed的get value函数。从截图可以看到。self2.effect也就是是effect1对应的dirty等级是4。所以获取到的dirty属性就是true。然后在运行self2.fn。并且根据最新的值和旧的值进行比较。如果相同就不会触发isEven对应的effect依赖。这里只有一个effect2。如果相等就会触发。并且对应的等级是dirty等级。因为是初始化。所以他的dep属性是undefined。执行完成trackRefValue之后。会有一个effect2的依赖。如下
从上图可以看出如果重新运行对应的
effect。他的dirty等级就是0。
更新过程
没有变化
更新过程触发的语句是从count.value = 2;开始。因为给count赋值。所以会触发count->ref对应的set value方法。如下图。
从第一个打印的
true也可以看出来。watchEffect已经收集完成依赖。并且执行过。
接着是触发count->ref收集到的effect依赖数组。见下图。
count对应的依赖只有一个effect1。也就是computed对应的effect。triggerRefValue的作用就是根据传递的dirty等级去触发对应的effect。这里因为是ref触发。所以他的等级是4。
接下来是triggerEffects。他的主要作用是循环deps。并且设置对应的dirty等级。并且需要在更新的时候。就触发对应的trigger和scheduler。
上图1的标记就是当前的
effect是否更新。更新的逻辑就是当前的dirty等级是否是not_dirty。因为一个effect执行完成之后正常情况都是not_dirty。
2是一些关键日志信息。运行到此处当前的effect2就是对应的参数dirty等级4。从0到4。表示需要更新了。如果需要更新。就会依次触发triger。和对应的scheduler。triger是直接触发。scheduler需要放在任务队列依次执行。
可以看到。这个
trigger是effect1在实例化的时候传递的。作用就是需要触发isEven对应的依依赖effects。并且dirty等级是3。因为这次更新是computed触发的。接着又是triggerRefValue。只不过这时候对应的dep是isEven的。里面只有effect2。并且对应的dirty等级是3。见下图。
接着又来到对应的
triggerEffects。可以看下对应的执行栈。他是从count->dep到computed->dep。
接下来就是正常执行到对应的
trigger。effect2对应trigger是空函数。所以什么都没执行。然后把scheduler加入到对应的队列
isEvent对应的dep执行完成。count对应的dep执行完成。此时其实还没执行对应的scheduler。因为他们还在对应中排队。执行的其实是effect对应的trigeer。如下:
注意下右边的执行栈。已经回到了
count对应的dep。并且执行完成。测试对应的queueEffectSchedulers里面只有一个scheduler。是watchEffect的。接下来是这个scheduler的执行如下。
可以看到需要获取当前
effect对应的dirty。这个dirty的获取是一个get属性。如下:
可以看到当前
effect2对应的dirty等级已经是3。说明收集的依赖中有computed属性的变化。如果是4正常的ref触发。这个判断不会进去。直接返回true。因为是3所以进入。需要进行dirty-check。check的过程就是循环effect2对应的deps。如果有computed属性就触发一次他的get方法。没有就一直进行。注意。在check之前会把当前的effcet的dirty等级变成1也就是QueryingDirty。这样主要是为了如果在触发value的时候。两次的值不一样。需要重新触发isEeven的triggerRefValue。但是这次很明显不需要重新触发。只是去查询。去修改下当前的effect的dirty等级。所以在triggerEffects函数中的_shouldSchedule判断逻辑是当前的dirty等级是0。如果是其他的表示不应该触发。本次触发只是为了check-dirty。
可以看到当前的
dep是有一个computed属性的。 需要重新运行的他给get方法。triggerComputed函数很简单就是获取对应的value。从而触发他的get方法。
function triggerComputed(computed: ComputedRefImpl<any>) {
return computed.value
}
上图所示。本次变化没有发生变化。所以就不会触发对应的
triggerRefValue。所以就不会更改effect2对应dirty等级。
可以看到他的经过查询之后他的
dirty的等级还是1.所以也就是没有发生变化。最终的结果就是job函数返回。不需要重新执行了。
以上就是没有变化的情况。
有变化
当有变化的情况和上面的差不多。但是在check-dirty的时候有变化。
可以看到如果是变成修改
count.value =3。isEvent运行之后的值是false。发生了变化。就进入下面的triggerRefValue。把effect2对应的dirty的等级又变成4。这边直接到get dirty那一步。中间忽略更新成4的过程。如下。
effect2的dirty已经变成4。最终的结果就是返回true了。在job那一层在取一个反就会重新执行watch Effect对应的回调了
以上就是整体的过程了。
如果是Effct收集里面有ref。有computed。其实也是一样的。因为不管ref的收集在computed前面还是后面。只要有ref对应的dirty就一定是4。所以就一定会重新触发。
总结
这里还是解释下issus对应的图。
优化之前:其实就是在dataChange的时候先trigger Reactive Data对应的dep。在trigger computed data的dep。最后在重新sheduler wachEffect对应的fn。这之间其实没有什么两次触发的值是否一样。都是直接触发。
优化之后:前面都是一样。只是在trigger computed data的dep的时候。先trigerr。去派发一个信号。表示这次的更新里面有computed。然后在sheduler。在执行对应的fn的时候。需要判断当前的effect的dirty属性。这个时候应该是当前effect的dirty等级不是4。所以会进入check dirty的逻辑。check之后如果还不是dirty就不需要重新触发。check之后如果是dirty就重新触发。