异步组件与 Suspense:类比 Bean 懒加载

为什么需要异步组件

一个笔记站有这些功能:首页、笔记详情、AI 助手、代码 Playground。用户打开首页时,浏览器要下载多少 JS?

同步写法(全量打包)

import HomePage from '@/pages/HomePage.vue'
import NoteDetailPage from '@/pages/NoteDetailPage.vue'
import AiChatPage from '@/pages/AiChatPage.vue'         // 几 MB 的 AI SDK
import PlaygroundPage from '@/pages/PlaygroundPage.vue' // Monaco Editor 4MB+

打包时,这些组件一起塞进首页的 JS bundle。用户刚进首页就要下载几 MB 的 Monaco Editor——即使他从不点 Playground。

异步写法(按需加载)

import { defineAsyncComponent } from 'vue'

const AiChatPage = defineAsyncComponent(() => import('@/pages/AiChatPage.vue'))
const PlaygroundPage = defineAsyncComponent(() => import('@/pages/PlaygroundPage.vue'))

Vite 看到 import()自动把这些组件单独打包成 chunk,用户访问对应路由时才下载。首页 bundle 减小,首屏更快。

Java 对照:Spring 的 @Lazy

@Component
@Lazy
public class HeavyBean {
    public HeavyBean() { /* 耗时初始化 */ }
}

@Lazy 让 Bean 在第一次被用到时才实例化,不是启动时。异步组件同理——要渲染时才下载


1. defineAsyncComponent 基础

import { defineAsyncComponent } from 'vue'

// 最简写法
const AsyncComp = defineAsyncComponent(() => import('./MyComp.vue'))

// 完整配置
const AsyncComp = defineAsyncComponent({
  loader: () => import('./MyComp.vue'),
  loadingComponent: LoadingSpinner,     // 加载中显示啥
  errorComponent: ErrorView,            // 加载失败显示啥
  delay: 200,                           // 200ms 后才显示 loading(避免闪烁)
  timeout: 3000,                        // 3s 还没加载完,显示 error
})

delay: 200 的细节:大部分情况下组件 100ms 内就加载完了。如果每次都立刻显示 loading 再立刻消失,UI 会"闪一下"。延迟 200ms 再显示 loading,让快路径不出现加载动画。

Java 对照:像 Hystrix / Resilience4j 的 fallback + timeout,前端社区的 UI 等价物。


2. 最常见的用法:路由懒加载

vue-router 里每个路由对应一个页面组件,路由懒加载是异步组件最经典的场景。

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      component: () => import('@/pages/HomePage.vue'),  // 懒加载
    },
    {
      path: '/notes/:slug',
      component: () => import('@/pages/NoteDetailPage.vue'),
    },
    {
      path: '/ai',
      component: () => import('@/pages/AiChatPage.vue'),
    },
  ],
})

component: () => import(...) 就是路由层的异步组件语法。Vue Router 内部会用 defineAsyncComponent 包装它。不用手动调 defineAsyncComponent

实际效果

打开首页时 Network 面板能看到:

index.js          15 KB   (主包)
HomePage.js        3 KB   (首页 chunk)

点击进入 AI 页面时:

AiChatPage.js    180 KB   (按需下载)

项目里的约定src/router/ 里所有路由组件都用懒加载。只有当前路由组件会被下载。


3. <Suspense>:统一处理异步边界

defineAsyncComponentloadingComponent 能显示加载态,但每个异步组件都要单独配很繁琐。更大的问题:组件的 <script setup> 里直接 await 的情况:

<!-- NoteDetailPage.vue -->
<script setup lang="ts">
const note = await fetchNote(slug)   // 顶层 await
</script>

<template>
  <article>{{ note.title }}</article>
</template>

Vue 3 支持 <script setup> 里的顶层 await——组件返回的不是同步的组件实例,而是一个 Promise。这种组件必须放在 <Suspense>

基础用法

<template>
  <Suspense>
    <!-- default slot:真正要渲染的异步内容 -->
    <NoteDetailPage :slug="slug" />

    <!-- fallback slot:数据还没就绪时显示 -->
    <template #fallback>
      <div class="loading">加载中...</div>
    </template>
  </Suspense>
</template>

效果

  1. 进入页面 → 显示 fallback 的"加载中..."
  2. NoteDetailPageawait fetchNote(slug) 完成 → 自动切换到真正的内容

捕获嵌套的异步

<Suspense> 会等子树里所有异步组件和顶层 await 都完成:

<Suspense>
  <div>
    <AsyncHeader />           <!-- 异步组件 A -->
    <AsyncSidebar />          <!-- 异步组件 B -->
    <ArticleWithTopLevelAwait />  <!-- 顶层 await 组件 C -->
  </div>
  <template #fallback>
    <PageSkeleton />
  </template>
</Suspense>

A、B、C 全部就绪后一次性显示内容——没有"半加载"状态。

Java 对照:像 CompletableFuture.allOf(...).thenRun(...) 统一等一组异步任务,全部完成才继续。


4. <Suspense> vs 手写 v-if="loaded"

手写加载状态:

<script setup>
const data = ref(null)
const loading = ref(true)
const error = ref(null)

onMounted(async () => {
  try {
    data.value = await fetch('/api/notes').then(r => r.json())
  } catch (e) {
    error.value = e
  } finally {
    loading.value = false
  }
})
</script>

<template>
  <div v-if="loading">加载中...</div>
  <div v-else-if="error">出错了</div>
  <div v-else>{{ data }}</div>
</template>

<Suspense> 把这套"三段模板 + 异步状态管理"简化成:

<!-- 父组件 -->
<Suspense>
  <DataView />
  <template #fallback><Spinner /></template>
</Suspense>

<!-- DataView.vue -->
<script setup>
const data = await fetch('/api/notes').then(r => r.json())  // 顶层 await
</script>
<template>{{ data }}</template>

优势

权衡

项目里的建议


5. 错误处理

异步组件本身加载失败

const AsyncComp = defineAsyncComponent({
  loader: () => import('./MyComp.vue'),
  errorComponent: ErrorView,
  timeout: 5000,
  onError(error, retry, fail, attempts) {
    if (attempts < 3) {
      retry()    // 自动重试
    } else {
      fail()
    }
  },
})

Suspense 内部组件抛错

onErrorCaptured 在父组件里拦截:

<script setup>
import { onErrorCaptured, ref } from 'vue'

const error = ref(null)
onErrorCaptured((e) => {
  error.value = e
  return false   // 阻止错误继续向上冒泡
})
</script>

<template>
  <div v-if="error">出错啦:{{ error.message }}</div>
  <Suspense v-else>
    <DataView />
    <template #fallback><Spinner /></template>
  </Suspense>
</template>

Java 对照onErrorCaptured@ControllerAdvice + @ExceptionHandler,声明一次处理子树抛出的所有错误。


6. 打包层面会发生什么

Vite 看到这行:

() => import('@/pages/AiChatPage.vue')

会把 AiChatPage.vue 以及它递归 import 的所有文件单独打包成一个 chunk(如 AiChatPage-abc123.js)。访问该路由时浏览器才请求这个文件。

查看 chunk 情况

pnpm build
# 输出类似:
#   dist/assets/HomePage-xxx.js           3.2 KB
#   dist/assets/NoteDetailPage-xxx.js     5.1 KB
#   dist/assets/AiChatPage-xxx.js       180.0 KB

pnpm build 末尾的 Vite 警告「chunks larger than 500 kB」就是在提醒你:"有块太大了,考虑用 import() 拆开"。


7. 项目里的用法规划

in4vue 目前的 src/router/index.ts 已经在用懒加载(每个页面都是 () => import(...))——打开 pnpm build 的输出能看到每个语言的高亮 chunk(wasm、cpp、emacs-lisp)都被 Shiki 按需拆分了。

以后会用 <Suspense> 的场景

暂时不用的场景


8. 新手常见误区

误区 1:给所有组件都加异步

// ❌ 小组件不值得拆分,反而多一次网络请求
const Button = defineAsyncComponent(() => import('./Button.vue'))

原则:只对大组件 + 非首屏必需的组件做异步。按钮、图标这类小东西同步打包更好。

误区 2:在循环里 defineAsyncComponent

// ❌ 每次渲染都创建新的异步组件,缓存失效
const items = computed(() =>
  list.map(() => defineAsyncComponent(() => import('./Item.vue')))
)

defineAsyncComponent在模块顶层调用一次,复用返回值。

误区 3:忘了加 <Suspense>

<!-- 子组件有顶层 await -->
<script setup>
const data = await fetchData()
</script>

父组件没包 <Suspense> → 开发期警告 "A component with async setup() must be nested in a <Suspense> boundary"。

解法:要么加 <Suspense>,要么改成 onMountedawait


速查表

// 异步组件
import { defineAsyncComponent } from 'vue'
const Comp = defineAsyncComponent(() => import('./Comp.vue'))

// 带配置
const Comp = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  loadingComponent, errorComponent,
  delay: 200, timeout: 3000,
})

// 路由懒加载(vue-router)
{ path: '/x', component: () => import('@/pages/X.vue') }

// Suspense
<Suspense>
  <AsyncChild />
  <template #fallback><Spinner /></template>
</Suspense>

小练习

  1. pnpm build,记录 dist/assets/ 里最大的 3 个 chunk 是什么(大概率是 Shiki 的语言高亮)
  2. src/router/index.ts 里把某个路由改成同步导入import X from '@/pages/X.vue'),再 build 一次,对比 chunk 大小变化
  3. 思考:如果以后把 AI 聊天的大模型 SDK 塞到 <AiChatPage> 里,应该怎么打包?(答:路由懒加载 + 页面内部再用 defineAsyncComponent 拆出"历史记录"、"设置面板"等子组件)

延伸阅读