不一样的叙述思路 | 一步一步带你了解 vue3 的内部队列

简介: 不一样的叙述思路 | 一步一步带你了解 vue3 的内部队列

之前写过一篇关于 vue 队列的文章,阅读量不高,在分析为什么的时候,我发现直接介绍这么个东西,有点晦涩难懂,略显无聊,于是我换了个叙述思路,多说这是为什么,而不是说它是什么

本文使用到的所有 Demo ,都可以在这个仓库找到,如果有同学需要深入调试,可以克隆这个仓库进行折腾,里面的各个 Demo 的 README 页面,也稍微写了一下建议的断点调试的位置。

该文章不涉及 vue 内部的源码,仅仅是队列机制的介绍,阅读难度应该不会太高


前言


在 vue3 官方文档中,仅仅看到极少的关于队列的描述,多数情况我们不需要关心它,但它在 vue3 内部,负责整个 vue 代码的运作调度,是 vue3 内部很重要的基础设施。

下面我们来,从【为什么 vue 需要队列】到【vue 需要怎样的队列】,一步一步地剖析 vue3 的队列机制


为什么 vue 需要队列?


我们先做个类比,看看如下代码:


element.style.borderWidth = '1px';
element.style.borderWidth = '2px';
element.style.borderWidth = '1px';

在浏览器中,并不是执行完一个 DOM 操作语句,就立即更新界面的。在浏览器的事件循环中,需要等 js 执行完毕,才会进行页面的渲染更新,因为后面 js 的运行可能还会修改页面,实时同步地更新 UI,会造成很多的渲染性能浪费。

1686382758662.png

vue 的组件更新,也有类似的机制,看看下面例子(Demo 1)。

html

复制代码

<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  // 没有立即修改 DOM
  count.value = count.value + 2  // 没有立即修改 DOM
}
</script>

当点击按钮,count.value 被修改时,没有立即修改 DOM。等所有组件数据都更新完成了,再统一地进行 DOM 操作更新界面。

因此,为了延迟 DOM 更新,vue 引入了异步更新队列,当 count.value 被修改时,将该组件的 update 函数,放入队列。在所有组件数据都更新完成后,再执行队列,统一地进行 DOM 操作更新界面

以下描述,节选自 Vue3 官方文档

Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更


名词约定


  • Job ——  队列的最小执行单位,在异步更新队列中,它的执行内容为组件更新(update)函数
  • 组件更新函数 —— 对比更新前后的组件数据(Vnode)差异,然后进行更新。update 函数会递归更新组件本身及其内部的所有嵌套组件。这个过程叫 patch,比较 Vnode 差异的算法称为 diff 算法。

vue 需要怎样的队列?


之前的小节提到,Vue 更新 DOM 时,是异步执行的。因此,需要一个异步更新队列

但仅仅异步是不够的,下面看看几个特殊的场景


队列需要去重


仍然是之前小节的例子(Demo 1):


<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
  count.value = count.value + 2
}
</script>

count.value 前后两次被修改,会触发两次响应式更新,每次都会将组件的 update 函数(会更新整个组件)加入到异步更新队列。

由于组件 update 函数,运行一次,就可以更新组件到最新状态,因此不需要重复加入队列,需要对队列的任务进行去重


队列 Job 能够被删除


下面是一个父组件和子组件的 update 函数都进入异步更新队列的例子(Demo 2):

父组件:


<template>
  <div>Father {{ count }}</div>
  <Children :count='count' @add="onAdd" />
</template>
<script setup lang='ts'>
import {ref} from 'vue'
import Children from './Children.vue'
const count = ref(0)
function onAdd() {
  count.value = count.value + 1
}
</script>

子组件:


<template>
  <button @click='add'>add {{ count }}</button>
  <p>Children props count {{ count }}</p>
  <p>childrenCount {{ childrenCount }}</p>
</template>
<script lang='ts' setup>
import {ref} from 'vue'
defineProps({
  count: {
    type: Number,
    required: true
  }
})
const emit = defineEmits(['add'])
function add() {
  emit('add')
  childrenCount.value++
}
const childrenCount = ref(0)
</script>

当子组件点击按钮时,emit 事件,使父组件 count.value+1,父组件 update 函数进入队列。

ChildrenCount.value+1,子组件 update 函数进入队列。

在之前名词约定时提到过,组件更新函数,不仅仅是更新当前组件,还要递归,更新它的子组件及其内部的所有嵌套组件

那么,如果一个队列中,父组件和它的子组件都进入了异步更新队列,那么父组件的 Job 执行时,会把子组件也更新了。因此子组件无需再次更新,从而提升性能。

组件执行 update 函数时,会将该组件未被执行的 Job,从队列中删除。因此,父组件在递归更新其子组件时,若子组件的 Job 仍未被执行,将会从队列中删除。

1686382563508.png

队列 Job 可能会失效


当一个组件被卸载时(unmount),如果它对应的 Job(update 函数),已经进入了异步更新队列,那么该 Job 是不应该被执行的,因为这个组件已经被卸载了,因此,需要将这个 Job 标记为失效

将上一小节的 Demo 稍作改动,给 Children 组件加上 v-if="(count+1)%2"(Demo 3)

父组件:


<template>
  <div>Father {{ count }}</div>
  <!-- 这里加了 v-if -->
  <Children v-if="(count+1)%2" :count='count' @add="onAdd" />
</template>
<script setup lang='ts'>
import {ref} from 'vue'
import Children from './Children.vue'
const count = ref(0)
function onAdd() {
  count.value = count.value + 1
}
</script>

子组件:


<template>
  <button @click='add'>add {{ count }}</button>
  <p>Children props count {{ count }}</p>
  <p>childrenCount {{ childrenCount }}</p>
</template>
<script lang='ts' setup>
import {ref} from 'vue'
defineProps({
  count: {
    type: Number,
    required: true
  }
})
const emit = defineEmits(['add'])
function add() {
  emit('add')
  childrenCount.value++
}
const childrenCount = ref(0)
</script>
当子组件点击按钮时,emit 事件,使父组件 count.value+1,父组件 update 函数进入队列。

ChildrenCount.value+1,子组件 update 函数进入队列。

当父组件 update 函数执行时,由于 count 更新后为 1,(count+1)%2 === 0,v-if 为 false,Children 组件被卸载,其 Job 也会被置为失效,不会被执行

失效和删除的区别是什么?

  • 场景不同:失效是发生在组件卸载时,删除是发生在组件更新
  • 失效的 Job 仍存在于队列中,删除的 Job 不在队列中
  • 失效的 Job 再次被加入到队列中时,由于 Job 已经存在会被去重;删除的 Job 能够被再次加入到队列中

由于失效是使用在组件卸载的场景中,无论该组件依赖的响应式变量如何变化,触发 Job 加入异步更新队列,都会被去重,该 Job 已经标记为失效,无法再次被执行

而删除 Job 则不同, 组件在执行 update 时,删除未执行的 Job,目的是为了提升性能,因为该组件已经更新了。若是其依赖的响应式变量又被修改,则 Job 会被重新被加入异步更新队列,重新更新组件

允许插队的队列

我们常说的队列,是指先进先出的队列,就好像饭堂排队,先来的先能打到饭,因为每个打饭的人,都是一个独立的个体,之前没有相互依赖,不存在 A 同学打饭之后,B 同学先才能打饭。

而在 Vue 中,组件间并不是独立的,它们是有相互关系的,因此被依赖方,需要先更新,依赖方,需要后更新

听起来很别扭,那就换个说法:父组件先更新,子组件后更新

我们再稍微修改一下之前的例子,来说明,什么情况下,子组件会比父组件先进入队列(Demo 4):

父组件:

<template>
  <div>Father {{ count }}</div>
  <Children :count='count' @add="onAdd" />
</template>
<script setup lang='ts'>
import { ref } from 'vue'
import Children from './Children.vue'
const count = ref(0)
function onAdd() {
  count.value++
}
</script>

子组件:


<template>
  <button @click='add'>add {{ count }}</button>
  <p>Children props count {{ count }}</p>
  <p>childrenCount {{ childrenCount }}</p>
</template>
<script lang='ts' setup>
import {ref} from 'vue'
defineProps({
  count: {
    type: Number,
    required: true
  }
})
const emit = defineEmits(['add'])
const childrenCount = ref(0)
function add() {
  // 交换了两者的顺序,改为先修改子组件依赖
  childrenCount.value++   // 先改变 ChildrenCount
  emit('add')       // 再通过事件 emit,改变属性 count
}
</script>

点击子组件 button 时,先改变 childrenCount,触发子组件更新,子组件 update 函数被加入到异步更新队列

然后 emit 事件,父组件改变 count,触发父组件更新,父组件 update 函数被加入到异步更新队列

子组件 update 函数会比父组件 update 函数先进入队列,如果子组件先更新,在父组件更新后,count 属性的改变,会导致子组件仍然需要再次更新,才能保证正确性。但子组件先更新就是浪费了性能。

为了保证组件更新的正确性,以及提升性能,Vue 的队列,需要一个插队机制/优先级机制,来保证组件更新的顺序。

vue 使用了一种非常简单且巧妙的方式,为每个组件分配一个 uid,uid 从 0 开始自增,第一个创建的组件为 0,第二个为 1,以此类推。这就保证了,父组件 uid,一定是比子组件 uid 小,只要在队列中根据 uid,在合适的位置插入任务,就能保证父组件的更新在子组件之前

于是,我们的队列变成了这样:

1686382439273.png

一个队列还不够


在异步更新队列执行前,需要确保组件数据已经更新到最新,在之前小节的例子中,队列都是在下一个事件循环的 tick 执行组件更新。

下面我们看一个更复杂的例子,一个使用了 watch 的例子(Demo 5):


<template>
  <div>
    <button @click='add'>count: {{ count }}</button>
    <div>watch value: {{ watchValue }}</div>
  </div>
</template>
<script setup lang='ts'>
import {ref, watch} from 'vue'
const count = ref(0)
const watchValue = ref(100)
function add() {
  count.value = count.value + 1
  watchValue.value = watchValue.value + 1
  watchValue.value = watchValue.value - 1
}
watch(watchValue, () => {
  console.log('watch', watchValue.value)
})
</script>

点击按钮后,watchValue 被加一然后马上被减一,最终的值没变。

那么这时候 Vue 是会触发两次 watch,还是不触发 watch

答案是不触发 watch,因为最终 watchValue.value 没有被改变,不执行 watch 回调

这里的 watch,需要等待组件数据更新完成,比较数据是否有改变,改变才执行 watch,因此也是需要延迟执行的。而组件的异步更新,又依赖 watch回调引起组件数据变化的结果。因此,组件异步队列,会在 watch 之后延迟执行

所以会有以下关系:

1686382414033.png


因此,还需要一个在异步更新队列前的队列,我们后面称之 Pre 队列

在组件数据更新后,下一个事件循环 tick 执行 Pre 队列然后再执行组件异步更新队列

由于 Pre 队列的 job,没有依赖关系,不需要插队,因此 Pre 队列只是一个先进先出的队列

Post 队列


既然有 Pre 队列,那会不会有 Post 队列呢?

答案是有的,Post 队列,是在异步更新队列之后运行。

什么情况下会用到 Post 队列呢?我们来看看下面这个例子(Demo 6):


<template>
  <button @click='add' ref="buttonRef">count: {{ count }}</button>
</template>
<script setup lang='ts'>
import {onUpdated, ref} from 'vue'
const buttonRef = ref<HTMLElement>()
const count = ref(0)
function add() {
  count.value = count.value + 1
}
onUpdated(() => {
  // 打印的是 DOM 更新后的 count 的值,且只打印一次
  // 打印:onUpdated count: 1
  console.log('onUpdated', buttonRef.value?.innerHTML)  
})
</script>

当点击 button 时,执行 add 函数,修改 count 响应式变量,组件 update 函数进入队列,然后执行组件 DOM 更新,最后再执行一次 onUpdated 生命周期。

这里的 onUpdated 生命周期,也是延后执行,等所有组件的 DOM 都更新完成之后,再执行。

因此,在异步更新队列执行完成后,仍需要一个队列,去运行 DOM 更新后的代码,这个队列就是 Post 队列


除了组件 updated 生命周期外,mounted、activated、deactivated、unmounted、watchPostEffect、模板引用 ref 等,需要在 DOM 更新之后执行的功能,都需要使用到 Post 队列


有优先级的 Post 队列


Post 队列中大部分的 Job,都是没有依赖的,但 Post 队列也是有优先级的。因为需要尽快的更新模板索引 ref,很可能在 Post 队列中的 Job,就会使用到。

看看下面例子(Demo 7):


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

divRef 的值,并不是一个固定的 DOM,因此,需要在 Post 队列的其他 Job 执行之前,优先更新模板引用

到目前为止了,Pre 队列、组件异步更新队列、Post 队列,它们的关系如下:

1686382370550.png


允许 Job 递归的队列


什么是 Job 递归?

当前正在执行组件异步更新队列,执行 Job 的过程中,组件依赖的响应式数据被修改,导致正在运行的 Job 再次被加入到队列中

什么情况下会发生 Job 递归?

一般情况下是不会发生 Job 递归的,因为在组件异步更新队列运行前,组件数据已经更新到最新的值,因此这种情况下组件 update 函数执行过程中,不会再有响应式数据的变化

但有一种特殊情除外,我们来看看下面这个例子(Demo 8):

父组件:


<template>
  <button @click='add'>add {{ count }}</button>
  <Children :count='count' @add="add" />
</template>
<script setup lang='ts'>
import {ref} from 'vue'
import Children from './Children.vue'
const count = ref(0)
function add() {
  count.value = count.value + 1
}
</script>

子组件:


<template>
  <div>children {{ count }}</div>
</template>
<script lang='ts' setup>
import {watch} from 'vue'
const props = defineProps({
  count: {
    type: Number,
    required: true
  }
})
const emit = defineEmits(['add'])
watch(() => props.count, () => {
  if (props.count < 10) {
    emit('add')
  }
})
</script>

点击按钮后,count.value + 1,父组件 update 函数被加入队列。

父组件 update 函数的 Job 被执行时,进行组件差异对比,需要更新 Children 的属性 count。

Children 组件 watch 到 count 变化(这里有一个特殊逻辑,如果已经处于组件异步更新队列运行中,则 watch 不会被延迟执行),emit 事件,count.value+1,父组件 update 函数再次被加入队列(当前正在执行的父组件 Job,不参与去重判定)。

如此递归循环,直到 props.count = 10,Children 组件不再 emit 事件。

如果使用 watch 且监听的是组件属性,会导致,在组件异步更新队列运行阶段,组件数据仍未是最终的数据

因为子组件属性只有在父组件异步更新时,才会被修改,而此时才会触发组件属性的 watch,watch 回调又有可能会导致组件数据发生变化。

上面的例子有点扯,正常人不会写这样的代码,这里再稍微举一个更常见的例子

对上面例子稍微做点修改,子组件的属性我们改为项目 id(原本是 count),子组件 watch id,id 改变则请求后台,重新拉取项目 id 的信息,拉取过程中,emit loading 事件,父组件数据 loading 改为 true,页面展示 loading。

这个例子更为真实常见,watch 子组件属性 id,且改变父组件数据,这就是触发递归了。


总结


最后,我们用一个表格总结一下 vue 各个队列的特性:

Pre 队列 异步更新队列 Post 队列
Job 去重 Job 去重 Job 去重
先进先出 允许插队,按 id 从小到大执行 允许插队,按 id 从小到大执行
不需要删除 Job 可以删除 Job 不需要删除 Job
不需要失效 Job Job 会失效 不需要失效 Job
允许递归 允许递归 允许递归

下篇文章,我将会深入 vue 队列源码进行讲解,敬请期待~

如果喜欢本文的叙述思路,欢迎在文章底部评论区留言~

如果对该文章有疑问,也可以在评论区提问,我会一一回答~

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

目录
相关文章
|
19天前
|
JavaScript 前端开发 索引
「Vue3系列」Vue3 条件语句/循环语句
在 Vue 3 中,你可以使用条件语句来动态地控制模板中的渲染内容。Vue 提供了多种方式来实现条件渲染,包括 `v-if`、`v-else-if`、`v-else` 和 `v-show` 指令。
33 0
|
20天前
Vue3框架中让table合计居中对齐
Vue3框架中让table合计居中对齐
|
20天前
Vue3 子/父组件相互调用
Vue3 子/父组件相互调用
35 0
|
20天前
|
JavaScript
vue3 使用element plus 打包时 报错
vue3 使用element plus 打包时 报错
23 0
|
16天前
|
JavaScript 前端开发 数据安全/隐私保护
Vue3——如何实现页面访问拦截
Vue3——如何实现页面访问拦截
|
20天前
Vue3 子传父 暴露数据 defineExpose
Vue3 子传父 暴露数据 defineExpose
Vue3 子传父 暴露数据 defineExpose
|
1天前
|
JSON 数据可视化 前端开发
vue3+threejs+koa可视化项目——模型文件上传(第四步)
vue3+threejs+koa可视化项目——模型文件上传(第四步)
15 7
|
1天前
|
JSON 数据可视化 数据库
vue3+threejs+koa可视化项目——实现登录注册(第三步)
vue3+threejs+koa可视化项目——实现登录注册(第三步)
18 5
|
1天前
|
JavaScript 数据可视化 算法
vue3+threejs可视化项目——搭建vue3+ts+antd路由布局(第一步)
vue3+threejs可视化项目——搭建vue3+ts+antd路由布局(第一步)
16 6
|
10天前
|
JavaScript 算法 前端开发
vue3和vue2的区别都有哪些
【4月更文挑战第15天】Vue3与Vue2在响应式系统(Proxy vs. Object.defineProperty)、组件模块化(Composition API vs. Options API)、数据变化检测(Reactive API vs. $watch)、虚拟DOM算法(基于迭代 vs. 基于递归)及Tree-Shaking支持上存在显著差异。Vue3的改进带来了更好的性能和灵活性,适合追求新技术的项目。Vue2则因其成熟稳定,适合维护大型项目。选择版本需根据项目需求、团队情况和技术追求来决定。
13 0