注:文本中的源码摘自tag:v3.3.11,删减了代码中逻辑无关的代码
1. 基本流程

scheduler内部存在两个job队列
queuependingPostFlushCbs
调度器在执行这两个队列中的job时,优先执行queue,当queue被清空后,再去执行pendingPostFlushCbs中的job。
清空这两个队列的代码为:大体上看懂即可,后面会继续解释
typescript
// packages/runtime-core/src/scheduler.ts
function flushJobs() {
isFlushPending = false // 是否正在等待清空
isFlushing = true // 是否正在清空两个job队列
// 对queue进行排序,我们这里暂时不讨论
queue.sort(comparator)
try {
// 遍历queue队列
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
// 只有active为true的job才执行
if (job && job.active !== false) {
// 执行job
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
}
}
} finally {
// queue队列清空完毕
flushIndex = 0
queue.length = 0
// 开始清空pendingPostFlushCbs队列
flushPostFlushCbs()
// 两个队列都清空完毕
isFlushing = false
currentFlushPromise = null
// 在清空pendingPostFlushCbs队列时
// 其中的job可能会向queue中添加新的job
// 如果length不为0 表示添加了新的job
// 需要再次启动刷新流程 直到清空为止
if (queue.length || pendingPostFlushCbs.length) {
flushJobs()
}
}
}
function flushPostFlushCbs() {
if (pendingPostFlushCbs.length) {
// 对pendingPostFlushCbs队列中的job进行去重
const deduped = [...new Set(pendingPostFlushCbs)]
pendingPostFlushCbs.length = 0
// 执行的job可能会调用flushPostFlushCbs
if (activePostFlushCbs) {
// 只需要将新添加到pendingPostFlushCbs中的job
// 添加到activePostFlushCbs执行
activePostFlushCbs.push(...deduped)
return
}
// 去重后的job,放到activePostFlushCbs中执行
activePostFlushCbs = deduped
// 需要根据job的id进行排序
activePostFlushCbs.sort((a, b) => getId(a) - getId(b))
// 遍历activePostFlushCbs执行job
for (
postFlushIndex = 0;
postFlushIndex < activePostFlushCbs.length;
postFlushIndex++
) {
activePostFlushCbs[postFlushIndex]()
}
// pendingPostFlushCbs已清空
activePostFlushCbs = null
postFlushIndex = 0
}
}
问题1:什么时候开始清空队列
上面我们介绍了清空队列的流程,但怎么触发一轮清空流程呢?
给queue或者queuePostFlushCbs中添加job后,就会给微任务队列添加一个清空队列的微任务。
先看一下向两个队列中添加job的方法:
typescript
// 向queue队列添加一个job
export function queueJob(job: SchedulerJob) {
// ...
queueFlush()
}
export type SchedulerJobs = SchedulerJob | SchedulerJob[]
// 向pendingPostFlushCbs添加一个或多个job
export function queuePostFlushCb(cb: SchedulerJobs) {
// ...
queueFlush()
}
这两个方法都会调用queueFlush开启清空流程:
typescript
const resolvedPromise = /*#__PURE__*/ Promise.resolve() as Promise<any>
function queueFlush() {
if (!isFlushing && !isFlushPending) {
// 清空队列的微任务已经发布
// 再下一行的代码就是发布微任务
isFlushPending = true
// 将flushJobs添加到微任务队列
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
这里涉及到两个关键的变量:
isFlushPending:是否在等待清空队列(清空队列的微任务是否已经发布)。isFlushing:是否正在清空两个队列。
在初始状态时,它们的值都为false:
typescript
let isFlushing = false
let isFlushPending = false
当给某一个队列添加job时(通过queueJob或者queuePostFlushCb),会调用queueFlush准备清空队列。在清空队列的flushJobs执行之前,可能会多次调用queueJob和queuePostFlushCb,我们只需要在第一次添加job的时候发布一个微任务即可,isFlushPending就是在这里发挥作用的。
isFlushPending在发布微任务flushJobs时置为trueflushJobs开始执行时,isFlushPending置为false
在清空队列的过程中,队列中的job也可能通过queueJob或者queuePostFlushCb给队列添加job,这个时候,正在执行的方法就是flushJobs,不需要重新发布一个微任务执行flushJobs,isFlushing在这里发挥作用。
isFlushing在flushJobs开始执行时置为true。isFlushing在flushJobs结束执行时置为false。
问题2:怎么保证nextTick时队列已经清空完毕了
首先我们来看一下nextTick的实现:
typescript
export function nextTick<T = void, R = void>(
this: T,
fn?: (this: T) => R
): Promise<Awaited<R>> {
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(this ? fn.bind(this) : fn) : p
}
我们先不管currentFlushPromise是干啥的,nextTick就是发布了一个微任务。再回顾一下我们上面已经讨论过的清空队列,整个清空过程都是同步的,即使添加了新的job,也不会重新发布执行flushJobs的微任务,直到两个队列都被清空。所以,这里我们再发布的微任务,一定是再清空队列的微任务后面。结合之前的图片,可以更好的理解:

问题3:currentFlushPromise有什么用
现在我们再回头看一下nextTick中有一个currentFlushPromise,这个有什么用?nextTick只需要使用resolvedPromise发布一个微任务就好了,为什么需要这个promise?答案就是:捕获清空队列过程中,job抛出的异常
如果没有currentFlushPromise这个promise,会发生什么情况呢?
javascript
const resolvedPromise = Promise.resolve()
// 用来模拟清空队列 我们直接抛出一个错误
function flushJobs() {
const job = function() {
throw 'error'
}
job()
}
// 发布清空队列的微任务
function queueFlush() {
resolvedPromise.then(flushJobs)
}
// 删除掉currentFlushPromise的nextTick
function nextTick(self, fn) {
const p = resolvedPromise
return fn ? p.then(self ? fn.bind(self) : fn) : p
}
// 测试代码
async function test() {
// 发布一个清空队列的微任务
queueFlush()
try {
// 希望在这里可以捕获清空队列过程中的错误
await nextTick()
} catch(e) {
console.warn(e)
}
}
// 执行测试代码,捕获失败
test() // Uncaught (in promise) error
如果希望await nextTick()抛出一个错误,nextTick就需要返回一个"被拒绝的"(rejected)promise。而上面的我们的代码中,如果我们不给nextTick传递参数,nextTick返回的是resolvedPromise,这个promise是一个"已兑现"(fulfilled)的promise。
所以,我们需要在清空job队列时,创建一个currentFlushPromise,用来帮助我们捕获job执行过程中的异常:(对上面的代码做以下修改)
javascript
let currentFlushPromise = null
function flushJobs() {
const job = function() {
throw 'error'
}
try {
job()
} finally {
currentFlushPromise = null
}
}
function queueFlush() {
currentFlushPromise = resolvedPromise.then(flushJobs)
}
function nextTick(self, fn) {
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(self ? fn.bind(self) : fn) : p
}
async function test() {
// 发布一个清空队列的微任务
queueFlush()
try {
// 捕获清空队列过程中的错误
await nextTick()
} catch(e) {
console.warn(e)
}
// 这里不会再次抛出异常
await nextTick()
}
test()
再次执行test(),job中抛出的错误已经可以被捕获了。我们理一下代码的执行流程:

- 当执行了
queueFlush之后,会通过.then(flushJobs)创建一个currentFlushPromise。 - 在
flushJobs执行之前,我们调用了nextTick(),这个时候就会返回currentFlushPromise,这里会有一个临时的引用,和currentFlushPromise指向同一个Promise对象;代码里并没有体现这个临时对象,为了好理解,我们记为tempPromise。 - 碰到了
await同步代码结束,等待tempPromise被兑现或被拒绝。执行微任务flushJobs。 flushJobs即将执行完成,将currentFlushPromise置为null。flushJobs执行完成,currentFlushPromise被兑现(或者被拒绝)。下一个微任务切换到之前的异步代码继续执行。await解包tempPromise,如果job抛出错误,这个Promise对象就"已拒绝"的状态,对它解包会抛出错误。
问题4:job中可能会发布新的job,怎么保证queue和pendingPostFlushCbs的相对顺序
如果当前正在执行的job是queue中的job,则在调用queueJob和queuePostFlushCbs发布新的job时,会分别放入两个队列中,执行的流程不会有区别,还是先清空queue,再清空pendingPostFlushCbs。
如果当前正在执行的job是在queuePostFlushCbs,也可以调用queueJob和queuePostFlushCbs发布新的job。为了可以重新开始清空queue,flushJobs在清空完queuePostFlushCbs之后,又递归调用flushJobs,开始新一轮的清空。在flushPostFlushCbs中,并不是遍历pendingPostFlushCbs本身,而是创建了新的activePostFlushCbs。这是为了在本轮清空pendingPostFlushCbs时,如果给pendingPostFlushCbs发布了新的job,在下一轮清空队列时(调用flushJobs)再执行新的job,否则就无法保证新添加的queue job优先于新添加的pendingPostFlushCbs job。
2. 任务优先级控制
上面已经详细介绍了scheduler整体的运行流程,但是,job的执行并不一定是按照入队列的顺序执行;例如,通过watch可以注册监听函数,同时可以设置参数flush指定执行的时间,我们简单的看一下watch关于这部分的源码:
typescript
// packages/runtime-core/src/apiWatch.ts
// doWatch
if (flush === 'sync') {
scheduler = job as any // the scheduler function gets called directly
} else if (flush === 'post') {
scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
} else {
// default: 'pre'
job.pre = true
if (instance) job.id = instance.uid
scheduler = () => queueJob(job)
}
可以看到,是通过scheduler实现优先级控制的,flush为pre的job一定优先于flush为post的job,这是我们前面一小节的问题4就讨论过的。
但是,仅有这样的控制粒度还是不够,我们再回过头看flushjobs的代码,在清空queue队列之前,对queue进行了依次排序,这里在源码中有详细的注释:
typescript
// Sort queue before flush.
// This ensures that:
// 1. Components are updated from parent to child. (because parent is always
// created before the child so its render effect will have smaller
// priority number)
// 2. If a component is unmounted during a parent component's update,
// its update can be skipped.
queue.sort(comparator)
即,组件更新时,先更新子组件,后更新父组件,因此,通过scheduler进行调度时,就需要保证这个顺序。在组件实例创建时,组件实例会有一个递增的id;创建组件实例是按照先父组件,后子组件的顺序。因此,组件实例的id就有父组件小,子组件大的关系。
因此,组件更新job会有一个可选的id属性,被指定为组件实例的id,这样就可以保证通过scheduler调度,按照id升序排列job,先更新父组件,后更新子组件。
除了组件更新之外,还有通过watch发布的flush为pre的任务同样在queue队列中,需要保证一个组件在更新之前,先执行flush为pre的watch监听器。因此,对于相同id的job,优先执行被打了pre标记的job(上面watch的部分源码中,可以看到flush为pre的job会设置一个pre标记)。
这里涉及到了其他部分的源码,当然也可以完全不用理会其他的部分,我们直接从comparator这个函数看出是怎么规定优先级的:
typescript
const getId = (job: SchedulerJob): number =>
job.id == null ? Infinity : job.id
const comparator = (a: SchedulerJob, b: SchedulerJob): number => {
const diff = getId(a) - getId(b)
if (diff === 0) {
if (a.pre && !b.pre) return -1
if (b.pre && !a.pre) return 1
}
return diff
}
- 如果一个
job没有设置id,则认为它的id是无穷大 id较小的job,优先级较高(被排序到前面)- 如果
id相同,则设置了pre标记的job优先级更高
当然,除了queue队列有优先级,pendingPostFlushCbs也有优先级,在清空该队列的flushPostFlushCbs函数中,按照id升序的方式进行排序。
typescript
activePostFlushCbs.sort((a, b) => getId(a) - getId(b))
问题:在清空队列的过程中,如果添加了新job,怎么保证优先级
上面已经说过,在flushJobs中,清空queue之前,需要对queue排序,这样保证job按照正确的优先级执行。
但是,如果在执行job的过程中,添加了新的job,要怎么保证优先级?这当然只能在入队列的时候进行处理:
typescript
export function queueJob(job: SchedulerJob) {
if (
!queue.length ||
!queue.includes(
job,
isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex
)
) {
if (job.id == null) {
queue.push(job)
} else {
queue.splice(findInsertionIndex(job.id), 0, job)
}
queueFlush()
}
}
最外层的那个if条件我们先不看,先看一下它是怎么入队列的:
- 如果
id是null或者undefined,就放在队列的最后面。这和我们之前讨论的一致:如果id == null,getId()返回Infinity,就应该排在后面。 - 如果
id不是null,则需要通过findInsertionIndex找到插入的位置,然后插入到队列中。
typescript
// 二分法寻找插入位置
function findInsertionIndex(id: number) {
// flushIndex表示当前正在执行queue队列中的哪一个job
// 在flushjobs()函数中 遍历queue队列时,使用一个全局的flushIndex
// 因此,搜索开始的位置是从下一个job开始
let start = flushIndex + 1
let end = queue.length
while (start < end) {
const middle = (start + end) >>> 1
const middleJob = queue[middle]
const middleJobId = getId(middleJob)
// middleJob优先级更高的条件:
// 1. middleJobId的id更小
// 2. id相同,但middleJob设置了pre标志
if (middleJobId < id || (middleJobId === id && middleJob.pre)) {
start = middle + 1
} else {
end = middle
}
}
return start
}
而对于queuePostFlushCb,在它的源码中,没有关于排序的内容:
typescript
export function queuePostFlushCb(cb: SchedulerJobs) {
if (!isArray(cb)) {
if (
!activePostFlushCbs ||
!activePostFlushCbs.includes(
cb,
cb.allowRecurse ? postFlushIndex + 1 : postFlushIndex
)
) {
pendingPostFlushCbs.push(cb)
}
} else {
pendingPostFlushCbs.push(...cb)
}
queueFlush()
}
没有排序,怎么保证新添加到pendingPostFlushCbs的job按照优先级顺序执行? 这又要回到flushPostFlushCbs函数中,每一次调用flushPostFlushCbs:
- 保存
pendingPostFlushCbs到一个去重的副本activePostFlushCbs; - 对
activePostFlushCbs进行排序 - 遍历
activePostFlushCbs,执行job
可以看到,就算在第3步时,发布了新的任务到pendingPostFlushCbs,也不会影响这一轮的顺序;在本次flushPostFlushCbs完成后,会递归地开启新的一轮flushJobs,然后再调用flushPostFlushCbs完成排序。
3. 处理递归与去重
假设有这样一段代码:
typescript
function testJob() {
//...
queueJob(testJob);
}
queueJob(testJob);
如果queueJob()不进行特殊处理,testJob就会一直被递归地添加到queue队列中;queueJob()在添加job时,如果该job正在被执行,则说明发生了递归添加job的情况,默认情况下,job不会入队。
这个情况也很好处理,我们自己写一下这部分的伪代码:
typescript
export function queueJob(job: SchedulerJob) {
// 如果当前正在清空队列且正在执行的job与要添加的job相同
// 则说明出现了递归发布job的情况
// 需要将该job丢弃
if (!isFlushing || queue[flushIndex] !== job) {
// 插入部分地代码上面已经介绍过 这里就不贴了
}
}
当然,我们的逻辑和源码还是有区别的,源码还帮我们做了去重;也就是说,如果队列中已经存在的job,就不需要再次入队了,我们再补充一下上面的条件:
typescript
export function queueJob(job: SchedulerJob) {
if ((!isFlushing || queue[flushIndex] !== job)
// 之后要执行的job中不存在当前job
&& !queue.contains(job, isFlushing ? 0 : flushIndex + 1)) {
// 插入部分地代码上面已经介绍过 这里就不贴了
}
}
如果job被设置了allowRecurse标志,则允许添加,如果我们接着在上面的代码中补充条件,会比较混乱。我们来看一下源码是如何巧妙地处理的:
typescript
// 直接把去重和递归处理合并为了一句
// 如果允许递归,就从下一个位置开始检查是否有重复
// 而不管当前执行的job和要添加的job是否相同
//
// 如果不允许递归,则从当前位置开始检查是否有重复
!queue.includes(
job,
isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex
)
同样的,queuePostFlushCb也进行了同样的处理,这里就不再赘述了。
4. 其他函数
源码中还剩下两个方法,都比较简单,这里就简单介绍一下:
typescript
// 把job从queue队列中移除
export function invalidateJob(job: SchedulerJob) {
const i = queue.indexOf(job)
if (i > flushIndex) {
queue.splice(i, 1)
}
}
typescript
export function flushPreFlushCbs(
instance?: ComponentInternalInstance,
seen?: CountMap,
// 如果正在清空队列 则从当前job的下一个位置开始
i = isFlushing ? flushIndex + 1 : 0
) {
for (; i < queue.length; i++) {
const cb = queue[i]
if (cb && cb.pre) {
if (instance && cb.id !== instance.uid) {
continue
}
queue.splice(i, 1)
i--
cb()
}
}
}
清空queue队列中的设置了pre标志的任务,如果参数中传递了组件实例,则跳过该组件实例对应的job。