前言
在vue.3.4.0-alpha.1之前,vue3的响应式大部分是积极的。
什么意思?我们知道,vue3是基于 effect 通过 Proxy 的 get 等拦截器收集依赖,然后通过触发Proxy的set,来触发依赖了当前响应数据的effect,来实现的响应式的。
但在部分响应式数据中,一次修改可能触发多次响应式更新,虽然存在新旧值对比的机制,但新值不一定是最新的。
比如下面的一个例子。这个例子来自于这个issues
import { ref, effect } from "vue"
const store = ref([])
let counterForRun = 0
const effectRunner = effect(() => {
console.log(`effect run times is ${counterForRun}`)
if (store.value.length > 0) {
console.log(`store value is ${JSON.stringify(store.value)}`)
store.value.splice(0)
}
counterForRun += 1
})
let intervalTimes = 0
const intervalId = setInterval(() => {
if (intervalTimes === 2) {
clearInterval(intervalId)
return
}
store.value.push(intervalTimes)
intervalTimes += 1
}, 1000)
这个例子做了什么呢?
首先定义了一个响应式空数组,store。
然后定义了counterForRun,来记录effectRunner的触发次数,初始值是0,effectRunner每触发一次,counterForRun就会自增1。
而effectRunner会在开始打印counterForRun的次数,如果发现store数组长度大于0,就将他重置为0,同时这一步收集了数组的length来作为effectRunner的依赖。
然后定义了一个定时器,这个定时器的作用是是异步触发store的更新,从而触发effectRunner的执行。
这个定时器只会执行2次。
定时器的逻辑换成一个按钮,然后自己点两下,每次让store push任意一个值,结果也是对等的。
那么这个例子的运行结果是什么呢?
effect run times is 0
effect run times is 1
store value is [0]
effect run times is 2
store value is [null]
effect run times is 3
store value is [1]
effect run times is 4
store value is [null]
我们会发现,effect执行了4次,在第5行和第9行出现了 [null] 。
为什么effect会执行4次?并且store一度等于 [null]。
不过略微思考一下就明白了。
我们知道,在ref中传入对象,最终还是使用reactive,也就是Proxy来返回代理对象,而数组是特殊的对象,因此数组的修改,是会触发Proxy的set拦截器的,并且可能触发多次。
我们来确定一下。
const arr = []
const proxyArr = new Proxy(arr,{
set(target, key, value, receiver) {
console.log(key, value)
Reflect.set(target, key, value, receiver)
return true
},
})
proxyArr.push(0)
// 0 0
// length 1
proxyArr.splice(0)
// length 0
push会触发两次set,第一次key是索引,第二次key是length。而splice只会触发key是length的set(当然也会触发deleteProperty,我们只考虑set拦截器)。但splice触发的时候,当前activeEffect是 effectRunner,所以不会重复触发。
因此会执行四次,第一个是索引触发,第二次是length触发,第三次是索引触发,第四次是length。
但是会出现[null]呢?我们来捋一下逻辑:
当使用push的时候,第一次索引变更,触发了数组的set拦截器,通过Reflect.set更新值后,触发了trigger,进一步触发了triggerEffects,遍历依赖这个数组的effect,从而执行这个effect的run方法,run方法最后执行了effectRunner包装的函数,可以说这一步触发了effectRunner。
请注意,这个时候key是length的set依然还没有触发。
但是store事实上已经是一个非空响应式数组了。
因此顺利通过if判断条件,执行了数组的splice方法。
这个时候再次触发了数组的set拦截器,通过Reflect.set更新值后,也会走trigger和triggerEffects,但是由于activeEffect是当前effect,因此不会进入effect的run方法。
此时索引变更的拦截器逻辑才执行完,但由于splice方法,store事实上又变成了一个空响应式数组了。
但逻辑还没完,因为js是单线程的,这个时候push操作导致key是length的set拦截器被触发了。
在set拦截器中,key是length,value是1,但此时store数组事实上已经是一个空响应式数组了。
所以Reflect.set对store的length赋值为1。从而把store变成了[null]。之后还会触发一系列的响应式操作,再次触发effectRunner,然后再次触发splice方法,从而让结果回归正轨,但与我们的结论没什么太大关系了。
我们在这一步就可以得出结论,由于数组的push操作触发的两次set,并且其中夹杂一次splice触发的set,从而导致数组会暴露出一个[null]瞬间状态。
而3.4.0的这个提交可以解决这个问题。
effect run times is 0
effect run times is 1
store value is [0]
effect run times is 2
store value is [1]
看起来逻辑是符合直觉的,push只会触发一次counterForRun,并且splice也没有触发counterForRun。
那么当前逻辑是如何呢?
为了方便理解,我们从收集依赖就开始追踪代码,来看看到底存在哪些变化,而这些变化为什么会防止上面的问题出现。
收集依赖
ref的逻辑没有变动,我们直接略过,在创建effect的ReactiveEffect,出现了变化。
我们知道effect实际是new ReactiveEffect的封装,但在之前,ReactiveEffect 需要的参数分别是get, scheduler, scope,但现在,在第二个位置增加了一个参数,trigger,而scheduler和scope则后移。
const _effect = new ReactiveEffect(fn, NOOP, () => {
if (_effect.dirty) {
_effect.run()
}
})
他会检查dirty是否是true,true才会执行run,也就是effectRunner。
在创建了一个effect对象后,会紧接着调用run方法。
if (!options || !options.lazy) { // option undefined
_effect.run()
}
我们进入ReactiveEffect,看看发生了什么变化。
export class ReactiveEffect<T = any> {
active = true; // 表示当前effect是否处于活动状态
deps: Dep[] = []; // 存储与effect相关的依赖项数组
computed?: ComputedRefImpl<T>; // 表示该effect是由某个计算属性触发的
allowRecurse?: boolean; // 表示该effect是否允许递归运行
onStop?: () => void;
_dirtyLevel = DirtyLevels.Dirty; // 内部属性,表示当前effect的脏状态级别,默认为 Dirty
_trackId = 0; // 内部属性,用于标识追踪的 ID
_runnings = 0; // 内部属性,表示当前正在运行中的effect的数量
_queryings = 0; // 内部属性,表示当前正在查询的effect的数量
_depsLength = 0; // 内部属性,表示当前effect的依赖项数组的长度
constructor(
public fn: () => T,
public trigger: () => void,
public scheduler?: EffectScheduler,
scope?: EffectScope
) {
recordEffectScope(this, scope); // 记录effect的范围信息
}
// 获取当前effect的脏状态
public get dirty() {
// ...
}
// 设置当前effect的脏状态
public set dirty(v) {
// ...
}
run() {
this._dirtyLevel = DirtyLevels.NotDirty; // 将脏状态NotDirty
if (!this.active) {
return this.fn(); // 如果effect不处于活动状态,直接执行
}
let lastShouldTrack = shouldTrack;
let lastEffect = activeEffect;
try {
shouldTrack = true;
activeEffect = this;
this._runnings++; // 增加运行中effect的数量
preCleanupEffect(this); // 执行effect前的清理操作
return this.fn();
} finally {
postCleanupEffect(this); // 执行effect后的清理操作
this._runnings--; // 减少运行中effect的数量
activeEffect = lastEffect; // 恢复之前的活动effect
shouldTrack = lastShouldTrack; // 恢复之前的追踪状态
}
}
stop() {
if (this.active) {
preCleanupEffect(this); // 执行effect前的清理操作
postCleanupEffect(this); // 执行effect后的清理操作
this.onStop?.();
this.active = false;
}
}
}
我们省略了一些属性,这次只看初始化的。在run方法中,我们会执行传入的函数,然后收集依赖。
在之前的逻辑,会通过parent来判断是否有循环依赖的问题,同时还有基于effectTrackDepth的依赖对比和依赖清理逻辑。
但现在更改为基于_runnings计数来判断是否有循环依赖的问题,执行fun的时候,_runnings会自增,执行finally的时候,_runnings会自减。
在后面的逻辑中,如果会检测到当前effect的_runnings不为0,说明finally并没有执行,出现了循环。
而依赖对比和依赖清理,则由preCleanupEffect和postCleanupEffect负责。
function preCleanupEffect(effect: ReactiveEffect) {
effect._trackId++; // 增加effect的追踪 ID
effect._depsLength = 0; // 重置依赖项数组长度为 0
}
function postCleanupEffect(effect: ReactiveEffect) {
if (effect.deps && effect.deps.length > effect._depsLength) {
for (let i = effect._depsLength; i < effect.deps.length; i++) {
cleanupDepEffect(effect.deps[i], effect); // 清理多余的依赖项
}
effect.deps.length = effect._depsLength; // 调整依赖项数组的长度
}
}
function cleanupDepEffect(dep: Dep, effect: ReactiveEffect) {
const trackId = dep.get(effect); // 获取依赖项中的追踪 ID
if (trackId !== undefined && effect._trackId !== trackId) {
dep.delete(effect); // 删除无关的依赖项
if (dep.size === 0) {
dep.cleanup(); // 如果依赖项为空,执行清理操作
}
}
}
在执行fun之前,preCleanupEffect会永久自增_trackId,确保了每次effect的fun运行时,其追踪 ID 都是唯一的,避免了在不同追踪周期间引入混淆。同时,重置依赖项数组长度确保了只追踪在本次运行期间访问的新依赖项,避免了旧依赖项的影响。
在finally中,会执行postCleanupEffect, 由于依赖项数组可能在fun运行时被动态添加新的依赖,执行后需要清理多余的依赖项,确保依赖项的数量与实际被追踪的依赖一致。这样,便于在下次effect触发,只追踪新的依赖项,提高了响应式系统的性能。
清理主要靠cleanupDepEffect,这个函数负责根据追踪 ID 判断依赖项是否与effect相关,如果不相关,则删除该关系。
在执行fun触发store的get拦截器,执行trackRefValue,从而收集依赖,这个逻辑没有什么变化,但初始化依赖的逻辑发生了变化。
(ref.dep = createDep(
() => (ref.dep = undefined),
ref instanceof ComputedRefImpl ? ref : undefined
)),
我们看一下createDep是什么样子
export const createDep = (
cleanup: () => void, // 清理函数,在不再需要依赖项时调用
computed?: ComputedRefImpl<any> // 可选的计算属性实例,表示该依赖项是由计算属性触发的
): Dep => {
const dep = new Map() as Dep; // 创建一个新的 Map 对象,作为依赖项
dep.cleanup = cleanup; // 设置清理函数,用于在不再需要依赖项时执行清理操作
dep.computed = computed; // 设置计算属性实例,表示该依赖项是由计算属性触发的
return dep; // 返回创建的依赖项实例
}
该函数接受两个参数:一个是清理函数 cleanup,用于在不再需要依赖项时执行清理操作;另一个是可选的计算属性实例 computed,表示该依赖项是由计算属性触发的。函数内部创建了一个新的 Map 对象,将其类型断言为 Dep 类型,并设置了 cleanup 和 computed 属性。最后,返回创建的依赖项实例。
上文中,cleanupDepEffect调用的的dep.cleanup实际执行的就是ref.dep = undefined。
trackRefValue中,会检查ref是不是计算属性,如果是的话,会把计算属性传入第二个参数,而trackRefValue主要是在ref和computed来使用。
在之前的逻辑中,Dep是一个Set对象,并定义了w 和 n来识别新旧依赖。
创建并收集完依赖后,会执行之后会执行trackEffect,在之前的逻辑是执行trackEffects。逻辑类似,但也进行了变动。我们看一下。
export function trackEffect(
effect: ReactiveEffect, // 当前活动的effect
dep: Dep, // 当前的依赖项
debuggerEventExtraInfo?: DebuggerEventExtraInfo // 可选的调试事件信息
) {
// 如果当前依赖项中没有记录该effect的追踪 ID,则建立关联
if (dep.get(effect) !== effect._trackId) {
dep.set(effect, effect._trackId); // 在依赖项中记录effect的追踪 ID
// 获取当前effect的依赖项数组中的最后一个依赖项
const oldDep = effect.deps[effect._depsLength];
// 如果最后一个依赖项不是当前依赖项,进行处理
if (oldDep !== dep) {
if (oldDep) {
cleanupDepEffect(oldDep, effect); // 清理旧的依赖项关系
}
effect.deps[effect._depsLength++] = dep; // 将当前依赖项添加到effect的依赖项数组中
} else {
effect._depsLength++; // 如果最后一个依赖项就是当前依赖项,增加依赖项数组长度
}
}
}
该函数的主要作用是建立当前活动的effect,也就是effectRunner与指定dep之间的关系。如果当前依赖项中没有记录该effect的追踪 ID,则在依赖项中记录该 ID,并将该依赖项添加到当前effect的依赖项数组中。与之前不同的是,之前使用effectTrackDepth,这这里替换为effect自己的_depsLength。
当然,还会触发数组length的get拦截器,从而再次触发收集依赖,这个也跟之前逻辑相同,只不过track移到了reactiveEffect.ts中,初始化依赖的逻辑同样发生了变化。
// 如果指定键的依赖项不存在,创建一个新的依赖项,并设置清理函数用于删除依赖项
if (!dep) {
depsMap.set(key, (dep = createDep(() => depsMap!.delete(key))));
}
至此,依赖收集完毕,我们看触发逻辑。
触发依赖
首先,我们知道,push实际执行的是经过劫持的push,在原来push暂停收集依赖的基础上,增加了暂停调度和重启调度函数
;(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach(key => {
instrumentations[key] = function (this: unknown[], ...args: unknown[]) {
pauseTracking()
pauseScheduling() // 暂停调度
const res = (toRaw(this) as any)[key].apply(this, args)
resetScheduling() // 恢复调度
resetTracking()
return res
}
})
pauseScheduling 和 resetScheduling是这次新增的函数,并且他们必定成对出现。而他们的逻辑也很简单。
// 用于存储暂停调度的计数
export let pauseScheduleStack = 0;
// 用于存储effect调度器函数
const queueEffectSchedulers: (() => void)[] = [];
// 用于增加暂停调度的计数
export function pauseScheduling() {
pauseScheduleStack++; // 增加暂停调度的计数
}
// 减少暂停调度的计数,并在计数为零时执行队列中的effect调度器函数
export function resetScheduling() {
pauseScheduleStack--; // 减少暂停调度的计数
// 当暂停调度的计数为零且effect调度器队列不为空时,执行队列中的effect调度器函数并将其移出队列
while (!pauseScheduleStack && queueEffectSchedulers.length) {
queueEffectSchedulers.shift()!();
}
}
也就是说,这两个函数提供了一个机制,可以通过增加和减少 pauseScheduleStack 的值来控制调度的暂停和继续,并且在适当的时机执行与这个调度机制相关的effect调度器函数。
也就是执行resetScheduling可能并不会让存储的调度函数执行,只有pauseScheduleStack被减为0的时候,才会让存储下来的调度器执行。
那么此时pauseScheduleStack自增成为1。
然后执行了数组真正的push,从而触发了set拦截器。set拦截器并没有变动,顺利走到trigger函数里面。
trigger函数移动了位置,从 effect.ts 移动到了reactiveEffect.ts 。
trigger 的逻辑并没有太大的变化,只是最后触发依赖的时候与原先不同。
pauseScheduling(); // 暂停调度
// 遍历所有需要触发更新的依赖,并触发它们的effect
for (const dep of deps) {
if (dep) {
triggerEffects(
dep,
DirtyLevels.Dirty, // 标记为Dirty,表示需要重新计算
__DEV__
? {
target,
type,
key,
newValue,
oldValue,
oldTarget
}
: void 0
);
}
}
resetScheduling(); // 恢复调度
在原先的逻辑中,如果deps长度为1且是有效的是会直接触发,其他情况将deps进行循环过滤,如果dep是有效值,会将他进行展开后推入一个数组,这样这个数组就是存放effect的一维数组,然后使用createDep包装成Set,传入triggerEffects。然后triggerEffects会再次转为数组,遍历两次这个数组,使用triggerEffect提前触发计算属性的effect,然后触发其他effect。
而现在的逻辑,先暂停调度,此时pauseScheduleStack自增成为2。然后将deps进行遍历,dep传入triggerEffects,而triggerEffects的逻辑也跟之前不同了。
export function triggerEffects(
dep: Dep,
dirtyLevel: DirtyLevels,// 脏状态的级别
debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
pauseScheduling(); // 暂停调度
// 遍历依赖项的所有effect
for (const effect of dep.keys()) {
// 如果effect不允许递归运行且正在运行,则跳过此次循环
if (!effect.allowRecurse && effect._runnings) {
continue;
}
// 如果effect的脏状态小于指定的脏状态级别,执行下面的逻辑
if (
effect._dirtyLevel < dirtyLevel &&
(!effect._runnings || dirtyLevel !== DirtyLevels.ComputedValueDirty)
) {
const lastDirtyLevel = effect._dirtyLevel;
effect._dirtyLevel = dirtyLevel; // 更新effect的脏状态
// 如果上一次脏状态为 NotDirty,并且effect没有正在获取脏状态,执行下面的逻辑
if (
lastDirtyLevel === DirtyLevels.NotDirty &&
(!effect._queryings || dirtyLevel !== DirtyLevels.ComputedValueDirty)
) {
effect.trigger(); // 触发effect的trigger
if (effect.scheduler) {
queueEffectSchedulers.push(effect.scheduler); // 将effect的调度器加入调度队列
}
}
}
}
resetScheduling(); // 恢复调度
}
现在triggerEffects会先暂停调取,此时pauseScheduleStack自增成为3。
然后遍历外部传入的dep,根据脏状态的级别触发相应的更新。并且将调度函数推入queueEffectSchedulers中。
我们前面讲过,queueEffectSchedulers 会在 resetScheduling 中,pauseScheduleStack为0的时候触发。
这里,出现了DirtyLevels这个枚举,我们来看一下DirtyLevels的具体定义。
export const enum DirtyLevels {
NotDirty = 0, // 表示数据未脏,即数据没有发生变化。
ComputedValueMaybeDirty = 1, // 表示计算属性可能处于脏状态。当计算属性依赖的数据发生变化时,计算属性可能需要重新计算,但不确定是否真的脏。
ComputedValueDirty = 2, // 表示计算属性处于脏状态。当计算属性依赖的数据发生变化,并且计算属性确实需要重新计算时,它处于脏状态
Dirty = 3 // 表示数据处于脏状态。当普通数据(非计算属性)发生变化时,数据处于脏状态。
}
这就表示,如果数据变化了,也不一定会触发对应的响应式,还需要对应的级别,在原来triggerEffects中,会优先触发计算属性的effect和依赖他的响应逻辑。
这是因为如果某个响应数据变动导致effect的触发,而effect中恰好存在计算属性,那么此时计算属性的effect的调度函数还没触发,因此计算属性中的dirty是false。所以获取的value是之前的缓存的。
所以之前triggerEffects的逻辑是优先触发计算属性的effect以及对应依赖他的effect。
而现在triggerEffects的逻辑是会给予一个对应级别的脏状态,如果effect自身的脏状态小于这个脏状态,那么就需要更新为最大的脏状态,而执行effect的run的时候更改为NotDirty。
我们接着看,当前脏状态是Dirty,且dep自身的脏状态是0,所以最后会触发effect.trigger(),在这里,effect的trigger是空函数,所以会将scheduler推入queueEffectSchedulers中。
遍历结束后,执行resetScheduling恢复调度,pauseScheduleStack 自减变为2,但因为没有恢复0,所以不会执行queueEffectSchedulers队列。
然后执行trigger的resetScheduling,pauseScheduleStack 自减变为1,但因为没有恢复0,所以不会执行queueEffectSchedulers队列。
接着结束执行索引触发的set拦截器。
然后比较关键的是,在之前的逻辑,会触发splice的set的拦截器,但在新的逻辑,并不会这么做,因为scheduler并没有执行,而是推入了queueEffectSchedulers队列。
而最后一个resetScheduling是在push之后执行,但此时push并没有结束,因为还有length触发的set拦截器,但因为之前就存在对应的key(length),且新值和旧值一样,所以这次只执行反射的赋值,不会进入trigger。
在上文的例子,因为splice的执行,导致了新值和旧值的不同,所以会进入trigger。
结束执行length触发的set拦截器。这次我们执行最后的resetScheduling。pauseScheduleStack 自减变为 0,会执行存储在queueEffectSchedulers中的scheduler。
在前文中,我们应该还记得scheduler是定义effect的时候传入的。
他会检查dirty是否是true,true才会执行run,也就是触发effectRunner。
public get dirty() {
// 省略
// 返回effect的脏状态是否大于等于 ComputedValueDirty
return this._dirtyLevel >= DirtyLevels.ComputedValueDirty;
}
因为在前面将他的_dirtyLevel设置为Dirty所以返回的是true,其他逻辑我们后面再说。
之后会触发run,也就是触发了effectRunner。
而effectRunner会触发splice,从而再次触发set拦截器。
但是最后走到triggerEffects的时候,由于_runnings非0,所以会跳出此次循环,从而没有将scheduler推入队列。无事发生。
因此splice也不会触发effectRunner。
至此,push会触发一次effect,而splice不会触发,原因是索引会触发一次,length因为新旧值相同不会触发,splice因为_runnings非0,也不会触发。
计算属性
对于计算属性的处理,则通过effect中的get dirty进行处理。
// 获取effect的脏状态,表示effect是否需要重新计算
public get dirty() {
// 如果计算属性的脏状态为 ComputedValueMaybeDirty,执行下面的逻辑
if (this._dirtyLevel === DirtyLevels.ComputedValueMaybeDirty) {
this._dirtyLevel = DirtyLevels.NotDirty; // 将脏状态重置为 NotDirty
this._queryings++; // 增加查询状态计数
pauseTracking(); // 暂停依赖追踪
// 遍历effect的依赖,并触发与之相关的计算属性
for (const dep of this.deps) {
if (dep.computed) {
triggerComputed(dep.computed); // 触发依赖的计算属性
// 如果effect的脏状态变为 ComputedValueDirty,跳出循环
if (this._dirtyLevel >= DirtyLevels.ComputedValueDirty) {
break;
}
}
}
resetTracking(); // 恢复依赖追踪
this._queryings--; // 减少查询状态计数
}
// 返回effect的脏状态是否大于等于 ComputedValueDirty
return this._dirtyLevel >= DirtyLevels.ComputedValueDirty;
}
也就是说如果此effect的脏状态是ComputedValueDirty或者Dirty,那么dirty就是true,对应的逻辑会走全量更新或者执行effect.run。
如果此effect的脏状态是NotDirty,那么dirty就是false,对应的逻辑会使用缓存值,或者跳过effect.run的执行。
如果此effect的脏状态是ComputedValueMaybeDirty,那么将循环执行依赖的计算属性,这个时候会更改此effect的脏状态,所以这里有个优化,一旦发现当前effect的脏状态大于等于ComputedValueDirty,那么跳出循环。
我们直接看个例子。
const a = ref(0)
const b = computed(() => a.value + 1)
const fun = effect(() => {
console.log(b.value)
})
a.value++
1、因为a的值更新,依赖a的计算属性会被标记为Dirty。
2、而依赖计算属性的fun会被标记为ComputedValueMaybeDirty。最终被推入queueEffectSchedulers队列。
3、queueEffectSchedulers队列开始依次执行scheduler,会检查fun的dirty,从而触发他的dirty拦截器。
4、而因为他的标记是ComputedValueMaybeDirty,从而循环执行他依赖的计算属性,这个计算属性在trackEffect就传入了。
5、而因为计算属性的effect的标记是Dirty,因此会获取最新的值,计算属性的effect的标记更改为NotDirty,然后会触发triggerRefValue,从而给fun打上ComputedValueDirty标记
6、执行完毕之后,因为fun已经是ComputedValueDirty,所以跳出循环,从而实际触发fun的响应函数。
7、里面还会再触发一次计算属性,但因为计算属性的effect是NotDirty,所以使用缓存值。
8、如果里面还有其它计算属性,没有被第6步遍历到。会在获取值的时候,获取这个计算属性的effect的dirty,也就是获取他的脏状态,也就是前面的第5步逻辑,然后根据脏状态来判断计算属性重新计算还是使用缓存的值。
可以看出来,新的响应式逻辑是基于脏状态的的变动,从而选择式做出响应,但也支持外界强制变更,让他在下次必定做出响应。
instance.effect.dirty = true
instance.update()
public set dirty(v) {
this._dirtyLevel = v ? DirtyLevels.Dirty : DirtyLevels.NotDirty
}
如果指定dirty为true,那么就会给effect的脏状态设置为Dirty,从而使下次必定触发effect。
小结
由于当前并非正式版本,可能存在很多边界问题,也可能存在不少BUG,甚至有可能最后不会被采纳,不过这些更改的思考逻辑是值得学习的。