Skip to content

07|异步组件与 Suspense ​

第七章 / 07。目标:把“加载中”拆成可观测的开始、等待、成功、失败、超时与重试。

两种异步不要混在一起 ​

defineAsyncComponent(() => import('./Panel.vue')) 延迟的是组件代码;组件内部 await fetch() 等待的是运行时数据。代码 chunk 成功下载不代表数据成功,反之也一样。

ts
import { defineAsyncComponent } from 'vue'
import LoadingPanel from './LoadingPanel.vue'
import LoadError from './LoadError.vue'

export const ReportPanel = defineAsyncComponent({
  loader: () => import('./ReportPanel.vue'),
  loadingComponent: LoadingPanel,
  errorComponent: LoadError,
  delay: 200,
  timeout: 5000,
  onError(error, retry, fail, attempts) {
    if (attempts <= 2) retry()
    else fail()
  }
})

默认 loading 延迟 200ms 是为了避免快请求闪烁;timeout 默认没有上限。上例的数字是产品实验参数,不是性能保证。onError 必须有终止条件,否则永久失败会变成无限重试。

用可控 Promise 观察边界 ​

测试时不要依赖真实网络:

ts
export function deferred<T>() {
  let resolve!: (value: T) => void
  let reject!: (reason?: unknown) => void
  const promise = new Promise<T>((ok, bad) => {
    resolve = ok
    reject = bad
  })
  return { promise, resolve, reject }
}

把 loader 改为返回 gate.promise。挂载后先断言占位;调用 gate.resolve(Component) 并等待 Promise 与 Vue DOM 队列后断言内容;另一用例调用 reject(new Error('offline')),断言错误 UI 和重试按钮。这样每个中间状态都由测试控制,不会因机器速度漂移。

Suspense 管的是异步依赖边界 ​

<Suspense> 会等待默认插槽树里的 async setup 和 suspensible 异步组件,并在等待期间显示 fallback。它当前仍是实验性功能,公共 API 可能变化;采用前应锁定 Vue 版本与回归测试。

先做一个只等待数据的子组件。ReportView.vue 的顶层 await 会让整个 setup 成为 Suspense 能发现的异步依赖:

vue
<script setup lang="ts">
const props = defineProps<{ load: () => Promise<string> }>()
const body = await props.load()
</script>

<template><article data-report>{{ body }}</article></template>

再把它放进边界。以下是完整的 SuspenseLab.vue;按钮刻意控制同一个 Promise,因此不需要猜网络要等多久:

vue
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'
import ReportView from './ReportView.vue'

function deferred<T>() {
  let resolve!: (value: T) => void
  let reject!: (reason?: unknown) => void
  const promise = new Promise<T>((ok, bad) => { resolve = ok; reject = bad })
  return { promise, resolve, reject }
}

const gate = deferred<string>()
const trace = ref<string[]>([])
const error = ref('')
onErrorCaptured(reason => {
  error.value = reason instanceof Error ? reason.message : String(reason)
  return false
})
</script>

<template>
  <button @click="gate.resolve('报告正文')">完成</button>
  <button @click="gate.reject(new Error('offline'))">失败</button>
  <p v-if="error" role="alert">{{ error }}</p>
  <Suspense
    v-else
    timeout="0"
    @pending="trace.push('pending')"
    @fallback="trace.push('fallback')"
    @resolve="trace.push('resolve')"
  >
    <ReportView :load="() => gate.promise" />
    <template #fallback><p role="status">正在准备报告…</p></template>
  </Suspense>
  <output>{{ trace.join(' → ') }}</output>
</template>

页面首次渲染时,Vue 先在内存中尝试默认插槽。ReportView 尚未完成,于是边界进入 pending;timeout="0" 让 fallback 立即出现,事件轨迹为 pending → fallback。事件处理器已经执行,但 output 的文字也属于响应式 DOM,自动化测试读取它之前仍要等待一次 nextTick()。点击“完成”后,Promise 兑现,默认树才提交到页面,状态提示消失,轨迹追加 resolve。点击“失败”不会产生 resolved;错误交给边界外的 onErrorCaptured,显示 offline。Suspense 自身只协调等待,没有内建错误界面。

这个过程也解释了为什么下面这个片段不能单独教会 Suspense:

vue
<Suspense @pending="trace.push('pending')" @resolve="trace.push('resolve')">
  <ReportView />
  <template #fallback><p role="status">正在准备报告…</p></template>
</Suspense>

只有模板时,读者看不到谁产生异步依赖、Promise 如何完成,也无法区分 fallback 与错误 UI。

已解决后的再次等待不是初次加载的简单重复 ​

初次渲染尚无旧内容,发现异步依赖后会显示 fallback。边界已经 resolved 后,只有默认插槽的根节点被替换才会重新进入 pending;更深层新出现的异步依赖不会自动让外层边界回退。发生根替换时,Vue 默认保留旧内容,直到新内容完成;设置 timeout="0" 才会立即换成 fallback,设置正数则超过这段时间再显示。

因此,测试至少区分三件事:pending 表示开始等待,fallback 表示占位真的进入 DOM,resolve 表示新默认树已经就绪。快速完成时,后续切换可能出现 pending → resolve 而没有 fallback,不能把“没有 fallback 事件”判成没有等待。

父 Suspense 接管 defineAsyncComponent 时,异步组件自己的 loadingComponent、errorComponent、delay 和 timeout 不再主导界面;需要组件自己控制时显式设 suspensible: false。关键首屏不要被无关侧栏等待,多个职责不同的请求应放进不同边界。默认插槽和 fallback 插槽都应保持单一直接根节点。

练习:可观察的 Suspense 边界(测试章节回访) ​

这道自动化题需要先完成 测试运行环境 与 组件测试。首次学习可先用按钮 resolve/reject 可控 Promise,记录三种 UI;测试夹具放到第九章回访,不能因未安装测试工具中断渲染主线。

把正文实验改成可重复创建 gate 的 SuspenseHost,输入是 load: () => Promise<string>。必须验证:初始 pending 时只有状态提示,事件是 pending,fallback;resolve 后正文出现、提示消失并追加 resolve;reject 后显示错误且绝不出现成功正文;点击重试只创建一次新 gate,并重新进入 pending。再把 timeout="0" 删除,说明这是在改变首次加载还是已解决后的根替换行为。所有等待使用可控 Promise 与 flush,不准 sleep。

参考答案:第 7 章第 07 题。

用心记录每一步成长