七千字深度剖析 Vue3 的调度系统

4,669 阅读19分钟

前言

什么是调度?

调度这一概念最开始应该来自于操作系统。

由于计算机资源的有限性,必须按照一定的原则,选择任务来占用资源。

操作系统引入调度,目的是解决计算机资源的分配问题,因为任务是源源不断的,但 CPU 不能同时执行所有的任务。如:对部分优先级高的任务(如:用户交互需要立即反馈),需要先占用资源/ 运行,这就是一个优先级的调度。

Vue 的调度是什么?有什么不同?

Vue 的调度,行为上也是按照一定的原则,选择任务来占用资源/执行。但同样的行为,目的却是不一样的。

因为,Vue 并不需要解决计算机资源分配的问题(操作系统解决)。Vue 利用调度算法,保证 Vue 组件渲染过程的正确性以及 API 的执行顺序的正确性(不好理解的话可以先看下文)

在 Vue3 的 API 设计中,存在着各种的异步回调 API 设计,如:组件的生命周期,watch API 的回调函数等。这些回调函数,并不是立即执行的,它们都作为任务(Job),需要按照一定的规则/顺序去执行

部分规则如下:

  • watch 的 callback 函数,需要在组件更新前调用
  • 组件 DOM 的更新,需要在响应式数据(Vue 模板依赖的 ref、reactive、data 等数据的变化)更新之后
  • 父组件需要先更新,子组件后更新
  • Mounted 生命周期,需要在组件挂载之后执行
  • updated 生命周期,需要在组件更新之后执行
  • ……

Vue 的 API 设计中就制定了这份规则,在什么时候应该执行什么任务,而这个规则在代码中的实现,就是调度算法

学习 Vue 调度的目的

Vue 不是调度算法发明者,相反,Vue是调度算法的使用者和受益者。这些设计,都是基于先人的探索沉淀,再结合自身需求改造出来的。

前端技术的更新迭代速度非常快,但是这些优秀的设计,却是不变的,这也就是我们学习这些优秀设计的目的,能够做到,以不变应万变。

调度算法基本介绍

调度算法有两个基本数据结构:队列(queue),任务(Job)

image-20220116115525759

  • 入队:将任务加入队列,等待执行
  • 出队:将任务取出队列,立即执行

调度算法有很多种,它们都有不同的目的,但它们的基本数据结构都相同,不同点在于入队和出队的方式

下面是两种常见的调度算法

  • 先来先服务(FCFS):先入队的 Job 先执行。这种算法常见于,Job 平等、没有优先级的场景。
  • 优先级调度算法:优先级高的 Job 先执行。

image-20220117220918349

调度算法里面一点关于 Vue 的东西都没有,如何跟 Vue 扯上关系?

调度算法是对整个调度过程的抽象,算法无需关心任务(Job)的内容是什么,它作为 Vue3 的一种基础设施,起到了解耦的作用(如果暂时还理解不了这句话,下一小节还有解释)

调度算法只调度执行的顺序,不负责具体的执行

那么 Vue 是如何利用调度算法,来实现自身 API 的正确调度的呢? 我们在文章后面会详细描述

Vue3 调度算法的使用

Vue3 的调度算法,与上面提到的算法,大致相同,只是适配了 Vue 的一些细节

Vue 有 3 个队列,分别为:

  • 组件 DOM 更新(不是组件的数据 data 更新)前队列,后面也称为 Pre 队列
  • 组件 DOM 更新(不是组件的数据 data 更新)队列,后面也称为 queue 队列 / 组件异步更新队列
  • 组件 DOM 更新(不是组件的数据 data 更新)后队列,后面也称为 Post 队列

image-20220116201444970

3 个队列的部分特性对比(大概看看即可,后面会详细介绍):

Pre 队列queue 队列Post 队列
队列作用执行组件 DOM 更新之前的任务执行组件 DOM 更新执行组件 DOM 更新之后的任务
出队方式先进先出允许插队,按 id 从小到大执行允许插队,按 id 从小到大执行

整个调度过程中,只有入队过程,是由我们自己控制,整个队列的执行(如何出队),都由队列自身控制

因此:调度算法对外暴露的 API,也只有入队 API:

  • queuePreFlushCb:加入 Pre 队列
  • queueJob: 加入 queue 队列
  • queuePostFlushCb:加入 Post 队列

下面是用法:

const job1 = () => {
    // 假设这里是父组件的 DOM 更新逻辑
    console.log('父组件 DOM 更新 job 1')
}
job1.id = 1		// 设置优先级,Vue 规定是 id 越小,优先级越高

const job2 = () => {
    // 假设这里是子组件的 DOM 更新逻辑
    console.log('子组件 DOM 更新 job 2')
}
job2.id = 2		// 设置优先级

// 加入 queue 队列
// job 2 先加入,但是会在 job 1 之后执行,因为 id 小的,优先级更高
queueJob(job2)
queueJob(job1)

// 加入 Post 队列
queuePostFlushCb(() => {
    // 假设这里是 updated 生命周期
    console.log('执行 updated 生命周期 1')
})
// 加入 Post 队列
queuePostFlushCb(() => {
    // 假设这里是 updated 生命周期
    console.log('执行 updated 生命周期 2')
})

// 加入 Pre 队列
queuePreFlushCb(() => {
    // 假设这里是 watch 的回调函数
    console.log('执行 watch 的回调函数 1')
})
// 加入 Pre 队列
queuePreFlushCb(() => {
    // 假设这里是 watch 的回调函数
    console.log('执行 watch 的回调函数 2')
})
console.log('所有响应式数据更新完毕')

打印结果如下:

// 所有响应式数据更新完毕
// 执行 watch 的回调函数 1
// 执行 watch 的回调函数 2
// 父组件 DOM 更新 job 1
// 子组件 DOM 更新 job 2
// 执行 updated 生命周期 1
// 执行 updated 生命周期 2

队列使用上非常的简单,只要往对应的队列,传入 job 函数即可。队列会在当前浏览器任务的所有 js 代码执行完成后,才开始依次执行 Pre 队列、queue 列、Post 队列

调度算法是对整个调度过程的抽象

这里我们应该能更好的理解这句话,队列只是根据其自身的队列性质(先进先出 or 优先级),选择一个 Job 执行,队列不关心 Job 的内容是什么。

这样的设计,可以极大的减少 Vue API 和 队列间耦合,队列不知道 Vue API 的存在,即使 Vue 未来新增新的异步回调的 API,也不需要修改队列。

在上述例子中:我们大概可以看出,Vue3 是如何使用调度 API,去控制各种类型的异步回调的执行时机的。对于不同的异步回调 API,会根据 API 设计的执行时机,使用不同的队列

如:

  • watch 的回调函数,默认是在组件 DOM 更新之前执行,因此使用 Pre 队列。
  • 组件 DOM 更新,使用 queue 队列。
  • updated 生命周期需要在组件 DOM 更新之后执行,因此使用的是 Post 队列。

image-20220117221400312

本文不会过多的介绍 Job 的具体内容的实现(不同的 API,Job 的内容都是不一样的),而是专注于调度机制的内部实现,接下来我们的深入了解 Vue 的调度机制内部。

名词约定

我们从一个例子中,理解用到的各种名词:

<template>
  <div>{{count}}</div>
  <button @click='add'>Add</button>
</template>
<script setup lang='ts'>
import { ref } from 'vue'

const count = ref(0)

function add() {
  count.value = count.value + 1		// template 依赖 count,修改后会触 queueJob(instance.update)
}
</script>

响应式数据更新

指模板依赖的 ref、reactive、组件 data 等响应式数据的变化

这里指点击按钮触发的 click 回调中,响应式数据 count.value 被修改

组件 DOM 更新

实际上是调用 instance.update 函数,该函数会对比组件 data 更新前的 VNode组件 data 更新后的 VNode,对比之间的差异,修改差异部分的 DOM。该过程叫 patch,比较 vnode 的方法叫 diff 算法(因为这里没有篇幅展开,因此大概看看记住 instance.update 的特点即可)

  • instance 是指 Vue 内部的组件实例,我们直接使用接触不到该实例。

  • instance.update深度更新,即除了会更新组件本身,还会递归调用子组件的 instance.update ,因此,这个过程会更新整个组件树。

  • instance.update更新该组件的属性(如果父组件的传入发生变化),然后更新它对应的 DOM

  • **响应式数据更新 ≠ 组件 DOM **更新,响应式数据更新,只是变量值的改变,此时还没修改 DOM,但会立即执行 queueJob(instance.update),将组件 DOM 更新任务,加入到队列。即数据修改是立即生效的,但 DOM 修改是延迟执行

image-20220116215351750

调度细节

用一个表格总结 3 个调度过程中的一些细节

Pre 队列queue 队列Post 队列
队列作用执行组件 DOM 更新之前的任务执行组件 DOM 更新执行组件 DOM 更新之后的任务
任务去重去重去重去重
出队方式先进先出允许插队,按 id 从小到大执行允许插队,按 id 从小到大执行
任务有效性任务全部有效组件卸载时,对应的任务失效任务全部有效
删除任务不需要特殊情况需要删除任务不需要
Job 递归默认允许默认允许默认允许

接下来我们一个个细节进行解析:

任务去重

每次修改响应式变量(即修改相应的响应式数据),都会将组件 DOM 更新 Job加入队列。

// 当组件依赖的响应式变量被修改时,会立即调用 queueJob
queueJob(instance.update)

那当我们同时修改多次,同一个组件依赖的响应式变量时,会多次调用 queueJob。

下面是一个简单的例子:

<template>
  <div>{{count}}</div>
  <button @click='add'>Add</button>
</template>
<script setup lang='ts'>
import { ref } from 'vue'

const count = ref(0)

function add() {
  count.value = count.value + 1		// template 依赖 count,修改后会触 queueJob(instance.update)
  count.value = count.value + 2		// template 依赖 count,修改后会触 queueJob(instance.update)
}
</script>

count.value 前后两次被修改,会触发两次 queueJob

为了防止多次重复地执行更新,需要在入队的时候,对 Job 进行去重(伪代码):

export function queueJob(job: SchedulerJob) {
  // 去重判断
  if (!queue.includes(job)) {
  	// 入队
    queue.push(job)
  }
}

其他队列的入队函数也有类似的去重逻辑。

优先级机制

只有 queue 队列和 Post 队列,是有优先级机制的,job.id 越小,越先执行

为什么需要优先级队列?

queue 队列和 Post 队列使用优先级的原因各不相同。

我们来逐一分析:

queue 队列的优先级机制

queue 队列的 Job,是执行组件的 DOM 更新。在 Vue 中,组件并不都是相互独立的,它们之前存在父子关系

必须先更新父组件,才能更新子组件,因为父组件可能会传参给子组件(作为子组件的属性)

下图展示的是,父组件和子组件及其属性更新先后顺序:

image-20220121121340442

父组件 DOM 更新前,才会修改子组件的 props,因此,必须要先执行父组件 DOM 更新,子组件的 props 才是正确的值。

因此:父组件优先级 > 子组件优先级

如何保证父组件优先级更高?即如何保证父组件的 Job.id 更小?

我们上一小节说过,组件 DOM 更新,会深度递归更新子组件。组件创建的过程也一样,也会深度递归创建子组件。

下面是一个组件树示意图,其创建顺序如下:

image-20220117210509918

深度创建组件,即按树的深度遍历的顺序创建组件。深度遍历,一定是先遍历父节点,再遍历子节点

因此,从图中也能看出,父组件的序号,一定会比子组件的序号小,使用序号作为 Job.id 即可保证父组件优先级一定大于子组件

这里我们可以感受一下深度遍历在处理依赖顺序时的巧妙作用,前辈们总结出来的算法,竟有如此的妙用。

我们学习源码,学习算法,就是学习这些设计。

当我们以后在项目中,遇到依赖谁先执行的问题,会想起深度遍历这个算法。

要实现 queue 队列 Job 的优先级,我们只需要实现插队功能即可:(伪代码):

export function queueJob(job: SchedulerJob) {
  // 去重判断
  if ( !queue.includes(job) ) {
    // 没有 id 放最后
    if (job.id == null) {
      queue.push(job)
    } else {
      // 二分查找 job.id,计算出需要插入的位置
      queue.splice(findInsertionIndex(job.id), 0, job)
    }
  }
}

Post 队列的优先级机制

先回顾一下我们常常使用到的 Post 队列的 Job,都有哪些:

  • mounted、updated 等生命周期,它们有个共同特点,就是需要等 DOM 更新后,再执行
  • watchPostEffect API,用户手动设置 watch 回调在 DOM 更新之后执行

这些用户设定的回调之间,并没有依赖关系

那为什么 Post 队列还需要优先级呢?

因为有一种内部的 Job,要提前执行,它的作用是,更新模板引用

因为用户编写的回调函数中,可能会使用到模板引用,因此必须要在用户编写的回调函数执行前,把模板引用的值更新

看如下代码:

<template>
  <button @click='add' >count: {{ count }}</button>
  <div v-if="count % 2" :ref="divRef">count 为偶数</div>
  <div v-else :ref="divRef">count 为奇数</div>
</template>
<script setup lang='ts'>
import {onUpdated, ref} from 'vue'

const count = ref(0)

function add() {
  count.value = count.value + 1
}
const divRef = ref<HTMLElement>()

onUpdated(() => {
  console.log('onUpdated', divRef.value?.innerHTML)
})
</script>

响应式变量 count 为奇数或偶数时,divRef.value 指向的 DOM 节点是不一样的。

必须要在用户写的 updated 生命周期执行前,先更新 divRef,否则就会取到错误的值。

因此,更新模板引用的 Job,job.id = -1,会先执行

而其他用户设定的 job,没有设置 job.id,会加入到队列末尾,在最后执行。

失效任务

当组件被卸载(unmounted)时,其对应的 Job 会失效,因为不需要再更新该组件了。失效的任务,在取出队列时,不会被执行。

只有 queue 队列的 Job,会失效。

下面是一个失效案例的示意图:

image-20220121123048216

  1. 点击按钮,count.value 改变
  2. count 响应式变量改变,会立即 queueJob 将子组件 Job 加入队列
  3. emit 事件,父组件 hasChild.value 改变
  4. hasChild 响应式变量改变,会立即 queueJob 将父组件 Job 加入队列
  5. 父组件有更高优先级,先执行。
  6. 更新父组件 DOM,子组件由于 v-if,被卸载
  7. 子组件卸载时,将其 Job 失效,Job.active = false

要实现失效任务不执行,非常简单,参考如下实现(伪代码):

for(const job of queue){
    if(job.active !== false){
        job()
    }
}

删除任务

组件 DOM 更新(instance.update),是深度更新,会递归的对所有子组件执行 instance.update

因此,在父组件深度更新完成之后,不需要再重复更新子组件,更新前,需要将组件的 Job 从队列中删除

下图是任务删除的示意图:

image-20220119205435400

在一个组件 DOM 更新时,会先把该组件的 Job,从队列中删除。因为即将更新该组件,就不需要再排队执行了。

要实现删除 Job,非常简单:

export function invalidateJob(job) {
  // 找到 job 的索引
  const i = queue.indexOf(job)
  // 删除 Job
  queue.splice(i, 1)
}

// 在 instance.udpate 中删除当前组件的 Job
const job = instance.update = function(){
    invalidateJob(job)
    
    // 组件 DOM 更新
}

删除和失效,都是不执行该 Job,它们有什么使用上的区别?

失效删除
场景组件卸载时,将 Job 设置为失效,Job 从队列中取出时,不再执行组件更新时,删除该组件在队列中的 Job
能否再次
加入队列
不能,会被去重可以再次加入队列
意义被卸载的组件,无论它依赖的响应式变量如何更新,该组件都不会更新了删除任务,是因为已经更新过了,不需要重复更新。
如果依赖的响应式变量再次被修改,仍然需要加入队列,等待更新

Job 递归

递归这个特性,是 vue 调度中比较复杂的情况。如果暂时理解不了的,可以先继续往下看,不必过于扣细节。

Job 递归,就是 Job 在更新组件 DOM 的过程中,依赖的响应式变量发生变化,又调用 queueJob 把自身的 Job 加入到队列中

为什么会需要递归?

先做个类比,应该就大概明白了:

你刚拖好地,你儿子就又把地板踩脏了,你只有重新再拖一遍。

如果你一直拖,儿子一直踩,就是无限递归了。。。这时候就应该把儿子打一顿。。。

在组件 DOM 更新(instance.update)的过程中,可能会导致自身依赖的响应式变量改变,从而调用 queueJob,将自身 Job 加入到队列。

由于响应式数据被改变(因为脏了),需要整个组件重新更新(所以需要重新拖地)

下图就是一个组件 DOM 更新过程中,导致响应式变量变化的例子:

image-20220121151108748

父组件刚更新完,子组件由于属性更新,立即触发 watch,emit 事件,修改了父组件的 loading 响应式变量,导致父组件需要重新更新。

(watch 一般情况下,是加入到 Pre 队列等待执行,但在组件 DOM 更新时,watch也是加入队列,但会立即执行并清空 Pre 队列,暂时先记住有这个小特性即可)

Job 的结构是怎样的?

Job 的数据结构如下:

export interface SchedulerJob extends Function {
  id?: number			// 用于对队列中的 job 进行排序,id 小的先执行
  active?: boolean
  computed?: boolean
  allowRecurse?: boolean   // 表示 effect 是否允许递归触发本身
  ownerInstance?: ComponentInternalInstance		// 仅仅用在开发环境,用于递归超出次数时,报错用的
}

job 本身是一个函数,并且带有有一些属性。

  • id,表示优先级,用于实现队列插队,id 小的先执行
  • active:表示 Job 是否有效,失效的 Job 不执行。如组件卸载会导致 Job 失效
  • allowRecurse:是否允许递归

其他属性,我们可以先不关注,因为跟调度机制的核心逻辑无关。

队列的结构是怎样的?

queue 队列的数据结构如下:

const queue: SchedulerJob[] = []

队列的执行:

// 按优先级排序
queue.sort((a, b) => getId(a) - getId(b))
try {
    for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
      const job = queue[flushIndex]
      if (job && job.active !== false) {
        // 执行 Job 函数,并带有 Vue 内部的错误处理,用于格式化错误信息,给用户更好的提示
        callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
      }
    }
  } finally {
    // 清空 queue 队列
    flushIndex = 0
    queue.length = 0
  }

在之前的图示讲解中,为了更好的理解队列,会把 Job 的执行,画成取出队列并执行。

而在真正写代码中,队列的执行,是不会把 Job 从 queue 中取出的,而是遍历所有的 Job 并执行,在最后清空整个 queue。

加入队列

queueJob

下面是 queue 队列的 Job,加入队列的实现:

export function queueJob(job: SchedulerJob) {
  if (
    (!queue.length ||
      // 去重判断
      !queue.includes(
        job,
        // isFlushing 表示正在执行队列
        // flushIndex 当前正在执行的 Job 的 index
        // queue.includes 函数的第二个参数,是表示从该索引开始查找
        // 整个表达式意思:如果允许递归,则当前正在执行的 Job,不加入去重判断
        isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex
      ))
  ) {
    if (job.id == null) {
      // 没有 id 的加入到队列末尾
      queue.push(job)
    } else {
      // 在指定位置加入 job
      // findInsertionIndex 是使用二分查找,找出合适的插入位置
      queue.splice(findInsertionIndex(job.id), 0, job)
    }
    queueFlush()   // 作用会在后面说
  }
}

这里有几个特性:

  • 去重
  • 处理递归,如果允许递归,则正在运行的 job,不加入去重判断
  • 优先级实现,按 id 从小到大,在队列合适的位置插入 Job;如果没有 id,则放到最后

queueCb

Pre 队列和 Post 队列的实现也大致相同,只不过是没有优先级机制(Post 队列的优先级在执行时处理):

function queueCb(
  cb: SchedulerJobs,
  activeQueue: SchedulerJob[] | null,
  pendingQueue: SchedulerJob[],
  index: number
) {
  if (!isArray(cb)) {
    if (
      !activeQueue ||
      // 去重判断
      !activeQueue.includes(cb, cb.allowRecurse ? index + 1 : index)
    ) {
      pendingQueue.push(cb)
    }
  } else {
    // if cb is an array, it is a component lifecycle hook which can only be
    // triggered by a job, which is already deduped in the main queue, so
    // we can skip duplicate check here to improve perf
    // 翻译:如果 cb 是一个数组,它只能是在一个 job 内触发的组件生命周期 hook(而且这些 cb 已经去重过了,可以跳过去重判断)
    pendingQueue.push(...cb)
  }
  queueFlush()
}

export function queuePreFlushCb(cb: SchedulerJob) {
  queueCb(cb, activePreFlushCbs, pendingPreFlushCbs, preFlushIndex)
}

export function queuePostFlushCb(cb: SchedulerJobs) {
  queueCb(cb, activePostFlushCbs, pendingPostFlushCbs, postFlushIndex)
}

小结

总的来说,加入队列函数,核心逻辑就都是如下:

function queueJob(){
    queue.push(job)
    queueFlush()  // 作用会在后面说
}

在这个基础上,另外再加上一些去重判断、和优先级而已。

为什么组件异步队列 queue 跟 Pre 队列、Post 队列的入队方式还不一样呢?

因为一些细节上的处理不一致

  • queue 队列有优先级
  • 而 Pre 队列、Post 队列的入参,可能是数组

但其实我们也不需要过分关心这些细节,因为我们学习源码,其实是为了学习它的优良设计,我们把设计学到就好了,在现实的项目中,我们几乎不会遇到一模一样的场景,因此掌握整体设计,比抠细节更重要

那么 queueFlush 有什么作用呢?

queueFlush 的作用,就好像是你第一个到饭堂打饭,阿姨在旁边坐着,你得提醒阿姨该给你打饭了。

队列其实并不是一直都在执行的,当列队为空之后,就会停止等到又有新的 Job 进来的时候,队列才会开始执行

queueFlush 在这里的作用,就是告诉队列可以开始执行了。

我们来看看 queueFlush 的实现:

let isFlushing = false  // 标记队列是否正在执行
let isFlushPending = false // 标记队列是否等待执行

function queueFlush() {
  // 如果不是正在执行队列 / 等待执行队列
  if (!isFlushing && !isFlushPending) {
    // 用于标记为等待执行队列
    isFlushPending = true
    // 在下一个微任务执行队列
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}

执行队列的方法,是 flushJob。

queueFlush 是队列执行时机的实现 —— flushJob 会在下一个微任务时执行

为什么执行时机为下一个微任务?为什么不能是 setTimeout(flushJob, 0)

我们目的,是延迟执行 queueJob,等所有组件数据都更新完,再执行组件 DOM 更新(instance.update)。

要达到这一目的:我们只需要等在下一个浏览器任务,执行 queueJob 即可

因为,响应式数据的更新,都在当前的浏览器任务中。当 queueJob 作为微任务执行时,就表明上一个任务一定已经完成了。

而在浏览器中,微任务比宏任务有更高的优先级,因此 queueJob 使用微任务。

浏览器事件循环示意图如下:

image-20220113205347386

每次循环,浏览器只会取一个宏任务执行,而微任务则是执行全部,在微任务执行 queueJob,能在最快时间执行队列,并且接下来浏览器就会执行渲染页面,更新UI。

否则,如果 queueJob 使用宏任务,极端情况下,可能会有多个宏任务在 queueJob 之前,而每次事件循环,只会取一个宏任务,则 queueJob 的执行时机会在非常的后,这对用户体验来说是有一定的伤害的

至此,我们已经把下图蓝色部分都解析完了:

image-20220120000045131

剩下的是红色部分,即函数 flushJob 部分的实现了:

队列的执行 flushJob

function flushJobs() {
  // 等待状态设置为 false 
  isFlushPending = false
  // 标记队列为正在执行状态
  isFlushing = true

  // 执行 Pre 队列
  flushPreFlushCbs()

  // 根据 job id 进行排序,从小到大
  queue.sort((a, b) => getId(a) - getId(b))

  // 用于检测是否是无限递归,最多 100 层递归,否则就报错,只会开发模式下检查
  const check = __DEV__
    ? (job: SchedulerJob) => checkRecursiveUpdates(seen!, job)
    : NOOP

  try {
    // 循环组件异步更新队列,执行 job
    for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
      const job = queue[flushIndex]
      // 仅在 active 时才调用 job
      if (job && job.active !== false) {
          
        // 检查无限递归
        if (__DEV__ && check(job)) {
          continue
        }
        // 调用 job,带有错误处理
        callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
      }
    }
  } finally {
    // 收尾工作,重置这些用于标记的变量
    flushIndex = 0		// 将队列执行的 index 重置
    queue.length = 0	// 清空队列

    // 执行 Post 队列
    flushPostFlushCbs()

    isFlushing = false
    currentFlushPromise = null
     
    // 如果还有 Job,继续执行队列
    // Post 队列运行过程中,可能又会将 Job 加入进来,会在下一轮 flushJob 执行
    if (
      queue.length ||
      pendingPreFlushCbs.length ||
      pendingPostFlushCbs.length
    ) {
      flushJobs()
    }
  }
}

flushJob 主要执行以下内容:

  1. 执行 Pre 队列
  2. 执行queue 队列
  3. 执行 Post 队列
  4. 循环重新执行所有队列,直到所有队列都为空

执行 queue 队列

queue 队列执行对应的是这一部分:

try {
    // 循环组件异步更新队列,执行 job
    for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
      const job = queue[flushIndex]
      // 仅在 active 时才调用 job
      if (job && job.active !== false) {
          
        // 检查无限递归
        if (__DEV__ && check(job)) {
          continue
        }
        // 调用 job,带有错误处理
        callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
      }
    }
  } finally {
    // 收尾工作,重置这些用于标记的变量
    flushIndex = 0		// 将队列执行的 index 重置
    queue.length = 0	// 清空队列
  }
}

循环遍历 queue,运行 Job,直到 queue 为空

queue 队列执行期间,可能会有新的 Job 入队,同样会被执行。

image-20220121163647496

执行 Pre 队列

export function flushPreFlushCbs() {
  // 有 Job 才执行
  if (pendingPreFlushCbs.length) {
    // 执行前去重,并赋值到 activePreFlushCbs
    activePreFlushCbs = [...new Set(pendingPreFlushCbs)]
    // pendingPreFlushCbs 清空
    pendingPreFlushCbs.length = 0

    // 循环执行 Job
    for (
      preFlushIndex = 0;
      preFlushIndex < activePreFlushCbs.length;
      preFlushIndex++
    ) {
      // 开发模式下,校验无限递归的情况
      if (
        __DEV__ &&
        checkRecursiveUpdates(seen!, activePreFlushCbs[preFlushIndex])
      ) {
        continue
      }
      // 执行 Job
      activePreFlushCbs[preFlushIndex]()
    }
    // 收尾工作
    activePreFlushCbs = null
    preFlushIndex = 0
      
    // 可能递归,再次执行 flushPreFlushCbs,如果队列为空就停止
    flushPreFlushCbs()
  }
}

主要流程如下:

  1. Job 最开始是在 pending 队列中的
  2. flushPreFlushCbs 执行时,将 pending 队列中的 Job 去重,并改为 active 队列
  3. 循环执行 active 队列的 Job
  4. 重复 flushPreFlushCbs,直到队列为空

image-20220121163922530

执行 Post 队列

export function flushPostFlushCbs(seen?: CountMap) {
  // 队列为空则结束
  if (pendingPostFlushCbs.length) {
    // 去重
    const deduped = [...new Set(pendingPostFlushCbs)]
    pendingPostFlushCbs.length = 0

    // #1947 already has active queue, nested flushPostFlushCbs call
    // 特殊情况,发生了递归,在执行前 activePostFlushCbs 可能已经有值了,该情况可不必过多关注
    if (activePostFlushCbs) {
      activePostFlushCbs.push(...deduped)
      return
    }

    activePostFlushCbs = deduped
    if (__DEV__) {
      seen = seen || new Map()
    }
    
    // 优先级排序
    activePostFlushCbs.sort((a, b) => getId(a) - getId(b))

    // 循环执行 Job
    for (
      postFlushIndex = 0;
      postFlushIndex < activePostFlushCbs.length;
      postFlushIndex++
    ) {
      // 在开发模式下,检查递归次数,最多 100 次递归
      if (
        __DEV__ &&
        checkRecursiveUpdates(seen!, activePostFlushCbs[postFlushIndex])
      ) {
        continue
      }
      // 执行 Job
      activePostFlushCbs[postFlushIndex]()
    }
    // 收尾工作
    activePostFlushCbs = null
    postFlushIndex = 0
  }
}

主要流程如下:

  1. Job 最开始是在 pending 队列中的
  2. flushPostFlushCbs 执行时,将 pending 队列中的 Job 去重,然后跟 active 队列合并
  3. 循环执行 active 队列的 Job

image-20220120213224077

为什么在队列最后没有像 Pre 队列那样,再次执行 flushPostFlushCbs?

Post 队列的 Job 执行时,可能会将 Job 继续加入到队列(Pre 队列,组件异步更新队列,Post 队列都可能)

新加入的 Job,会在下一轮 flushJob 中执行:

// postFlushCb 可能又会将 Job 加入进来,如果还有 Job,继续执行
if (
  queue.length ||
  pendingPreFlushCbs.length ||
  pendingPostFlushCbs.length
) {
  // 执行下一轮队列任务
  flushJobs()
}

最后

之前写了两篇关于 vue 队列的文章,但是总感觉没能很好的表达出想要的意思。

恰逢最近公司要进行晋级答辩,听了同事的预答辩,越发觉得,个人的表达能力,跟技术能力同样的重要,如何将一件事情表达清楚(在有限的时间内,让别人知道,你做了什么厉害的事情),也是一个很重要得能力。

因此,我决定在这两篇文章的基础上,再次修改,整个过程,包括前两篇文章的编写,前前后后写了有一个半月,写了又改改了有写,补充了很多的图片和细节,希望能更好的帮助大家理解。

如果这篇文章对您有所帮助,请帮忙点个赞👍,您的鼓励是我创作路上的最大的动力。