引入
scheduler是Vue 3中负责管理异步更新的模块,实现了一个任务调度和执行系统,其源码位于runtime-core/src/scheduler.ts。它主要包含以下几个函数:
queueJob:负责将任务添加至任务队列queue中,并通过调用queueFlush异步执行flushJobsqueuePostFlushCb:负责将DOM更新结束后需要执行的任务添加至pendingPostFlushCbs队列中flushJobs:执行所有位于任务队列queue和pendingPostFlushCbs中的任务。nextTick:负责在DOM更新后执行回调
本文将主要介绍queueJob、flushJobs和nextTick的实现原理,并回答以下问题:
Vue如何实现异步执行更新- 如何保证刷新时机为
pre的任务 先于DOM更新 先于 刷新时机为post的任务 - 如何保证
nextTick回调在所有DOM更新结束后执行
最后一部分总结从源码当中学到的东西以及自己的思考。
queueJob
queueJob源码做了三件事:
- 判断要不要把
job加入到queue中 - 把
job加入到queue中,如果job存在id则通过二分查找插入到正确的地方 - 调用
queueFlush异步执行queue中的任务
export function queueJob(job: SchedulerJob) {
// the dedupe search uses the startIndex argument of Array.includes()
// by default the search index includes the current job that is being run
// so it cannot recursively trigger itself again.
// if the job is a watch() callback, the search will start with a +1 index to
// allow it recursively trigger itself - it is the user's responsibility to
// ensure it doesn't end up in an infinite loop.
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()
}
}
首先第一步,判断是否把job插入queue中。这里的逻辑是,如果queue为空或者不包含job,则加入。这里不太好理解的地方是isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex。
flushIndex是指目前queue的任务执行到第几个了。例如说目前不处于flushJob中,那么flushIndex自然为0,因为queue中的任务还没有被执行。
而假如说目前正在执行flushJob中的第0个任务,而添加的任务是一个watch回调,那么就是以下情况:
isFlushing: true // 由于正在 flushJob,所以该变量为真
job.allowRecurse: true // watch 回调是允许自己调用自己的
flushIndex: 0
由于isFlushing和job.allowRecurse都为真,所以此时includes的第二个参数就是0+1=1,而如果没有执行任务或是正在添加的任务不是watch回调,那么includes的第二个参数就是0。
为什么要这么设计?其原因在源码中的英文注释写的很清楚。因为对于watch,我们允许它调用自己,从而也应该可以将自己添加到queue中,因此includes检查时不应该包含自己;而对于允许调用自己以外的情况,需要考虑从自己的位置开始,是否已经添加过任务到队列中,如果已经添加过就不再添加。
第二步,把job插入到queue中。这一步是根据job.id来判断的。如果job不存在id,则直接插入到最后;如果存在id,则通过二分查找加入到队列:
// #2768
// Use binary-search to find a suitable position in the queue,
// so that the queue maintains the increasing order of job's id,
// which can prevent the job from being skipped and also can avoid repeated patching.
function findInsertionIndex(id: number) {
// the start index should be `flushIndex + 1`
let start = flushIndex + 1
let end = queue.length
while (start < end) {
const middle = (start + end) >>> 1
const middleJobId = getId(queue[middle])
middleJobId < id ? (start = middle + 1) : (end = middle)
}
return start
}
这个二分查找找到了,把i插入到一个递增数组中,保持数组递增,所对应的插入的下标。
至于为什么要通过按顺序插入来保证flush时的id是递增顺序,并不是一个很好解释的事情(毕竟有些bug是coding的时候很难考虑到的,只有遇到了问题之后,追根溯源才能想到解决办法),如果想了解详见PR #3184。
第三步,调用queueFlush异步执行任务队列中的任务,本质上是进行一次任务调度。
这里的if判断很好理解,意思是如果当前不在执行任务(如果正在执行任务就不需要调度了,直接把任务添加到队列中,等着执行到自己就好了)并且isFlushPending为假(即当前tick中queueFlush还没有被执行过。如果isFlushPending为真,表示等待flush,说明已经调度过了,无需再次调度)
之后通过resolvedPromise.then开启异步任务,在所有同步任务执行后执行flushJobs(回忆一下事件循环就懂了)。值得注意的是,这里把resolvedPromise.then返回的promise赋值给了currentFlushPromise,这是为nextTick所准备的,后面会讲到。
所以到这里我们明白了**Vue是如何执行异步更新的**,本质就是把所有的更新任务存到任务队列当中,然后再异步执行任务队列中的任务。
const resolvedPromise = /*#__PURE__*/ Promise.resolve() as Promise<any>
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
flushJobs
首先置isFlushPending = false和isFlushing = true,表示正在flush而不是等待flush。
function flushJobs(seen?: CountMap) {
isFlushPending = false
isFlushing = true
if (__DEV__) {
seen = seen || new Map()
}
// 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)
// conditional usage of checkRecursiveUpdate must be determined out of
// try ... catch block since Rollup by default de-optimizes treeshaking
// inside try-catch. This can leave all warning code unshaked. Although
// they would get eventually shaken by a minifier like terser, some minifiers
// would fail to do that (e.g. https://github.com/evanw/esbuild/issues/1610)
const check = __DEV__
? (job: SchedulerJob) => checkRecursiveUpdates(seen!, job)
: NOOP
try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (job && job.active !== false) {
if (__DEV__ && check(job)) {
continue
}
// console.log(`running:`, job.id)
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
}
}
} finally {
flushIndex = 0
queue.length = 0
flushPostFlushCbs(seen)
isFlushing = false
currentFlushPromise = null
// some postFlushCb queued jobs!
// keep flushing until it drains.
if (queue.length || pendingPostFlushCbs.length) {
flushJobs(seen)
}
}
}
接着对任务队列进行排序,把job.id小的放在前面。如果id相同,表示是同一个实例下的任务,这时候把有pre属性的放在前面,优先执行这些任务。
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
}
之所以按id排序是因为要保证组件按从parent到child的顺序执行更新任务。而组件内部优先执行pre的任务是为了保证组件内部DOM更新是最后被执行的任务。
考虑两个组件parent和child,各自都有一个watch和DOM更新的任务等待执行,那么由于watch有pre属性而DOM diff任务没有pre,因此顺序如下:
parent.watch -> parent.patch -> child.watch -> child.patch
我们可以看到,通过排序,Vue保证了组件内部所有刷新时机为pre的任务均先于DOM更新,这回答了本文最开始提出的问题2的一半。
接下来的for循环就是执行任务队列中的每个job。这里check函数的目的在于检查可能出现的无限递归更新,比如
const num = ref(0)
watch(num.value, () => {
num.value++
})
这种情况肯定会导致无限递归,因此会在check中报警告并不再重复执行该job。
在finally代码块中,通过flushPostFlushCbs执行了所有刷新时机为post的任务,原理也是排序后for循环执行,不再讲解。这里我们发现由于try代码块中已经执行了所有的DOM更新,所以post任务一定会晚于DOM更新,这回答了问题2的另一半。
nextTick
如果你还记得在queueFlush中把resolvedPromise.then返回的promise赋值给了currentFlushPromise,你应该就能明白nextTick的原理了:
当所有同步任务执行完毕后,会调用通过resolvedPromise.then安排的异步任务flushJobs,而当flushJobs执行完毕后,currentFlushPromise才会resolve,所以此时才会执行nextTick的回调fn。
这就像一个链式调用resolvedPromise.then(flushJobs).then(fn)。
当然,如果没有传入fn,则nextTick则会直接返回这个promise,从而用户可以通过await该promise,等待DOM更新完毕。
至此,我们回答了“如何保证nextTick回调在所有DOM更新结束后执行”这个问题:Vue是通过两个promise实现的,本质其实是一个promise的链式调用。
export function nextTick<T = void>(
this: T,
fn?: (this: T) => void
): Promise<void> {
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(this ? fn.bind(this) : fn) : p
}
思考与总结
-
通过
Vue的源码学习到了调度的思想,以及使用异步来调度的一种做法。这个功能一开始的需求可能是,所有的响应式更新(watch更新、计算属性更新、DOM更新)不立即执行,而是存储到一个队列当中,等到一个时间节点,统一执行。Vue通过异步调度,实现了在所有同步任务结束后这个时间节点执行所有的任务,而且通过排序,把任务分为了pre和post。我觉得无论是idea还是solution都是很棒的,如果自己设计肯定都不会想到做一个scheduler系统,而是会出现重复的更新。 -
学习到了链式调用的变形,其实本质还是链式调用只是可能不好看出来。使用链式调用可以实现异步任务执行顺序的调度。
-
nextTick和onUpdated生命周期钩子都是在DOM更新后执行回调,它们有什么区别?官方文档的解释是:这个解释乍看之下不是很好懂,其实它的意思是两者应用于不同的场景当中:
-
onUpdated用在每次DOM更新都需要做某些事情的需求中,比如每次DOM更新你都需要打印出来DOM的状态或是查看某个变量,这时候使用该生命周期钩子 -
nextTick用在某个特定状态改变后,此时DOM还没更新,这时候你可以通过nextTick等待DOM更新,从而可以访问到更新后的DOM,例如官方文档的这个例子:<script setup> import { ref, nextTick } from 'vue' const count = ref(0) async function increment() { count.value++ // DOM 还未更新 console.log(document.getElementById('counter').textContent) // 0 await nextTick() // DOM 此时已经更新 console.log(document.getElementById('counter').textContent) // 1 } </script> <template> <button id="counter" @click="increment">{{ count }}</button> </template>
-