一、前言
vue3.0是如何对框架执行中的任务进行调度的呢?我们知道js是单线程的,如果所有任务都是一鼓作气的同步去执行,会导致主线程的阻塞,那么当你想在表单输入东西时,由于主线程被其他任务占据,所以你的输入任务是无法立即得到响应的。调度系统是为了解决这个问题。
vue的调度系统相对比较轻,并没有像react那样引入fiber这么重的调度算法(做了快两年),而是巧妙的运用了js微任务机制,将更新触发的任务放到一个微任务中,这样就保证在有大量更新任务时,主线程不会被阻塞,等主线程任务执行完毕后,微任务中的更新开始执行。
二、源码分析
先看下调度器声明的一些变量,相对于调度器本身来说,这些是全局变量,用于调度过程中的一些状态记录。
// 任务队列是否正在排空
let isFlushing = false
// 微任务已创建,任务队列等待排空
let isFlushPending = false
// 主任务队列,用于存储更新任务
const queue: (SchedulerJob | null)[] = []
// 当前正在执行的任务在主任务队列中的索引
let flushIndex = 0
// 框架运行过程中产生的前置回调任务,比如一些特定的生命周期
// 这些回调任务是在主任务队列queue开始排空前批量排空执行的
const pendingPreFlushCbs: SchedulerCb[] = []
// 当前激活的前置回调任务
let activePreFlushCbs: SchedulerCb[] | null = null
// 当前前置回调任务在队列中的索引
let preFlushIndex = 0
// 框架运行过程中产生的后置回调任务,比如一些特定的生命周期(onMounted等)
// 这些回调任务是在主任务队列queue排空后批量排空执行的
const pendingPostFlushCbs: SchedulerCb[] = []
// 当前激活的后置回调任务
let activePostFlushCbs: SchedulerCb[] | null = null
// 当前后置回调任务在队列中的索引
let postFlushIndex = 0
// 微任务创建器
const resolvedPromise: Promise<any> = Promise.resolve()
// 当前微任务promise
let currentFlushPromise: Promise<void> | null = null
let currentPreFlushParentJob: SchedulerJob | null = null
// 同一个任务递归执行的上限次数
const RECURSION_LIMIT = 100
// 记录每个任务执行的次数
type CountMap = Map<SchedulerJob | SchedulerCb, number>
queueJob
调度系统的核心处理逻辑,将更新任务推入主任务队列,同时会在合适的时机创建微任务,在微任务中执行任务并排空任务队列,做批量的更新工作。
vue model层更新,并不是立即出发view更新的,原因上面我们也提到了,大量的同步更新,比如一个由父到子的递归更新,函数调用栈会长时间占用主线程,导致线程阻塞无法执行其他更重要的任务。因此vue将更新时产生的任务缓存到任务队列,在微任务中批量执行。
export function queueJob(job: SchedulerJob) {
// 主任务可入队逻辑:1. 队列为空 2. 正在清空队列(有正在执行的任务)且当前待入队任务
// 是允许递归执行本身的,由于任务可能递归执行自身,该情况下待入队任务一定和当前执行任务
// 是同一任务,因此待入队任务和正在执行任务相同,但不能和后面待执行任务相同 3. 其他情况下,
// 由于不会出现任务自身递归执行的情况,因此待入队任务不能和当前正在执行任务以及后面待执
// 行任务相同。
if (
(!queue.length ||
!queue.includes(
job,
isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex
)) &&
job !== currentPreFlushParentJob
) {
// 满足入队条件,将主任务入队
queue.push(job)
// 创建清队微任务
queueFlush()
}
}
queueFlush
创建微任务,isFlushingPending和isFlushing时表示微任务已创建等待执行或者正在执行微任务,这时候是会禁止再次创建更多的微任务,因为在主线程同步任务执行完后才会执行已创建的微任务,此时入队操作已完成,并且flushJobs会在一次微任务中会递归的将主任务队列全部清空,所以只需要一个微任务即可,如果重复创建微任务会导致接下来的微任务执行时队列是空的,那么这个微任务是无意义的,因为它不能清队。
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true
// 微任务创建成功,并记录当前微任务,作为nextTick创建自定义微任务的支点,也就是说,
// nextTick创建出来的微任务执行顺序紧跟在清队微任务后,保证自定义微任务执行时机的
// 准确性
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
flushJob
isFlushingPending状态表示清队微任务已创建,此时js主线程还可能会有其他的同步任务未执行完,因此在主线程同步任务执行完毕前isFlushingPending一直为true,当flushJobs开始执行时,表明清队微任务开始执行,此时isFlushingPending置为false,isFlushing置为true,表示正在清队中。
flushJob大致顺序如下:
批量清空前置回调任务队列 -> 清空主任务队列 -> 批量清空后置回调任务队列
function flushJobs(seen?: CountMap) {
// 关闭isFlushingPending微任务待执行标志位
isFlushPending = false
// 开启isFlushing清队中标志位
isFlushing = true
// 批量执行清空前置回调任务队列
flushPreFlushCbs(seen)
// 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.
// Jobs can never be null before flush starts, since they are only invalidated
// during execution of another flushed job.
// 将主任务队列中的任务按照ID进行排序,原因:1. 组件更新是由父到子的,而更新任务是在数据源
// 更新时触发的,trigger会执行effect中的scheduler,scheduler回调会把effect作为更新
// 任务推入主任务队列,排序保证了更新任务是按照由父到子的顺序进行执行;2. 当一个组件父组件
// 更新时执行卸载操作,任务排序确保了已卸载组件的更新会被跳过
queue.sort((a, b) => getId(a!) - getId(b!))
try {
// 遍历主任务队列,批量执行更新任务
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (job) {
// 执行当前更新任务
// 注意:在isFlushing = true和isFlushing = false之间,
// 主线程在批量执行更新任务(job),但是job中可能会引入新的
// 更新任务入队,此时queue长度会变化,因此下面需要递归清空queue
// 直到队列中的所有任务全部执行完毕
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
}
}
} finally {
// 当前队列任务执行完毕,重置当前任务索引
flushIndex = 0
// 清空主任务队列
queue.length = 0
// 主队列清空后执行后置回调任务
flushPostFlushCbs(seen)
// 清队完毕,重置isFlushing状态值
isFlushing = false
// 当前清队微任务执行完毕,重置currentFlushPromise
currentFlushPromise = null
// 由于清队期间(isFlushing)也有可能会有任务入队,因此会导致按照实微任务开始执行时
// 的队长度遍历清队,可能会导致无法彻底清干净。因此需要递归的清空队伍,保证一次清队
// 微任务中所有任务队列都被全部清空
if (queue.length || pendingPostFlushCbs.length) {
flushJobs(seen)
}
}
}