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

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

前言


什么是调度?

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

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

操作系统引入调度,目的是解决计算机资源的分配问题,因为任务是源源不断的,但 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)


1686383628231.png

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

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

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

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


1686383613620.png

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

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

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

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


Vue3 调度算法的使用


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

Vue 有 3 个队列,分别为:

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

1686383595175.png


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 队列。


1686383524325.png


本文不会过多的介绍 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 修改是延迟执行

1686383483155.png


调度细节


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

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

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


任务去重


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

typescript

复制代码

// 当组件依赖的响应式变量被修改时,会立即调用 queueJobqueueJob(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 中,组件并不都是相互独立的,它们之前存在父子关系

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

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

1686383200567.png


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

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

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

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

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


1686383181875.png


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

因此,从图中也能看出,父组件的序号,一定会比子组件的序号小,使用序号作为 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,会失效。

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


1686383078938.png

  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 从队列中删除

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

1686383044854.png

在一个组件 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
能否再次
加入队列
不能,会被去重 可以再次加入队列
意义 被卸载的组件,无论它依赖的响应式变量如何更新,该组件都不会更新了 删除任务,是因为已经更新过了,不需要重复更新。
如果依赖的响应式变量再次被修改,仍然需要加入队列,等待更新


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