Vue3-scheduler源码浅析

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

1. 基本流程

scheduler内部存在两个job队列

  1. queue
  2. 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)
  }
}

这里涉及到两个关键的变量:

  1. isFlushPending:是否在等待清空队列(清空队列的微任务是否已经发布)。
  2. isFlushing:是否正在清空两个队列。

在初始状态时,它们的值都为false

typescript 复制代码
let isFlushing = false
let isFlushPending = false

当给某一个队列添加job时(通过queueJob或者queuePostFlushCb),会调用queueFlush准备清空队列。在清空队列的flushJobs执行之前,可能会多次调用queueJobqueuePostFlushCb,我们只需要在第一次添加job的时候发布一个微任务即可,isFlushPending就是在这里发挥作用的。

  1. isFlushPending在发布微任务flushJobs时置为true
  2. flushJobs开始执行时,isFlushPending置为false

在清空队列的过程中,队列中的job也可能通过queueJob或者queuePostFlushCb给队列添加job,这个时候,正在执行的方法就是flushJobs,不需要重新发布一个微任务执行flushJobsisFlushing在这里发挥作用。

  1. isFlushingflushJobs开始执行时置为true
  2. isFlushingflushJobs结束执行时置为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中抛出的错误已经可以被捕获了。我们理一下代码的执行流程:

  1. 当执行了queueFlush之后,会通过.then(flushJobs)创建一个currentFlushPromise
  2. flushJobs执行之前,我们调用了nextTick(),这个时候就会返回currentFlushPromise,这里会有一个临时的引用,和currentFlushPromise指向同一个Promise对象;代码里并没有体现这个临时对象,为了好理解,我们记为tempPromise
  3. 碰到了await同步代码结束,等待tempPromise被兑现或被拒绝。执行微任务flushJobs
  4. flushJobs即将执行完成,将currentFlushPromise置为null
  5. flushJobs执行完成,currentFlushPromise被兑现(或者被拒绝)。下一个微任务切换到之前的异步代码继续执行。
  6. await解包tempPromise,如果job抛出错误,这个Promise对象就"已拒绝"的状态,对它解包会抛出错误。

问题4:job中可能会发布新的job,怎么保证queue和pendingPostFlushCbs的相对顺序

如果当前正在执行的jobqueue中的job,则在调用queueJobqueuePostFlushCbs发布新的job时,会分别放入两个队列中,执行的流程不会有区别,还是先清空queue,再清空pendingPostFlushCbs

如果当前正在执行的job是在queuePostFlushCbs,也可以调用queueJobqueuePostFlushCbs发布新的job。为了可以重新开始清空queueflushJobs在清空完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实现优先级控制的,flushprejob一定优先于flushpostjob,这是我们前面一小节的问题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发布的flushpre的任务同样在queue队列中,需要保证一个组件在更新之前,先执行flushprewatch监听器。因此,对于相同idjob,优先执行被打了pre标记的job(上面watch的部分源码中,可以看到flushprejob会设置一个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
}
  1. 如果一个job没有设置id,则认为它的id是无穷大
  2. id较小的job,优先级较高(被排序到前面)
  3. 如果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条件我们先不看,先看一下它是怎么入队列的:

  1. 如果idnull或者undefined,就放在队列的最后面。这和我们之前讨论的一致:如果id == nullgetId()返回Infinity,就应该排在后面。
  2. 如果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()
}

没有排序,怎么保证新添加到pendingPostFlushCbsjob按照优先级顺序执行? 这又要回到flushPostFlushCbs函数中,每一次调用flushPostFlushCbs

  1. 保存pendingPostFlushCbs到一个去重的副本activePostFlushCbs
  2. activePostFlushCbs进行排序
  3. 遍历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

相关推荐
天涯学馆2 小时前
Next.js与NextAuth:身份验证实践
前端·javascript·next.js
HEX9CF2 小时前
【CTF Web】Pikachu xss之href输出 Writeup(GET请求+反射型XSS+javascript:伪协议绕过)
开发语言·前端·javascript·安全·网络安全·ecmascript·xss
ConardLi2 小时前
Chrome:新的滚动捕捉事件助你实现更丝滑的动画效果!
前端·javascript·浏览器
ConardLi2 小时前
安全赋值运算符,新的 JavaScript 提案让你告别 trycatch !
前端·javascript
积水成江3 小时前
关于Generator,async 和 await的介绍
前端·javascript·vue.js
Z3r4y3 小时前
【Web】portswigger 服务端原型污染 labs 全解
javascript·web安全·nodejs·原型链污染·wp·portswigger
人生の三重奏3 小时前
前端——js补充
开发语言·前端·javascript
计算机学姐3 小时前
基于SpringBoot+Vue的高校运动会管理系统
java·vue.js·spring boot·后端·mysql·intellij-idea·mybatis
Tandy12356_3 小时前
js逆向——webpack实战案例(一)
前端·javascript·安全·webpack
老华带你飞3 小时前
公寓管理系统|SprinBoot+vue夕阳红公寓管理系统(源码+数据库+文档)
java·前端·javascript·数据库·vue.js·spring boot·课程设计