普通的队列模型
普通的队列模型,现实生活中随处可见,饭堂的排队,先来的先打饭。
它有以下特点:
- 是一个有先后顺序的列表
- 可以出队和入队
- 先进先出
vue 的队列模型
三个队列
vue 有 3 个队列,分别为组件数据更新前、组件数据更新、组件数据更新后队列。
3 个队列,都围绕着组件数据更新前中后执行的。
什么是组件数据更新?
<template> <p>{{name}}</p> </template> <script lang="ts"> import { ref, defineComponent } from 'vue' export default defineComponent({ setup(){ const name = ref("vue setup") return { name } } }) </script>
上面代码中,组件数据更新,就是指组件的 name 的值更新,DOM 的值也被更新的过程(虽然 DOM 值更新但 UI 仍未更新,因为 js 线程仍在运行代码)
两种不同的队列模型
vue 的 3 个队列中,有 2 种不同的队列模型
- 组件数据更新前(后面称 Pre 队列)、组件数据更新后队列 (后面称 Post 队列)—— 先进先出,无优先级,不可插队,允许递归
- 组件数据更新队列 —— 优先级高的 Job 先执行,允许插队,允许递归
这里先介绍一下组件数据更新队列
组件数据更新队列
如图所示,每个 Job 有一个属性 id,id 小的先执行,且中途可以插队。
那为什么要这么设计呢?我们拆分成几个问题一一回答:
Job 的 id 是怎么取值的?允许插队,id 小的 Job 先执行,有什么意义?
Job 的 id 是 vue 组件内部实例的 uid 属性。是一个不会重复的计数器。第一个组件 uid 是 0,第二个组件 uid 是 1,如此类推。
id 小的 Job 先执行,这保证了,父组件永远比子组件先更新(因为先创建父组件,再创建子组件,子组件可能依赖父组件的数据)
仅仅当父组件数据更新完毕,才能更新子组件。
试想,如果子组件数据更新先执行,在这之后,如果父组件更新了数据(子组件依赖该数据),那么子组件还需要再执行一次数据更新。
因此,id 小的先执行,即保证了数据的正确性,又提升了数据更新的性能
这也是自上而下的**单向数据流**所决定的组件数据更新顺序。
什么是递归?什么时候允许递归?
如上图所示:当一个 Job 在执行时,将它自身再加入队列,这种情况称为递归。
默认情况下,Job 是不能递归的。
允许 Job 递归的情况:组件更新的 Job、watch 的 callback(这也是个 Job)
什么情况下,组件更新会发生递归?
下面是一个例子:
定义全局属性:
const app = createApp(App) app.config.globalProperties.$loading = { isLoading: ref(false), }
父组件:
<template> <div> <div>{{ $loading.isLoading.value }}</div> <button @click='add'>{{ count }}</button> <Children :count='count' /> </div> </template> <script setup lang='ts'> // 省略 import const count = ref(0) function add() { count.value = count.value + 1 } </script>
子组件:
<template> <div> <p>{{ count }}</p> </div> </template> <script lang='ts'> import { ref, defineComponent, onUpdated } from 'vue' export default defineComponent({ props: { count: { type: Number, required: true } }, watch: { count() { this.$loading.isLoading.value = !this.$loading.isLoading.value } } }) </script>
有父子两个组件,点击 button 后,其数据流如下:
- 父组件 count 自增
- 子组件属性被修改,触发 watch,修改 loading
- loading 被修改,父组件更新 DOM
我们这里会发现,这个例子并不满足单向数据流:父组件正在进行数据更新时,子组件修改了全局属性,导致父组件需要进行更新。
因此父组件需要再次进入数据更新队列,再次执行更新,才能保证数据正确。这种情况就是递归。
如果过多的打破单向数据流,会导致多次递归执行更新,可能会导致性能下降
Pre/Post 队列
实际上,Pre/Post 队列,比普通队列复杂一点
该类型的队列,执行内容有哪些?
举几个例子:
- watch 函数有一个参数 flush,默认为 pre,会将 watch 的 callback 加入到 Pre 队列; flush 设置为 post,则会加入到 POST 队列
- mounted 声明周期,是在 Post 队列执行的,因为要等组件更新、 DOM 挂载上去再执行
为什么会有执行和等待两个队列?
因为执行队列时,会将 Job 加入队列,可能会加入多次,在等待队列转成执行队列过程中,可以最后统一执行一次去重
什么时候会递归?
<template> <div @click='add'> {{list}} </div> </template> <script setup> import { reactive,watch } from 'vue' const list = reactive([]) watch(list, ()=>{ if(list.length < 10){ list.push(1) } }) function add(){ list.push(1) } </script>
watch callback 默认在 Pre 队列执行;watch callback 里面又改变了自身,使 callback 又加入 Pre 的等待队列。
当然,一般是不会这么写直接导致递归的,递归往往是间接导致的。如:watch 修改了一个 ref,ref 触发依赖,又导致了 watch 监听的值改变,导致递归。
总结
使用队列模型,能更好的描述、更好的进行解耦, vue 的生命周期、组件更新、单向数据流等设计。
组件更新队列,使用了一个带有 Job id 的可插队队列。
- 延迟执行,能够对重复 Job 进行去重,提升性能
- 保证了,组件一定是先执行父组件,在执行子组件的数据更新,该顺序是单向数据流决定的
- 递归情况下,能够正确处理组件更新顺序,以及保证了数据的正确性
使用 Pre/Post 队列,:
- 延迟执行,能够对重复 Job 进行去重,提升性能
- 解析组件时,声明周期并没有立即执行,需要在特定时间执行(如组件 mounted 后)
下一篇文章,将会对 vue 队列的源码进行解析。