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

简介: 七千字深度剖析 Vue3 的调度系统(下)

Job 递归


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


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

为什么会需要递归?

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

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

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

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

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

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

1686383970132.png

父组件刚更新完,子组件由于属性更新,立即触发 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 队列的数据结构如下:

typescript

复制代码

constqueue: 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 使用微任务。

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

1686383889950.png

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

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

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

1686383878939.png

剩下的是红色部分,即函数 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 入队,同样会被执行。

1686383838421.png

执行 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,直到队列为空

1686383808956.png

执行 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

1686383771579.png


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

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

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


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

最后


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

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

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

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

目录
相关文章
|
18天前
|
存储 JavaScript 前端开发
vue3的脚手架模板你真的了解吗?里面有很多值得我们学习的地方!
【10月更文挑战第21天】 vue3的脚手架模板你真的了解吗?里面有很多值得我们学习的地方!
vue3的脚手架模板你真的了解吗?里面有很多值得我们学习的地方!
|
21天前
|
API
vue3知识点:provide 与 inject
vue3知识点:provide 与 inject
28 4
vue3知识点:provide 与 inject
|
21天前
|
API
vue3知识点:readonly 与 shallowReadonly
vue3知识点:readonly 与 shallowReadonly
24 1
vue3知识点:readonly 与 shallowReadonly
|
15天前
|
JavaScript 前端开发 开发者
Vue 3中的Proxy
【10月更文挑战第23天】Vue 3中的`Proxy`为响应式系统带来了更强大、更灵活的功能,解决了Vue 2中响应式系统的一些局限性,同时在性能方面也有一定的提升,为开发者提供了更好的开发体验和性能保障。
35 7
|
16天前
|
前端开发 数据库
芋道框架审批流如何实现(Cloud+Vue3)
芋道框架审批流如何实现(Cloud+Vue3)
38 3
|
15天前
|
JavaScript 数据管理 Java
在 Vue 3 中使用 Proxy 实现数据双向绑定的性能如何?
【10月更文挑战第23天】Vue 3中使用Proxy实现数据双向绑定在多个方面都带来了性能的提升,从更高效的响应式追踪、更好的初始化性能、对数组操作的优化到更优的内存管理等,使得Vue 3在处理复杂的应用场景和大量数据时能够更加高效和稳定地运行。
36 1
|
15天前
|
JavaScript 开发者
在 Vue 3 中使用 Proxy 实现数据的双向绑定
【10月更文挑战第23天】Vue 3利用 `Proxy` 实现了数据的双向绑定,无论是使用内置的指令如 `v-model`,还是通过自定义事件或自定义指令,都能够方便地实现数据与视图之间的双向交互,满足不同场景下的开发需求。
36 1
|
17天前
|
前端开发 JavaScript
简记 Vue3(一)—— setup、ref、reactive、toRefs、toRef
简记 Vue3(一)—— setup、ref、reactive、toRefs、toRef
|
18天前
Vue3 项目的 setup 函数
【10月更文挑战第23天】setup` 函数是 Vue3 中非常重要的一个概念,掌握它的使用方法对于开发高效、灵活的 Vue3 组件至关重要。通过不断的实践和探索,你将能够更好地利用 `setup` 函数来构建优秀的 Vue3 项目。
|
21天前
|
JavaScript Java API
vue3知识点:setup
vue3知识点:setup
27 5