异步组件与 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>

9. 原理深挖:defineAsyncComponent 内部就是个状态机

defineAsyncComponent 不返回原始组件,而是返回一个包装组件,包装组件根据 loader 状态切换渲染:

// 简化版源码
export function defineAsyncComponent(source) {
  if (typeof source === 'function') source = { loader: source }
  const { loader, loadingComponent, errorComponent, delay = 200, timeout } = source

  let resolvedComp = undefined          // 加载完的真组件
  let pendingPromise = null              // 加载中的 Promise(去重用)

  function load() {
    // ★ 同一个组件多处用,只发一次请求
    return pendingPromise ||= loader()
      .catch((err) => {
        if (source.onError) {
          return new Promise((resolve, reject) => {
            source.onError(err,
              () => resolve(load()),     // retry
              () => reject(err),         // fail
              attempts++
            )
          })
        }
        throw err
      })
      .then((mod) => {
        resolvedComp = mod.default || mod
        return resolvedComp
      })
  }

  // ★ 真正返回的是这个包装组件
  return defineComponent({
    name: 'AsyncComponentWrapper',
    setup() {
      const loaded = ref(false)
      const error = ref(null)
      const delayed = ref(!!delay)

      if (delay) setTimeout(() => delayed.value = false, delay)

      load().then(() => loaded.value = true).catch(e => error.value = e)

      if (timeout) setTimeout(() => {
        if (!loaded.value && !error.value) error.value = new Error('Async component timed out')
      }, timeout)

      return () => {
        if (loaded.value && resolvedComp) return h(resolvedComp)
        if (error.value && errorComponent) return h(errorComponent, { error: error.value })
        if (!delayed.value && loadingComponent) return h(loadingComponent)
      }
    }
  })
}

关键洞察

  1. 包装组件本身是同步的——只是它的 render 函数根据状态切换渲染目标
  2. 同一个 loader 多次调用只下载一次——因为 dynamic import 的 Promise 被浏览器/打包器缓存(webpack chunk 注册表 / Vite 模块图)
  3. delay 是给 loadingComponent 的延时,不是给 loader 的延时——加载快时跳过 loading
  4. HMR 时:dev 模式下 Vue 会重置 resolvedComp,让 import.meta.hot 触发重新 load

Java 对照:类似 Spring 的 lazy proxy——拿到的是个代理对象,第一次方法调用时才真正初始化目标 Bean。Vue 的"代理"是组件包装,初始化触发器是 mount。


10. 原理深挖:Vite/webpack 怎么把 import() 切成独立 chunk

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

Vite 在 build 模式下用 Rollup,看到 import() 时:

  1. 依赖分析:从 AiChatPage.vue 出发,递归找到所有它导入的模块(包括 markdown-it 也会被它引用的 @vue/repl 等等)
  2. chunk 划分
    • 这些模块如果被 AiChatPage chain 引用 → 进 AiChatPage chunk
    • 被多个动态 chunk 共享的模块(比如 vue 本身) → 提取到 vendor chunk
  3. 生成产物AiChatPage-[hash].js + 一个清单告诉运行时"AiChatPage chunk 依赖 vendor-vue chunk"
  4. 运行时注入import() 编译后变成 __vitePreload(() => import('./AiChatPage-abc.js'), ['./vendor-vue-xyz.js'])——预加载所有依赖再加载入口

Vite 在 dev 模式下完全不打包——import() 走原生 ESM,浏览器原生支持,按需请求文件。所以 dev 模式下"懒加载"和"全量"几乎没区别(都是按需),但 build 后才能看到真实的 chunk 划分效果。

对应的 Network 行为

点击进入 /ai 路由
  ↓
fetch('/assets/AiChatPage-abc.js')
  + fetch('/assets/vendor-monaco-xyz.js')   ← 依赖的大块
  + fetch('/assets/AiChatPage.css')         ← 样式也按 chunk 拆
  ↓
全部到达后执行 → 模块 default export → 真正的组件对象
  ↓
defineAsyncComponent 拿到组件 → loaded.value = true → 重新 render

Java 对照:类似 OSGi 的 bundle 按需加载——bundle 之间声明依赖,运行时按需解析装载。但 OSGi 是物理 jar 切分,Vite 是 chunk 切分。


11. 原理深挖:顶层 await 怎么和 Suspense 协作

<script setup>
const data = await fetchData()
</script>

<script setup> 编译器看到顶层 await,把 setup 编译成 async function

// 编译后大致
{
  async setup() {
    const data = await fetchData()
    return { data }
  }
}

问题:组件 setup 现在返回 Promise,不是同步对象——Vue 渲染流程怎么处理?

Suspense 的工作方式

// 简化版 Suspense 内部
function setupSuspense(suspenseVnode) {
  const pendingPromises = []      // 收集子树里所有未完成的 Promise

  // 临时改写 setupComponent,让 async setup 把 Promise 收上来
  const originalSetup = setupComponent
  setupComponent = (instance) => {
    const result = originalSetup(instance)
    if (result instanceof Promise) {
      pendingPromises.push(result)
    }
    return result
  }

  // 先 render 一遍(fallback 状态)
  renderFallback()

  // 等所有 Promise 完成
  Promise.all(pendingPromises).then(() => {
    setupComponent = originalSetup
    // 现在 async setup 已经 resolve,可以正常渲染真内容
    renderActualContent()
  })
}

实际比这复杂——Vue 用了一个叫 SuspenseBoundary 的状态机管理 pending / resolved / fallback / error 四种状态,但核心思想就是上面这套:截获子树的异步组件 setup Promise,全部完成后切换到真内容

为什么子树里多个 await 能合并:因为 Promise.all 等待所有,子树有几十个 await 也只切换一次状态——避免"内容闪烁"。

为什么 onMounted 在 Suspense 下行为特殊

所以Suspense 自己的 onMounted 比子组件的 onMounted 早——和正常父子顺序相反。这是因为 Suspense 先挂 fallback 启动,子异步组件后到。


12. 原理深挖:Suspense 的 4 个事件

<Suspense
  @pending="onPending"
  @resolve="onResolve"
  @fallback="onFallback"
  timeout="3000"
>
  <AsyncChild />
  <template #fallback><Spinner /></template>
</Suspense>
事件 触发时机 用途
pending 进入"等待中"(首次或后续切换) 显示进度条
resolve 异步内容全部就绪 隐藏 loading、上报性能
fallback 切换到 fallback 槽 切换路由时显示骨架

"后续切换"是什么意思——Suspense 不仅管首次加载。如果 default 槽里的异步组件重新 setup(比如 key 变了),又会进入 pending:

<Suspense>
  <NoteDetail :slug="route.params.slug" :key="route.params.slug" />
  <template #fallback><Skeleton /></template>
</Suspense>

切笔记 → key 变 → NoteDetail 重新 setup(顶层 await 重跑)→ Suspense 状态切到 pending → 触发 @pending 事件 → 显示骨架屏 → await 完成后 @resolve。

这就是 SPA 单页应用切页面骨架屏的标准实现


13. 原理深挖:为什么不能在循环里 defineAsyncComponent

第 8 节说过这个误区,原理:

// ❌ 每次 computed 重算都创建新的 wrapper 组件
const items = computed(() =>
  list.map(() => defineAsyncComponent(() => import('./Item.vue')))
)

每次 list 变化或重渲染,computed 重算 → 调用 defineAsyncComponent N 次 → 创建 N 个全新的包装组件对象

Vue 渲染时

diff 旧 vnode 树 vs 新 vnode 树
  ↓
旧 vnode.type = AsyncWrapper-001 (旧引用)
新 vnode.type = AsyncWrapper-002 (新对象)
  ↓
type 不同 → 当作不同组件 → 销毁旧的 + mount 新的
  ↓
新 wrapper 重新进入"开始 load → 显示 loading → ..."流程

结果:每次渲染 loading 都闪一下,体验糟糕,请求也多。

正确写法——在模块顶层创建一次:

const Item = defineAsyncComponent(() => import('./Item.vue'))   // ★ 顶层一次

// 模板
<Item v-for="x in list" :key="x.id" :data="x" />

虽然 Item 是异步包装组件,但只创建一次——所有 v-for 实例共享同一个 wrapper,第一次任意实例触发 load 后,剩下的实例会拿到缓存的 resolvedComp 直接渲染。

判断准则:所有"返回组件对象"的工厂函数(defineAsyncComponent、markRaw(SomeComp)shallowRef(SomeComp)),永远在模块顶层调用一次


14. 原理深挖:动态组件 + 异步组件的组合

<component :is="currentComp" />

currentComp 是个组件对象(可能是异步组件 wrapper)。Vue 的 <component> 内置组件:

// 简化版
const ComponentResolver = {
  setup(props, { slots }) {
    return () => h(props.is, attrs, slots)
  }
}

关键is 可以是同步组件、异步组件、甚至字符串(标签名)。Vue 渲染时按 type 决定走哪条路径——异步组件会先显示 loading,加载完后再渲染。

实战场景——按需切换不同的"重型面板":

<script setup>
import { shallowRef, markRaw } from 'vue'

const tabs = {
  ai: defineAsyncComponent(() => import('./AiPanel.vue')),
  playground: defineAsyncComponent(() => import('./PlaygroundPanel.vue')),
  notes: defineAsyncComponent(() => import('./NotesPanel.vue')),
}

const current = shallowRef(tabs.ai)
</script>

<template>
  <Suspense>
    <component :is="current" />
    <template #fallback><Spinner /></template>
  </Suspense>
  <button @click="current = tabs.playground">切到 Playground</button>
</template>

切 Tab → component is 变 → 新异步组件触发 load → Suspense 显示 fallback → 加载完显示 Playground。

为什么用 shallowRef + markRaw:组件对象本身不需要响应式——ref(组件) 会把组件对象包成深响应式 Proxy,浪费且可能破坏 Vue 内部结构。shallowRef 只对 .value 替换敏感,markRaw 直接告诉 Vue "这玩意儿别响应式化"。


15. 原理深挖:Suspense 还在实验阶段意味着什么

第 4 节提到"Suspense 仍是实验性 API"。具体不稳定点:

  1. API 可能改——Vue 团队在讨论是否换成更接近 React Suspense 的 API(基于 throw Promise 而不是 async setup)
  2. 错误处理——目前错误必须用 onErrorCaptured 兜底,未来可能加 #error
  3. 嵌套 Suspense 的行为——多层 Suspense 时哪个先显示 fallback、resolve 顺序,文档不够清楚
  4. 服务端渲染(SSR)支持有限——SSR 等待异步内容时可能锁死整个页面

当前推荐策略

场景 推荐
路由级懒加载 Vue Router 内置就好,不需要手动 Suspense
单页面单异步源 v-if + onMounted async 简单稳妥
单页面多异步源(聚合) Suspense(享受统一 loading)
SSR 关键路径 别用 Suspense,传统数据预取
想要"路由切换时旧页面留住直到新页面就绪" Suspense(这是它的杀手锏场景)

最后一条很重要——传统 router-view 切页面是"立即换",Suspense 包裹后变成"新页面准备好再换",避免"切到空白页"的感觉。这个体验提升非常明显,值得为它接受 API 不稳定的风险。


小练习

  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 拆出"历史记录"、"设置面板"等子组件)

延伸阅读