注:本文使用vue版本为3.3.7
在上一节万字逐行解析vue3如何使用effect实现响应式中,我们提到了queueJob
,从结果上来看是一个将入参延后执行的函数,但是他具体起到什么作用呢?我们看看源码。
queueJob
typescript
let flushIndex = 0
// 将任务加入任务队列
export function queueJob(job: SchedulerJob) {
// 用于数组.includes()的startIndex参数来进行去重查找
// 默认情况下,搜索索引包括当前正在运行的任务
// 所以它不能递归地再次触发自身。
// 如果任务是 watch() 回调函数,搜索将从 +1 索引开始
// 允许它递归地触发自身
// 确保它不会陷入无限循环。
if (
!queue.length || // 如果队列为空,或者
!queue.includes(
job,
isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex // 如果正在执行队列并且任务允许递归,则搜索从 flushIndex + 1 开始,否则从 flushIndex 开始
)
) {
if (job.id == null) {
queue.push(job); // 如果任务没有 ID,直接将任务加入队列
} else {
queue.splice(findInsertionIndex(job.id), 0, job); // 否则,在找到合适的位置插入任务
}
queueFlush();
}
}
从代码上看,这个函数的作用的确是将任务加入任务队列。
SchedulerJob
本质是一个挂载多个可选属性的函数 ,在推入任务队列之前,会检查队列是否为空,或者队列不包含当前任务,并且队列查找当前任务起始startIndex去来重
:是否在执行或者是否允许递归,如果正在执行并且任务允许递归,startIndex
就是flushIndex + 1
,否则是flushIndex
。
这样确保在执行的时候,任务不会再次触发自身,避免无限循环。
如果任务没有id
,那么就会直接推入任务队列,否则通过findInsertionIndex
调整合适的位置。
最后执行queueFlush
。
这里遇到了两个函数,一个是findInsertionIndex
,另一个是queueFlush
。
我们先看findInsertionIndex
。
typescript
function findInsertionIndex(id: number) {
// 起始索引应为 flushIndex + 1
let start = flushIndex + 1;
let end = queue.length;
// 二分查找循环
while (start < end) {
// 计算中间索引
const middle = (start + end) >>> 1;
// 获取中间位置的任务和任务的 ID
const middleJob = queue[middle];
const middleJobId = getId(middleJob);
// 如果中间任务的 ID 小于给定 ID,
// 或者中间任务的 ID 等于给定 ID 且pre为 true,
// 将起始索引移到中间索引的后一位
if (middleJobId < id || (middleJobId === id && middleJob.pre)) {
start = middle + 1;
} else {
// 否则,将结束索引移到中间索引的位置
end = middle;
}
}
// 返回插入位置的索引
return start;
}
}
使用findInsertionIndex
的前提是任务需要有id
,因此这里默认存在id
,通过二分查找的方式,保证队列的任务执行顺序是递增的。
这里的递增有两个判断确保,id
不等的情况下,id
自增。
id
相同的情况下,pre:true
的任务在前面。这样,可以确保任务队列的有序性。
接下来看看queueFlush
。
typescript
function queueFlush() {
// 如果没有队列在执行且没有等待执行的标志时
if (!isFlushing && !isFlushPending) {
isFlushPending = true; // 设置等待执行的标志为 true
currentFlushPromise = resolvedPromise.then(flushJobs); // 使用 Promise 确保会在下一个事件循环中执行
}
}
很简单的逻辑,就是确保当前任务队列执行完成之后,触发下一轮队列的执行,他通过设置isFlushPending
为true
,来表示等待执行。
然后使用Promise
创建一个微任务 ,来却确保flushJobs
在下个事件循环的执行。
这样,在当前的任务队列执行结束后,下一轮任务队列就会被触发。这种机制确保了任务队列的顺序执行,避免了并发执行可能引发的问题。
resolvedPromise
实际上是Promise.resolve()
,必定会走到flushJobs
中的,所以看看flushJobs
是个什么东西。
typescript
// 定义比较器函数,用于比较两个任务的优先级
const comparator = (a: SchedulerJob, b: SchedulerJob): number => {
// 获取任务的 ID,用于比较任务的顺序
const diff = getId(a) - getId(b);
// 如果任务的 ID 相等,考虑任务的pre来确定优先级
if (diff === 0) {
if (a.pre && !b.pre) return -1; // 如果任务 a 是pre任务而任务 b 不是,a 的优先级较高,返回 -1
if (b.pre && !a.pre) return 1; // 如果任务 b 是pre任务而任务 a 不是,b 的优先级较高,返回 1
}
// 返回任务的比较结果
return diff; // 返回任务 ID 的差值,正数表示 a 的优先级较高,负数表示 b 的优先级较高,零表示两者优先级相等
};
// 执行任务队列中的任务
function flushJobs(seen?: CountMap) {
isFlushPending = false; // 清除等待执行的标志
isFlushing = true; // 设置正在执行的标志为 true
// 在执行前对任务队列进行排序,以确保任务按照一定的顺序执行:
// 1. 组件的更新从父组件到子组件执行(因为父组件总是在子组件之前创建,所以它的渲染效果的优先级数字较小)
// 2. 如果一个组件在父组件的更新期间被卸载,它的更新可以被跳过。
queue.sort(comparator);
try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex];
if (job && job.active !== false) {
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER); // 执行任务
}
}
} finally {
flushIndex = 0;
queue.length = 0;
flushPostFlushCbs(seen); // 执行post任务
isFlushing = false; // 任务执行结束
currentFlushPromise = null;
// 如果任务队列中还有任务,或者还有待执行的post任务,继续执行任务队列
if (queue.length || pendingPostFlushCbs.length) {
flushJobs(seen);
}
}
}
这个函数主要用于执行任务队列的任务,他按照任务的优先级,进行了排序,而排序逻辑就是id
相同的,优先执行pre
,整体是以id
从小到大递增。
然后通过for
循环执行任务队列中的任务,任务被检测到可能引发递归更新,会跳过该任务,避免无限循环。
函数执行完成后,会检查任务队列是否还有剩余的任务或者待执行的post
任务,如果有,则继续执行flushJobs
。
这里可能有人有疑问了,明明queue.length
设置为0
,为什么还要在后面检测queue.length
的大小。
那是因为flushPostFlushCbs
可能会再次往queue
推入任务。
queuePostFlushCb
还记得上一节说的,如果watch
的flush
是post
,也就是watchPostEffect
,那么响应函数会通过queuePostRenderEffect
进行包装。queuePostRenderEffect
在当前渲染周期结束后执行。也就是延后执行。
而queuePostRenderEffect
实际上是queuePostFlushCb
的别名。而queuePostFlushCb
是什么呢?
typescript
export function queuePostFlushCb(cb: SchedulerJobs) {
// 如果cb不是数组
if (!isArray(cb)) {
// 如果当前没有活跃的post任务,或者任务不在队列中(根据 allowRecurse 标志决定是否可重复添加)
if (
!activePostFlushCbs ||
!activePostFlushCbs.includes(
cb,
cb.allowRecurse ? postFlushIndex + 1 : postFlushIndex
)
) {
pendingPostFlushCbs.push(cb); // 将任务post队列
}
} else {
// 如果cb是数组,说明它是一个组件的生命周期钩子,它只能由任务触发
// 而任务在主队列中已经去重,所以这里可以跳过重复检查,提高性能
pendingPostFlushCbs.push(...cb); // 将数组中的任务全部加入到待执行的post任务数组中
}
queueFlush();
}
同queueJob
类似,不过这里是将post
的任务推入pendingPostFlushCbs
中,那么pendingPostFlushCbs
会被谁来消费呢?没错,就是上文的flushPostFlushCbs
typescript
export function flushPostFlushCbs(seen?: CountMap) {
// 如果有待执行的post任务
if (pendingPostFlushCbs.length) {
// 去除重复的任务
const deduped = [...new Set(pendingPostFlushCbs)];
pendingPostFlushCbs.length = 0; // 清空待执行的post任务队列
// 如果已经存在活跃的post任务,则将去重后的任务添加到队列中
if (activePostFlushCbs) {
activePostFlushCbs.push(...deduped);
return;
}
activePostFlushCbs = deduped; // 将去重后的队列设置为活跃的post任务队列
// 根据任务的 ID 进行排序,以确保它们按照一定的顺序执行
activePostFlushCbs.sort((a, b) => getId(a) - getId(b));
// 依次执行post任务队列
for (postFlushIndex = 0; postFlushIndex < activePostFlushCbs.length; postFlushIndex++) {
activePostFlushCbs[postFlushIndex](); // 执行
}
activePostFlushCbs = null;
postFlushIndex = 0;
}
}
这个函数主要用于执行post
任务。它会去除重复的任务,并按照回进行排序,以确保post
任务队列按照一定的顺序执行。在执行过程中,如果有新的任务被加入,会在下一轮执行时继续执行。
也就是是执行pendingPostFlushCbs
队列去重后的任务。
因此,上文会说,flushPostFlushCbs
可能会再次往queue推入任务。
比如下面的例子。
typescript
const num = ref(0)
const a = watchPostEffect(() => {
num.value // 收集依赖
const b = watchEffect(() => {
num.value++
})
})
num.value++
最后的num.value ++
会触发watchPostEffect
中函数的执行,也就是执行了flushPostFlushCbs
,而里面的watchEffect
默认是flush
是pre
,在上一节我们讲过,scheduler
会被包装成() => queueJob(job)
,最后作为调度函数执行。
也就是说flushPostFlushCbs
可以通过这种方式,再次往queue
推入任务。
flushPreFlushCbs
我们前文介绍了正常任务(没有id
的)也提到了pre
类型的任务排在同id
前面执行,然后也讲到了post
类型的任务,看起来很完美,但细想上一节,watch
中标记pre
的是要在模板渲染之前执行的。但显然只是一个sort
并无法让pre
类型的任务提前执行。
回忆一下。模板渲染之前调用的方法
typescript
const updateComponentPreRender = (
instance: ComponentInternalInstance,
nextVNode: VNode,
optimized: boolean
) => {
// ....
pauseTracking()
flushPreFlushCbs()
resetTracking()
}
我们发现这里面调用了flushPreFlushCbs
,如果这个方法是执行pre
任务,这样确实是可以实现,在模板渲染之前执行pre
任务的需求。
typescript
export function flushPreFlushCbs(
seen?: CountMap,
// 如果当前正在执行中,跳过当前的任务本身
i = isFlushing ? flushIndex + 1 : 0
) {
// 从指定索引(默认为0)开始遍历任务队列
for (; i < queue.length; i++) {
const cb = queue[i];
if (cb && cb.pre) { // 如果任务存在并且是pre任务
queue.splice(i, 1); // 从队列中移除当前任务
i--; // 减少索引,以便遍历下一个元素
cb(); // 执行任务
}
}
}
这个函数用于执行任务队列中pre
的任务。它从指定索引开始(默认从0
开始),遍历任务队列中的任务函数,找到标记为pre
的任务,然后依次执行他们。在执行过程中,如果某个任务被执行,它将会被从任务队列中移除,以避免重复执行。这样,所有pre
任务都会被有序地执行,确保了它们的执行顺序。
那么问题来了,这里执行了pre
任务,flushJobs
也执行了pre
任务,那么他们到底谁执行pre
任务呢?
从flushPreFlushCbs
的调用时机可以看出来,如果涉及到模板渲染,会由updateComponentPreRender
主动执行flushPreFlushCbs
来执行pre
任务,而非模板渲染更新阶段的事件循环,由flushJobs
执行。
那么,这样调度任务有什么用呢?
答案是这样我们我们将多次重复相似的操作合并起来,让他在渲染模板的时候,只调用最新的值。从而减小多次渲染造成的负担。
比如下面的逻辑
html
<template>
<div>{{num}}</div>
</template>
<script>
import { ref } from "vue"
export default {
setup() {
const num = ref(0)
setTimeout(() => {
for (let i = 0; i < 1000; i++) {
num.value++
}
}, 2000)
return { num }
},
}
</script>
我们知道,更新组件是重新调用componentUpdateFn
,我下面放一下相关代码
typescript
const effect = (instance.effect = new ReactiveEffect(
componentUpdateFn,
() => queueJob(update),
instance.scope
))
const update: SchedulerJob = (instance.update = () => effect.run())
update.id = instance.uid
componentUpdateFn
的调用取决于effect.run
的执行。也就是update
的执行。
并且由于定义update
的时候,将实例的uid
赋值给他。因此这个任务是有id
的。
而这个任务在第一次更新的时候,就被放入了任务队列,
因此第二次num.value
自增,实际数据在ref
的set
赋值了,然后会触发依赖effect
,并且再度触发queueJob(update)
,但由于queueJob
的判断
typescript
!queue.includes(
job,
isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex
) {
// ...往任务队列推
}
显而易见,queue
里面已经有一个job
了,所以不会再次推一个job
,也就是componentUpdateFn
。
所以最后执行的渲染函数是第一次 进入任务队列的渲染函数,但使用的值是最新的值。
nextTick
nextTick
就是基于任务调度实现的,所以我们直接看看他的代码
typescript
export function nextTick<T = void, R = void>(
this: T,
fn?: (this: T) => R
): Promise<Awaited<R>> {
const p = currentFlushPromise || resolvedPromise; // 获取当前的currentFlushPromise,如果不存在则resolvedPromise
return fn ? p.then(this ? fn.bind(this) : fn) : p; // 如果传入了回调函数,返回一个 promise,在下一个事件循环中执行回调函数(如果有 this 上下文,则绑定 this)
}
实际上还是基于Promise
微任务,在下一个事件循环执行传入的回调函数,而这个Promise
,可能是queueFlush
正在执行的事件循环的Promise
,如果没有,那么就使用默认的Promise
,也就是resolvedPromise
。
或者说可以看做近似的立即执行。
所以我们可以得到一个结论,vue3
的queuejob
就是基于Promise
创建的微任务来实现的。