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

·  阅读 2639

前言

什么是调度?

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

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

操作系统引入调度,目的是解决计算机资源的分配问题,因为任务是源源不断的,但 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 队列的文章,但是总感觉没能很好的表达出想要的意思。

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

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

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

分类:
前端
标签:
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改