Skip to content

其它内置组件

Suspense

温馨提示

截至 2024-11-11,<Suspense> 是一项实验性功能。待其稳定后 API 可能会发生变化!

<Suspense> 是 Vue 的内置组件,它可以让我们在组件树的上层等待下层多个嵌套的异步依赖解析完成(类似于 Promise.all() 等待的过程),并可以在等待期间渲染一个统一的加载 loading 状态。

dark

现在的问题是,<Suspense> 组件是如何知道哪些组件中包含异步依赖,需要统一进行异步等待的呢?

目前 <Suspense> 可以等待的异步依赖有以下两种:

  1. 带有异步 setup() 钩子的组件:

    js
    export default {
      // 这里的 setup() 使用了 async...await
      async setup() {
        const res = await fetch(...)
        const posts = await res.json()
        return {
          posts
        }
      }
    }

    或使用 <script setup> 时有顶层 await 表达式的组件:

    vue
    <script setup>
    // 在 <script setup> 内部的顶层作用域中,用到了 await
    const res = await fetch(...)
    const posts = await res.json()
    </script>
    
    <template>{{ posts }}</template>
  2. 异步组件:

    js
    // 使用 defineAsyncComponent() 函数,创建了异步组件
    const AsyncCom = defineAsyncComponent(() => import('SFC 组件'))
    // 或
    const AsyncCom2 = defineAsyncComponent({
      loader: () => import('SFC 组件'),
      loadingComponent: Loading,
      delay: 200,
      timeout: 1000,
      errorComponent: ErrorPage
    })

展示加载中状态

初步封装 RectColor 组件

我们封装一个 RectColor 组件,组件中渲染了一个红色背景的 div 元素:

vue
<script setup></script>

<template>
  <div class="rect-box"></div>
</template>

<style scoped>
.rect-box {
  width: 200px;
  height: 100px;
  margin: 15px;
  border-radius: 10px;
  /* 将来,这里的背景颜色值,需要动态获取并填充到这里 */
  background-color: red;
  box-shadow: 3px 3px 10px #666;
  cursor: pointer;
}
</style>

封装 utils 工具函数

src/ 目录下新建 utils/ 文件夹,并在 src/utils/ 下新建 index.js 模块。在 index 模块中封装名为 getRandomColor 的工具函数,这个函数的作用是:延迟指定的毫秒数,并返回一个包含 RGB 颜色值的 Promise 对象

js
// 异步函数,延迟指定的毫秒数,获取随机的 RGB 颜色值
export const getRandomColor = (delay = 500) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      const rgb = `rgb(${getRandomNumber()}, ${getRandomNumber()}, ${getRandomNumber()})`
      resolve(rgb)
    }, delay)
  })
}

// 获取随机整数(0 - 255 之间,包含两端)
function getRandomNumber(max = 256) {
  return Math.floor(Math.random() * max)
}

进一步完善 RectColor 组件

RectColor 组件在渲染时需要调用异步接口获取随机颜色值,并把获取到的随机颜色绑定为 div 的背景色。核心实现思路分为如下 3 步:

  1. 封装一个必传的 delay prop,表示调用接口获取随机颜色值时,等待的毫秒数;
  2. 调用 utils 中的 getRandomColor() API,异步获取随机的颜色值;
  3. 把获取到的颜色值,绑定为 div 的背景色;
vue
<script setup>
const props = defineProps({
  // 延迟的毫秒数
  delay: {
    type: Number,
    required: true,
    default: 1000
  }
})

// 调用异步 API,异步获取随机的颜色值
import { getRandomColor } from '../utils'
// props.delay 用来指定延迟多少毫秒之后,返回随机生成的 RGB 颜色值
// 由于在当前组件的 <script setup> 的顶级作用域中用到了 await,
// 因此,它必须被嵌套到 <Suspense> 组件下(直接或后代元素都可)
const rgbColor = await getRandomColor(props.delay)
</script>

<template>
  <div class="rect-box"></div>
</template>

<style scoped>
.rect-box {
  width: 200px;
  height: 100px;
  margin: 15px;
  border-radius: 10px;
  /* css 动态绑定颜色值 */
  background-color: v-bind('rgbColor');
  box-shadow: 3px 3px 10px #666;
  cursor: pointer;
}
</style>

请注意,当前封装好的 RectColor 组件不能直接渲染到父组件中,因为它的 <script setup> 顶级作用域中用到了 await 异步操作,它必须被嵌套渲染到 <Suspense> 组件中。警告消息如下:

[Vue warn]: Component <Anonymous>: setup function returned a promise, but no <Suspense> boundary was found in the parent component tree. A component with async setup() must be nested in a <Suspense> in order to be rendered.
  at <RectColor delay=2000 >
  at <App>

在 <Suspense> 中使用 RectColor 组件

<Suspense> 提供了两个插槽,分别是 #default#fallback。其中:

  • #default 插槽中的内容会在**异步成功后(完成状态)**被展示;
  • #fallback 插槽中的内容会在**异步等待期间(挂起状态)**作为 loading 展示;

而且,传入 #default 插槽中的模板内容必须有唯一根元素

示例代码如下:

vue
<script setup>
import RectColor from './components/RectColor.vue'
</script>

<template>
  <Suspense>
    <div>
      <!-- [Vue warn]: <Suspense> slots expect a single root node.  -->
      <!-- #default 插槽中的内容必须有唯一根元素 -->
      <RectColor :delay="2000" />
      <RectColor :delay="1000" />
      <RectColor :delay="4000" />
    </div>

    <!-- #fallback 插槽用来指定 loading 提示 -->
    <template #fallback>
      <p>loading...</p>
    </template>
  </Suspense>
</template>

回退到挂起状态

默认不回退到挂起状态

<Suspense> 处于完成状态后,其下嵌套的后代组件新产生的异步状态,不会让 <Suspense> 回退到挂起状态。这种默认的处理方式是高效且符合用户使用习惯的。

改造 App 组件,点击按钮向 delayList 数组中新增元素,然后使用 v-for 指令循环渲染 <RectColor> 组件:

vue
<script setup>
import RectColor from './components3/RectColor.vue'

import { ref } from 'vue'
// 1. 定义响应式数据
const delayList = ref([2000, 1000, 4000])
</script>

<template>
  <!-- 2. 点击按钮,向数组中新增 Item 项 -->
  <button @click="delayList.push(3000)">Add RectBox</button>
  <Suspense>
    <!-- 3. 循环渲染多个 RectColor 组件,并通过 prop 向下传入 delay 的时长 -->
    <RectColor v-for="delay in delayList" :delay="delay" />

    <template #fallback>
      <p>loading...</p>
    </template>
  </Suspense>
</template>

我们发现,当点击 Add RectBox 按钮之后,<Suspense> 并没有立即回退到 loading 的挂起状态。而是在等待期间持续展示上一次渲染出来的 3 个 <RectColor> 组件。

当新增的第 4 个 <RectColor> 就绪之后,直接将其更新到了页面之上(打补丁:只新增第 4 个,前 3 个被复用了)。

强制回退到挂起状态

如果想让 <Suspense> 在每次新产生异步状态的时候,立即回退到挂起状态(展示 loading 效果),则必须同时满足以下两个条件:

  1. <Suspense> 添加 timeout="0" 的 prop 属性;
  2. 每次必须替换 default 默认插槽的根节点

App.vue 中的代码更新如下:

vue
<script setup>
import RectColor from './components3/RectColor.vue'

import { ref } from 'vue'
const delayList = ref([2000, 1000, 4000])
</script>

<template>
  <button @click="delayList.push(3000)">Add RectBox</button>
  <!-- 1. 添加 timeout="0" 的 prop -->
  <Suspense timeout="0">
    <!-- 2. 手动为 default 插槽的根元素,绑定唯一的 key 值,
     从而告知 Vue 不要就地复用这个根元素,
     每次更新渲染都需要创建崭新的根元素 -->
    <div :key="delayList.length">
      <RectColor v-for="delay in delayList" :delay="delay" />
    </div>

    <!-- loading 提示 -->
    <template #fallback>
      <p>loading...</p>
    </template>
  </Suspense>
</template>

🚨 注意 🚨

请尽量避免这么做,因为每次新产生异步状态的时候,都会导致所有 <RectColor> 组件的销毁和创建,无法复用那些更新前后没有变化RectColor 组件。

Teleport

<Teleport> 是一个内置组件,它可以将一个组件内部的一部分模板“传送”到该组件的 DOM 结构外层的位置去。最常见的应用场景就是封装全屏模态框。

封装 CustomModal 组件

vue
<script setup>
import { ref } from 'vue'
const isShow = ref(false)
</script>

<template>
  <div class="container">
    <button @click="isShow = true">展示模态框</button>

    <div class="modal" v-if="isShow">
      <div class="modal-content">
        <div class="header">用户基本信息</div>
        <div class="body">
          <p>姓名:张三,年龄:12岁</p>
        </div>
        <div class="footer">
          <button @click="isShow = false">关闭</button>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.container {
  width: 200px;
  height: 200px;
  background-color: lightgreen;
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  padding: 15px;
  box-sizing: border-box;
}
.modal {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.8);
}

.modal-content {
  font-size: 12px;
  background-color: #fff;
  width: 150px;
  height: 100px;
  border-radius: 3px;
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  padding: 5px;

  .header {
    font-weight: bold;
  }
}
</style>

把模态框的元素传送到 body 节点下

<Teleport> 所包裹的元素,会被传送到 to 指向的元素结点下。to 属性的值可以是一个 CSS 选择器字符串,也可以是一个 DOM 元素对象

vue
<script setup>
import { ref } from 'vue'
const isShow = ref(false)
</script>

<template>
  <div class="container">
    <button @click="isShow = true">展示模态框</button>

    <!-- 被 <Teleport> 所包裹的元素, -->
    <!-- 会被传送到 to 指向的元素结点下 -->
    <Teleport to="body">
      <div class="modal" v-if="isShow">
        <div class="modal-content">
          <div class="header">用户基本信息</div>
          <div class="body">
            <p>姓名:张三,年龄:12岁</p>
          </div>
          <div class="footer">
            <button @click="isShow = false">关闭</button>
          </div>
        </div>
      </div>
    </Teleport>
  </div>
</template>

<style scoped>
.container {
  width: 200px;
  height: 200px;
  background-color: lightgreen;
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  padding: 15px;
  box-sizing: border-box;
}
.modal {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.8);
}

.modal-content {
  font-size: 12px;
  background-color: #fff;
  width: 150px;
  height: 100px;
  border-radius: 3px;
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  padding: 5px;

  .header {
    font-weight: bold;
  }
}
</style>

Deferred Teleport 3.5+

在 Vue 3.5 及更高版本中,我们可以使用 defer prop 推迟 Teleport 的目标解析,直到应用的其他部分挂载。这允许 Teleport 将由 Vue 渲染且位于组件树之后部分的容器元素作为目标:

html
<Teleport defer to="#late-div">...</Teleport>

<!-- 稍后出现于模板中的某处 -->
<div id="late-div"></div>

请注意,目标元素必须与 Teleport 在同一个挂载/更新周期内渲染,即如果 <div> 在一秒后才挂载,Teleport 仍然会报错。延迟 Teleport 的原理与 mounted 生命周期钩子类似。

例如,无法把 <Teleport defer> 挂载到通过 defineAsyncComponent 加载的异步组件中,下面的代码无法正常工作,且会在终端产生 Invalid Teleport target on mount: null (object) 的警告消息:

vue
<template>
  <Teleport to="#box2" defer>
    <div>xxx</div>
  </Teleport>

  <hr />

  <Inner />
</template>

<script setup>
// import Inner from './Inner.vue'
import { defineAsyncComponent } from 'vue'

// 异步加载本地的 SFC 组件
const Inner = defineAsyncComponent(() => import('./Inner.vue'))
</script>

<style scoped></style>

天不生夫子,万古长如夜