注:文本中的源码摘自tag:v3.3.11
,删减了代码中逻辑无关的代码
1. 基本流程
scheduler
内部存在两个job
队列
queue
pendingPostFlushCbs
调度器在执行这两个队列中的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
时置为true
flushJobs
开始执行时,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
。