Vue 源码解析(六):scheduler

1,073 阅读7分钟

引入

schedulerVue 3中负责管理异步更新的模块,实现了一个任务调度和执行系统,其源码位于runtime-core/src/scheduler.ts。它主要包含以下几个函数:

  • queueJob:负责将任务添加至任务队列queue中,并通过调用queueFlush异步执行flushJobs
  • queuePostFlushCb:负责将DOM更新结束后需要执行的任务添加至pendingPostFlushCbs队列中
  • flushJobs:执行所有位于任务队列queuependingPostFlushCbs中的任务。
  • nextTick:负责在DOM更新后执行回调

本文将主要介绍queueJobflushJobsnextTick的实现原理,并回答以下问题:

  • Vue如何实现异步执行更新
  • 如何保证刷新时机为pre的任务 先于 DOM更新 先于 刷新时机为post的任务
  • 如何保证nextTick回调在所有DOM更新结束执行

最后一部分总结从源码当中学到的东西以及自己的思考。

queueJob

queueJob源码做了三件事:

  1. 判断要不要把job加入到queue
  2. job加入到queue中,如果job存在id则通过二分查找插入到正确的地方
  3. 调用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

由于isFlushingjob.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是递增顺序,并不是一个很好解释的事情(毕竟有些bugcoding的时候很难考虑到的,只有遇到了问题之后,追根溯源才能想到解决办法),如果想了解详见PR #3184

第三步,调用queueFlush异步执行任务队列中的任务,本质上是进行一次任务调度。

这里的if判断很好理解,意思是如果当前不在执行任务(如果正在执行任务就不需要调度了,直接把任务添加到队列中,等着执行到自己就好了)并且isFlushPending为假(即当前tickqueueFlush还没有被执行过。如果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 = falseisFlushing = 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排序是因为要保证组件按从parentchild的顺序执行更新任务。而组件内部优先执行pre的任务是为了保证组件内部DOM更新是最后被执行的任务。

考虑两个组件parentchild,各自都有一个watchDOM更新的任务等待执行,那么由于watchpre属性而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,从而用户可以通过awaitpromise,等待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
}

思考与总结

  1. 通过Vue的源码学习到了调度的思想,以及使用异步来调度的一种做法。这个功能一开始的需求可能是,所有的响应式更新(watch更新、计算属性更新、DOM更新)不立即执行,而是存储到一个队列当中,等到一个时间节点,统一执行。Vue通过异步调度,实现了在所有同步任务结束后这个时间节点执行所有的任务,而且通过排序,把任务分为了prepost。我觉得无论是idea还是solution都是很棒的,如果自己设计肯定都不会想到做一个scheduler系统,而是会出现重复的更新。

  2. 学习到了链式调用的变形,其实本质还是链式调用只是可能不好看出来。使用链式调用可以实现异步任务执行顺序的调度。

  3. nextTickonUpdated生命周期钩子都是在DOM更新后执行回调,它们有什么区别?官方文档的解释是:

    image.png

    这个解释乍看之下不是很好懂,其实它的意思是两者应用于不同的场景当中:

    • 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>