前置知识: Vue 3

异步组件与Suspense

74 minAdvanced2026/6/14

Vue 3异步组件defineAsyncComponent与Suspense配合使用。

异步组件与 Suspense | Async Components and Suspense in Vue 3

本文档对标 MIT 6.170、Stanford CS142、CMU 17-437 软件工程课程水准,系统化阐述 Vue 3 异步组件(defineAsyncComponent)与 Suspense 机制的原理、形式化定义、企业级实践与对比分析。涵盖代码分割(Code Splitting)、动态导入(Dynamic Import)、异步依赖编排(Async Orchestration)、错误边界(Error Boundary)、加载状态管理、SSR 流式渲染等主题,并辅以数学建模、对比分析、案例研究与习题。


目录

  1. 学习目标
  2. 历史动机与发展脉络
  3. 形式化定义
  4. 理论推导与原理解析
  5. 代码示例
  6. 对比分析
  7. 常见陷阱与最佳实践
  8. 工程实践
  9. 案例研究
  10. 习题
  11. 参考文献
  12. 延伸阅读

1. 学习目标 | Learning Objectives

本章节基于 Bloom 教育目标分类法设计学习目标,覆盖记忆、理解、应用、分析、评价、创造六个层次。完成本章学习后,学习者应能够独立设计企业级异步加载架构,并对其性能特性与边界条件做出准确判断。

1.1 记忆层(Remember)

  • R1:准确陈述 defineAsyncComponent 的函数签名,包括简单形式 defineAsyncComponent(loader) 与完整配置形式 defineAsyncComponent(options)
  • R2:列举异步组件的完整配置项:loaderloadingComponenterrorComponentdelaytimeoutsuspensibleonError
  • R3:复述 Suspense 组件的两个具名插槽:#default(默认内容)与 #fallback(加载占位)。
  • R4:背记 Suspense 的事件回调:onPending(pending)、onResolve(resolve)、onFallback(fallback)。
  • R5:识别 async setup() 必须在 Suspense 内使用,否则 Vue 会抛出警告。

1.2 理解层(Understand)

  • U1:解释代码分割(Code Splitting)的核心思想:将应用拆分为多个 chunk,按需加载,减少首屏体积。
  • U2:阐述动态导入(Dynamic Import)import('./Component.vue') 返回 Promise 的语法,以及 Webpack/Vite 如何将其转换为独立 chunk。
  • U3:描述 Suspense 的工作原理:等待所有异步依赖完成后,再渲染默认插槽;若任一依赖 pending,则渲染 fallback。
  • U4:理解 defineAsyncComponentSuspense 的协作模式:suspensible: true(默认)时,异步组件参与 Suspense 协调;suspensible: false 时,异步组件独立管理 loading/error 状态。
  • U5:说明 delaytimeout 的语义:delay 防止 loading 闪烁,timeout 触发超时错误,避免无限等待。

1.3 应用层(Apply)

  • A1:使用 defineAsyncComponent 实现路由懒加载,将每个路由对应的组件拆分为独立 chunk。
  • A2:使用 Suspense 包装异步组件树,统一管理加载占位与错误边界。
  • A3:实现一个带重试机制的异步组件,使用 onError 回调实现最多 3 次重试。
  • A4:在 async setup() 中调用 API 获取数据,配合 Suspense 实现加载态管理。
  • A5:使用 Vite 的 import.meta.glob 实现批量异步加载,构建可配置的组件注册表。

1.4 分析层(Analyze)

  • An1:分析 Suspense 的依赖收集机制:Vue 内部维护异步依赖计数器,每个异步 setup 注册依赖,完成时递减,归零时触发 resolve。
  • An2:对比 Vue Suspense 与 React Suspense 的实现差异:Vue 基于组件树依赖追踪,React 基于 throw Promise 模式。
  • An3:解构 defineAsyncComponent 的内部状态机:idle → loading → loaded/error,分析状态转换条件。
  • An4:分析异步组件在 SSR 中的行为:服务端同步等待所有异步依赖,客户端 hydration 时跳过加载态。
  • An5:评估 delay: 200msdelay: 0 在用户体验上的差异:前者避免闪烁,后者立即显示 loading。

1.5 评价层(Evaluate)

  • E1:评估一个路由是否应当懒加载,权衡首屏体积、加载延迟、SEO 影响。
  • E2:判断何时应当使用 Suspense 包裹异步组件,何时使用 defineAsyncComponent 的内置 loading/error 配置,权衡灵活性。
  • E3:评价 async setup() 的适用场景:何时优于 onMounted + 状态管理,何时引入过度复杂。
  • E4:权衡 Suspense 在大型应用中的使用成本:嵌套 Suspense、错误边界、加载顺序的复杂性。

1.6 创造层(Create)

  • C1:设计一套企业级异步组件加载策略,支持优先级、预加载、降级、缓存,并与 Vue Router 深度集成。
  • C2:构建一个基于 Suspense 的流式 SSR 框架,支持服务端流式渲染与客户端渐进式 hydration。
  • C3:实现一个异步组件的错误恢复系统,支持自动重试、降级组件、用户手动重试、错误上报。
  • C4:设计一个可视化的异步依赖分析工具,展示组件树的异步加载链路、加载时间、错误率,辅助性能优化。

2. 历史动机与发展脉络 | Historical Motivation and Evolution

2.1 代码分割的起源

Web 应用的体积随功能增长而膨胀,首屏 JS 体积从 2010 年的几十 KB 增长到 2025 年的数 MB。代码分割(Code Splitting)是应对此问题的核心技术,其设计动机:

  1. 首屏性能:用户访问页面时只需加载首屏所需的 JS,减少 LCP(Largest Contentful Paint)时间。
  2. 带宽节省:移动用户按需加载,避免下载未访问页面的代码。
  3. 缓存优化:将稳定代码与频繁变化代码分离,提升缓存命中率。

关键里程碑

时间事件
2015Webpack 1 引入 require.ensure 实现代码分割
2017Webpack 2 支持 import() 动态导入语法
2017React 16 引入 React.lazySuspense
2018Vue 2.5 支持 defineAsyncComponent(Options API 形式)
2020Vue 3 重构 defineAsyncComponent,引入 Composition API 风格
2020Vue 3 引入 Suspense(实验性)
2022Vue 3.2 稳定 Suspense,支持嵌套与 SSR
2024Vite 5 优化动态导入,支持模块预加载

2.2 Vue 2 时代的异步组件

Vue 2 提供 defineAsyncComponent(早期为 () => Promise),支持基础代码分割:

// Vue 2 异步组件
Vue.component('async-component', (resolve, reject) => {
  import('./AsyncComponent.vue').then(resolve).catch(reject);
});

// Vue 2.5+ 工厂函数形式
const AsyncComponent = () => import('./AsyncComponent.vue');

Vue 2 异步组件的限制

  • 无内置 loading/error 配置,需手动管理。
  • 无超时机制,组件加载失败时无降级方案。
  • 无 Suspense 协调,多个异步组件无法统一管理加载态。
  • async setup(),数据获取需在 createdmounted 中处理。

2.3 Vue 3 时代(2020-至今):完整重构

Vue 3 对异步组件进行根本性重构,并引入 Suspense

2.3.1 defineAsyncComponent 完整配置(Vue 3.0)

import { defineAsyncComponent } from 'vue';

const AsyncComp = defineAsyncComponent({
  loader: () => import('./HeavyComponent.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorDisplay,
  delay: 200,           // 延迟显示 loading(毫秒)
  timeout: 3000,        // 超时显示 error(毫秒)
  suspensible: true,    // 参与 Suspense 协调(默认 true)
  onError(error, retry, fail, attempts) {
    if (attempts <= 3) retry();
    else fail();
  },
});

2.3.2 Suspense 组件(Vue 3.0,实验性)

<Suspense>
  <template #default>
    <AsyncComponent />
  </template>
  <template #fallback>
    <LoadingSpinner />
  </template>
</Suspense>

2.3.3 async setup()(Vue 3.0)

Vue 3 允许 setup 为 async 函数,自动与 Suspense 集成:

export default {
  async setup() {
    const data = await fetch('/api/data').then(r => r.json());
    return { data };
  },
};

2.3.4 嵌套 Suspense(Vue 3.2+)

Vue 3.2+ 支持嵌套 Suspense,允许局部异步依赖独立管理加载态:

<Suspense>
  <template #default>
    <Header />
    <Suspense>
      <template #default>
        <MainContent />
      </template>
      <template #fallback>
        <MainSkeleton />
      </template>
    </Suspense>
  </template>
  <template #fallback>
    <PageSkeleton />
  </template>
</Suspense>

2.3.5 SSR 流式渲染(Vue 3.3+)

Vue 3.3+ 优化了 SSR 中的 Suspense,支持流式渲染(renderToNodeStream),服务端在异步依赖完成时立即输出对应 HTML,提升首屏 TTFB(Time To First Byte)。

2.4 Evan You 的设计哲学

Evan You 对异步组件与 Suspense 的定位:

  1. 声明式优于命令式:通过 Suspense 组件声明加载占位,而非手动切换 v-if 状态。
  2. 渐进式复杂度:简单场景用 defineAsyncComponent 内置配置,复杂场景用 Suspense 协调多个异步依赖。
  3. 与 React Suspense 互补:借鉴 React 的概念,但实现基于 Vue 的响应式系统,重渲染粒度更细。
  4. 实验性优先级Suspense 长期标记为实验性,API 可能调整,避免过早稳定化限制演进。

2.5 与 React.lazy/Suspense 的对比

React 16.6(2018)引入 React.lazySuspense,与 Vue 方案解决相似问题:

维度Vue 3 defineAsyncComponentReact.lazy
API 形式defineAsyncComponent(options)React.lazy(loader)
loading 配置内置 loadingComponentSuspensefallback
error 配置内置 errorComponentErrorBoundary 组件
超时内置 timeout手动实现
重试内置 onError手动实现
async setup支持 async setup()无对应概念
嵌套 Suspense支持支持
数据获取async setup() 集成React Query / SWR 等外部库
SSR流式渲染流式渲染

关键差异

  • Vue 的 defineAsyncComponent 内置 loading/error/timeout/重试,配置更完整。
  • React 的 lazy 更精简,复杂场景需配合 ErrorBoundary 与外部数据获取库。
  • Vue 的 async setup() 允许组件级数据获取与 Suspense 集成,React 需借助 React Query 等。

2.6 与 Solid.js、Svelte 的对比

框架异步组件 APISuspense 支持数据获取集成
Vue 3defineAsyncComponent + Suspense内置async setup()
React 18React.lazy + Suspense内置use() Hook(实验性)
Solid.jslazy() + Suspense内置createResource()
Svelte动态 import无原生 Suspenseasync module + await
AngularloadChildrenRouter 内置Resolve 守卫

Solid.js 的 lazy 与 Vue 最相似,但基于细粒度响应式,性能更优。Svelte 依赖编译时优化,运行时 Suspense 较弱。Angular 的 loadChildren 仅在路由层支持异步。


3. 形式化定义 | Formal Definitions

3.1 异步组件的形式化定义

定义 3.1(异步组件):异步组件是一个返回 Promise 的工厂函数,记为 AA

A: () \to \text{Promise<ComponentDefinition>

Promise resolve 时返回组件定义对象,reject 时表示加载失败。

定义 3.2(defineAsyncComponent)defineAsyncComponent 将异步工厂转换为同步可渲染的包装组件:

defineAsyncComponent:AsyncFactoryWrappedComponent\text{defineAsyncComponent}: \text{AsyncFactory} \to \text{WrappedComponent}

包装组件内部维护状态机:

state{idle,loading,loaded,error}\text{state} \in \{\text{idle}, \text{loading}, \text{loaded}, \text{error}\}

3.2 异步组件配置的形式化

定义 3.3(完整配置)defineAsyncComponent 的完整配置是一个七元组:

Config=loader,loadingComponent,errorComponent,delay,timeout,suspensible,onError\text{Config} = \langle \text{loader}, \text{loadingComponent}, \text{errorComponent}, \text{delay}, \text{timeout}, \text{suspensible}, \text{onError} \rangle

其中:

  • loader:()Promise<Component>\text{loader}: () \to \text{Promise<Component>}:加载函数。
  • loadingComponent:Component\text{loadingComponent}: \text{Component}:加载占位组件。
  • errorComponent:Component\text{errorComponent}: \text{Component}:错误降级组件。
  • delay:N (ms)\text{delay}: \mathbb{N} \text{ (ms)}:延迟显示 loading,默认 200ms。
  • timeout:N (ms)\text{timeout}: \mathbb{N} \text{ (ms)}:超时阈值,默认 Infinity。
  • suspensible:boolean\text{suspensible}: \text{boolean}:是否参与 Suspense 协调,默认 true。
  • onError:(error,retry,fail,attempts)void\text{onError}: (\text{error}, \text{retry}, \text{fail}, \text{attempts}) \to \text{void}:错误回调。

3.3 状态机的形式化

定义 3.4(状态转换):异步组件包装器的状态转换:

state(t+1)={loadingif state(t)=idlemount(t)loadedif state(t)=loadingloader.resolveerrorif state(t)=loading(loader.rejecttimeout)loadingif state(t)=errorretryloadedif state(t)=loaded (absorbing state)\text{state}(t+1) = \begin{cases} \text{loading} & \text{if } \text{state}(t) = \text{idle} \land \text{mount}(t) \\ \text{loaded} & \text{if } \text{state}(t) = \text{loading} \land \text{loader.resolve} \\ \text{error} & \text{if } \text{state}(t) = \text{loading} \land (\text{loader.reject} \lor \text{timeout}) \\ \text{loading} & \text{if } \text{state}(t) = \text{error} \land \text{retry} \\ \text{loaded} & \text{if } \text{state}(t) = \text{loaded} \text{ (absorbing state)} \end{cases}

关键性质

  • loaded 是吸收态,组件加载成功后不再重新加载(除非组件被卸载并重新挂载)。
  • error 可通过 retry 转回 loading,支持错误恢复。
  • timeoutloader.reject 都会触发 error,但 timeout 不取消 loader。

3.4 Suspense 的形式化定义

定义 3.5(Suspense 依赖):Suspense 维护一个异步依赖集合 DD

D={d1,d2,,dn}D = \{d_1, d_2, \ldots, d_n\}

每个 did_i 是一个异步依赖(来自 async setup()suspensible: true 的异步组件)。

定义 3.6(Suspense 状态):Suspense 的状态由依赖集合决定:

Suspense.state={pendingif dD:d.state=pendingresolvedif dD:d.state=resolvedrejectedif dD:d.state=rejected\text{Suspense.state} = \begin{cases} \text{pending} & \text{if } \exists d \in D: d.\text{state} = \text{pending} \\ \text{resolved} & \text{if } \forall d \in D: d.\text{state} = \text{resolved} \\ \text{rejected} & \text{if } \exists d \in D: d.\text{state} = \text{rejected} \end{cases}

渲染规则

  • pending\text{pending}:渲染 #fallback 插槽。
  • resolved\text{resolved}:渲染 #default 插槽。
  • rejected\text{rejected}:向上抛出错误,由 ErrorBoundary 或上层 Suspense 处理。

3.5 async setup() 的形式化

定义 3.7(async setup)async setup() 是返回 Promise 的 setup 函数:

asyncSetup:()Promise<Bindings>\text{asyncSetup}: () \to \text{Promise<Bindings>}

其中 Bindings\text{Bindings} 是模板可访问的响应式对象集合。

Suspense 集成:在 Suspense 内使用 async setup 时,Vue 自动将该 Promise 注册为依赖:

Suspense.register(asyncSetup())\text{Suspense.register}(\text{asyncSetup}())

Promise resolve 时递减依赖计数,归零时触发 Suspense resolve。

3.6 代码分割的形式化

定义 3.8(chunk 分割):设应用总代码量为 CC,分割为 nn 个 chunk:

C=c1c2cnC = c_1 \cup c_2 \cup \ldots \cup c_n

首屏加载量为 cinitialCc_{\text{initial}} \subseteq C,按需加载量为 clazy=Ccinitialc_{\text{lazy}} = C \setminus c_{\text{initial}}

目标:最小化首屏加载量 cinitial|c_{\text{initial}}|,同时控制总加载量 C|C|

约束

  • 用户访问路由 rr 时,必须加载 crc_r(该路由的组件)。
  • 共享依赖(如 Vue 运行时)应抽离为 vendor chunk,避免重复。

3.7 动态导入的形式化

定义 3.9(动态导入)import('./module') 返回一个 Promise:

import:ModuleSpecifierPromise<Module>\text{import}: \text{ModuleSpecifier} \to \text{Promise<Module>}

Webpack/Vite 将其转换为:

  1. chunk 创建:构建时将 module 拆分为独立文件。
  2. 运行时加载:通过 <script> 标签或 fetch 加载 chunk。
  3. 模块缓存:加载后缓存到全局,重复 import() 返回同一 Promise。

复杂度

  • 首次加载:O(c)O(|c|),需下载并解析整个 chunk。
  • 后续加载:O(1)O(1),从缓存读取。

4. 理论推导与原理解析 | Theoretical Derivation

4.1 defineAsyncComponent 的内部实现

Vue 3 的 defineAsyncComponent 内部实现是一个包装组件,维护状态机:

// Vue 3 内部实现(简化)
export function defineAsyncComponent(options) {
  if (typeof options === 'function') {
    options = { loader: options };
  }
  
  const { loader, loadingComponent, errorComponent, delay = 200, timeout, suspensible = true, onError } = options;
  
  let resolvedComponent = null;
  let loading = false;
  let error = null;
  let loaded = false;
  let retries = 0;
  
  return defineComponent({
    name: 'AsyncComponentWrapper',
    async setup() {
      // 如果已加载,直接返回
      if (loaded) {
        return { component: resolvedComponent };
      }
      
      // 加载组件
      const load = () => {
        if (loading) return;
        loading = true;
        error = null;
        
        const promise = loader()
          .then(c => {
            resolvedComponent = c.__esModule ? c.default : c;
            loaded = true;
          })
          .catch(err => {
            error = err;
            if (onError) {
              onError(err, () => {
                retries++;
                load();
              }, () => {}, retries);
            }
          });
        
        // 超时处理
        if (timeout) {
          setTimeout(() => {
            if (!loaded && !error) {
              error = new Error(`Async component timed out after ${timeout}ms`);
            }
          }, timeout);
        }
        
        return promise;
      };
      
      await load();
      
      return () => {
        if (loaded) return h(resolvedComponent);
        if (error && errorComponent) return h(errorComponent, { error });
        if (loading && loadingComponent) return h(loadingComponent);
        return null;
      };
    },
  });
}

关键点

  1. 缓存机制loadedresolvedComponent 在模块作用域缓存,重复挂载不重新加载。
  2. 重试支持onError 提供 retry 回调,递归调用 load
  3. 状态机loading/loaded/error 三态切换,驱动渲染。

4.2 Suspense 的依赖追踪机制

Suspense 内部维护一个 deps 集合与计数器:

// Vue 3 内部实现(简化)
const Suspense = {
  name: 'Suspense',
  setup(props, { slots }) {
    const suspense = {
      deps: 0,            // 待解决的依赖数
      pending: false,
      fallback: false,
      effects: [],
      resolve() {
        this.deps--;
        if (this.deps === 0) {
          this.pending = false;
          this.fallback = false;
          // 触发渲染默认插槽
        }
      },
      reject() {
        this.pending = false;
        // 向上抛出错误
      },
      register(dep) {
        this.deps++;
        this.pending = true;
        this.fallback = true;
        dep.then(this.resolve.bind(this)).catch(this.reject.bind(this));
      },
    };
    
    return () => {
      if (suspense.fallback) {
        return slots.fallback?.();
      }
      return slots.default?.();
    };
  },
};

依赖注册流程

  1. async setup 触发async setup() 返回 Promise,Vue 将其注册到最近的 Suspense。
  2. 递增计数器suspense.deps++suspense.pending = true
  3. Promise 链接:Promise resolve 时调用 resolve(),递减计数器。
  4. 归零触发渲染deps === 0 时切换到默认插槽。

4.3 async setup() 的执行流程

async setup() 的执行流程:

  1. setup 调用:Vue 调用 setup(),返回 Promise。
  2. Suspense 注册:Vue 将 Promise 注册到当前 Suspense。
  3. fallback 渲染:Suspense 渲染 #fallback 插槽。
  4. Promise resolve:异步操作完成,setup 返回 bindings。
  5. Suspense resolve:递减依赖计数,归零时切换到 #default
  6. 默认插槽渲染:渲染组件,使用 setup 返回的 bindings。

关键点

  • async setup() 中抛出的错误会被 Suspense 捕获,向上传播。
  • 多个 async setup() 嵌套时,Suspense 等待所有依赖完成。
  • async setup() 中可以使用 onMounted 等生命周期钩子,但需在 Promise resolve 后执行。

4.4 代码分割的性能分析

首屏加载量优化

设应用总代码量为 CC,首屏路由代码量为 crc_r,公共依赖为 vv

  • 无代码分割:首屏加载 CC,包含所有路由代码。
  • 有代码分割:首屏加载 v+crv + c_r,其余路由按需加载。

收益

speedup=Cv+cr\text{speedup} = \frac{C}{v + c_r}

C=2MBC = 2\text{MB}v=200KBv = 200\text{KB}cr=300KBc_r = 300\text{KB},则:

speedup=2000500=4 倍\text{speedup} = \frac{2000}{500} = 4 \text{ 倍}

代价

  • 路由切换时需加载新 chunk,增加延迟。
  • HTTP 请求数增多,需 HTTP/2 或预加载优化。

4.5 chunk 预加载策略

Vite/Webpack 提供多种预加载策略:

  1. <link rel="modulepreload">:Vite 默认为动态导入添加 modulepreload,并行加载 chunk。
  2. webpackPrefetch:Webpack 4+ 支持在 import 注释中声明 prefetch。
import(/* webpackPrefetch: true */ './module');
  1. 手动预加载:在用户 hover 链接时预加载对应 chunk。
link.addEventListener('mouseenter', () => {
  import('./route-component');
});

预加载的复杂度分析

  • 带宽成本:O(c)O(|c|),每个预加载消耗带宽。
  • 性能收益:用户实际访问时 O(1)O(1),从缓存读取。
  • 权衡:仅预加载高概率访问的 chunk。

4.6 Suspense 嵌套的复杂度

嵌套 Suspense 允许局部依赖独立管理:

<Suspense> (外层)
  <Header />
  <Suspense> (内层)
    <MainContent />
  </Suspense>
</Suspense>

渲染顺序

  1. 外层 Suspense 等待所有依赖(包括 Header 与内层 Suspense)。
  2. 内层 Suspense 独立等待 MainContent。
  3. 内层 resolve 后,外层 deps 递减;外层 deps 归零时整体 resolve。

复杂度

  • 依赖追踪:O(n)O(n)nn 为 Suspense 节点数。
  • 错误传播:内层错误可被外层捕获,也可独立处理。

4.7 与 React Suspense 的原理对比

React 18 的 Suspense 基于 throw Promise 模式:

// React 内部(简化)
function Suspense({ fallback, children }) {
  try {
    return children;
  } catch (promise) {
    if (promise instanceof Promise) {
      promise.then(() => rerender());
      return fallback;
    }
    throw promise;
  }
}

// 数据获取库使用 throw
function use(fetchPromise) {
  if (fetchPromise.pending) throw fetchPromise;
  return fetchPromise.result;
}

Vue 的优势

  • 显式依赖注册:async setup() 自动注册,无需 throw。
  • 响应式追踪:Vue 的响应式系统天然支持依赖追踪。
  • 性能更优:避免 throw 的栈展开开销。

React 的优势

  • 通用性:任意数据获取库(如 React Query)可通过 throw 集成。
  • 并发渲染:React 18 的 Concurrent Rendering 与 Suspense 深度集成。

4.8 SSR 流式渲染的原理

Vue 3.3+ 的 SSR 支持 Suspense 流式渲染:

import { renderToNodeStream } from 'vue/server-renderer';

const stream = renderToNodeStream(app);
stream.pipe(res);

流程

  1. 同步部分输出:非异步依赖立即输出 HTML。
  2. 异步依赖 pending:输出占位注释 <!-- suspense-pending -->
  3. 异步依赖 resolve:流式输出对应 HTML,替换占位。
  4. 完成:所有依赖 resolve,输出闭合标签。

性能收益

  • TTFB(Time To First Byte)显著降低:服务端无需等待所有数据即可响应。
  • 用户感知性能提升:浏览器逐步渲染,无需等待完整 HTML。

4.9 错误传播与捕获

Suspense 的错误传播规则:

  1. async setup 抛出错误:Promise reject,Suspense 进入 rejected 状态。
  2. 向上传播:错误向上冒泡,寻找 ErrorBoundary 或上层 Suspense 的 onError。
  3. 未捕获错误:若未捕获,Vue 在控制台警告并渲染空内容。

ErrorBoundary 模式

Vue 3 没有官方 ErrorBoundary 组件,但可通过 onErrorCaptured 钩子实现:

export default {
  setup(props, { slots }) {
    const error = ref(null);
    
    onErrorCaptured((err) => {
      error.value = err;
      return false; // 阻止错误继续传播
    });
    
    return () => {
      if (error.value) {
        return h(ErrorDisplay, { error: error.value, onRetry: () => error.value = null });
      }
      return slots.default?.();
    };
  },
};

5. 代码示例 | Code Examples

5.1 基础用法:路由懒加载

// router/index.ts —— Vue 3.4+ + Vue Router 4
import { createRouter, createWebHistory } from 'vue-router';
import Home from '../views/Home.vue'; // 首屏直接 import

const routes = [
  {
    path: '/',
    component: Home,
  },
  {
    path: '/about',
    // 异步加载 About 组件
    component: () => import('../views/About.vue'),
  },
  {
    path: '/dashboard',
    // 命名 chunk,便于分析与缓存
    component: () => import(/* webpackChunkName: "dashboard" */ '../views/Dashboard.vue'),
  },
  {
    path: '/settings',
    // Vite 风格的注释
    component: () => import('../views/Settings.vue'),
  },
];

export const router = createRouter({
  history: createWebHistory(),
  routes,
});

5.2 完整配置:defineAsyncComponent

<!-- AsyncComponent.vue —— Vue 3.4+ -->
<script setup>
import { defineAsyncComponent } from 'vue';
import LoadingSpinner from './LoadingSpinner.vue';
import ErrorDisplay from './ErrorDisplay.vue';

// 完整配置的异步组件
const HeavyChart = defineAsyncComponent({
  loader: () => import('./HeavyChart.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorDisplay,
  delay: 200,           // 延迟 200ms 显示 loading,避免闪烁
  timeout: 10000,       // 10 秒超时
  suspensible: true,    // 参与 Suspense 协调
  onError(error, retry, fail, attempts) {
    // 最多重试 3 次
    if (attempts <= 3) {
      console.warn(`Loading failed, retrying (${attempts}/3)...`, error);
      retry();
    } else {
      console.error('Max retries reached, giving up.', error);
      fail();
    }
  },
});
</script>

<template>
  <div class="container">
    <h2>Dashboard</h2>
    <HeavyChart :data="chartData" />
  </div>
</template>

5.3 Suspense 基础用法

<!-- AsyncPage.vue -->
<script setup>
import { defineAsyncComponent } from 'vue';

const AsyncHeader = defineAsyncComponent(() => import('./Header.vue'));
const AsyncContent = defineAsyncComponent(() => import('./Content.vue'));
const AsyncFooter = defineAsyncComponent(() => import('./Footer.vue'));
</script>

<template>
  <Suspense>
    <template #default>
      <div class="page">
        <AsyncHeader />
        <AsyncContent />
        <AsyncFooter />
      </div>
    </template>
    <template #fallback>
      <div class="loading">
        <LoadingSpinner size="large" />
        <p>Loading page...</p>
      </div>
    </template>
  </Suspense>
</template>

<style scoped>
.loading {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100vh;
  gap: 16px;
}
</style>

5.4 async setup() 与数据获取

<!-- UserProfile.vue -->
<script setup>
import { ref } from 'vue';

// async setup:Suspense 等待此 Promise
const user = ref(null);
const posts = ref([]);

// 并行获取用户信息与文章
async function fetchData() {
  const [userResponse, postsResponse] = await Promise.all([
    fetch('/api/user/1').then(r => r.json()),
    fetch('/api/posts?userId=1').then(r => r.json()),
  ]);
  user.value = userResponse;
  posts.value = postsResponse;
}

// setup 是 async 的
await fetchData();
</script>

<template>
  <div class="user-profile">
    <h1>{{ user.name }}</h1>
    <p>{{ user.email }}</p>
    <h2>Posts</h2>
    <ul>
      <li v-for="post in posts" :key="post.id">{{ post.title }}</li>
    </ul>
  </div>
</template>
<!-- App.vue —— 使用 Suspense 包裹 async setup 组件 -->
<script setup>
import UserProfile from './UserProfile.vue';
import LoadingSkeleton from './LoadingSkeleton.vue';
import { onErrorCaptured, ref } from 'vue';

const error = ref(null);

onErrorCaptured((err) => {
  error.value = err.message;
  return false; // 阻止错误继续传播
});
</script>

<template>
  <div v-if="error" class="error">
    <h2>Something went wrong</h2>
    <p>{{ error }}</p>
    <button @click="error = null">Retry</button>
  </div>
  
  <Suspense v-else>
    <template #default>
      <UserProfile />
    </template>
    <template #fallback>
      <LoadingSkeleton />
    </template>
  </Suspense>
</template>

5.5 嵌套 Suspense

<!-- NestedSuspense.vue -->
<script setup>
import { defineAsyncComponent } from 'vue';

const AsyncHeader = defineAsyncComponent(() => import('./Header.vue'));
const AsyncSidebar = defineAsyncComponent(() => import('./Sidebar.vue'));
const AsyncMain = defineAsyncComponent(() => import('./MainContent.vue'));
const AsyncComments = defineAsyncComponent(() => import('./Comments.vue'));
</script>

<template>
  <!-- 外层 Suspense:等待 Header 与 Sidebar -->
  <Suspense>
    <template #default>
      <div class="layout">
        <AsyncHeader />
        <div class="body">
          <AsyncSidebar />
          
          <!-- 内层 Suspense:等待 Main 与 Comments -->
          <main class="main">
            <Suspense>
              <template #default>
                <div>
                  <AsyncMain />
                  <AsyncComments />
                </div>
              </template>
              <template #fallback>
                <div class="main-skeleton">
                  <SkeletonLine />
                  <SkeletonLine />
                  <SkeletonLine />
                </div>
              </template>
            </Suspense>
          </main>
        </div>
      </div>
    </template>
    <template #fallback>
      <div class="page-skeleton">
        <SkeletonBlock height="60px" />
        <SkeletonBlock height="400px" />
      </div>
    </template>
  </Suspense>
</template>

5.6 Suspense 事件

<!-- SuspenseWithEvents.vue -->
<script setup>
import { ref, defineAsyncComponent } from 'vue';

const AsyncComp = defineAsyncComponent(() => import('./AsyncComp.vue'));

const status = ref('resolved');
const events = ref([]);

function onPending() {
  status.value = 'pending';
  events.value.push('pending at ' + new Date().toISOString());
  console.log('Suspense: pending');
}

function onResolve() {
  status.value = 'resolved';
  events.value.push('resolve at ' + new Date().toISOString());
  console.log('Suspense: resolved');
}

function onFallback() {
  status.value = 'fallback';
  events.value.push('fallback at ' + new Date().toISOString());
  console.log('Suspense: fallback');
}
</script>

<template>
  <div>
    <p>Status: {{ status }}</p>
    <Suspense @pending="onPending" @resolve="onResolve" @fallback="onFallback">
      <template #default>
        <AsyncComp />
      </template>
      <template #fallback>
        <LoadingSpinner />
      </template>
    </Suspense>
    
    <details>
      <summary>Events Log</summary>
      <ul>
        <li v-for="event in events" :key="event">{{ event }}</li>
      </ul>
    </details>
  </div>
</template>

5.7 错误处理与重试

<!-- AsyncWithErrorHandling.vue -->
<script setup>
import { defineAsyncComponent, ref, onErrorCaptured } from 'vue';

const retryCount = ref(0);
const maxRetries = 3;
const hasError = ref(false);

// 带重试机制的异步组件
const createAsyncComponent = () => defineAsyncComponent({
  loader: () => import('./UnstableComponent.vue'),
  loadingComponent: { template: '<div>Loading...</div>' },
  errorComponent: { template: '<div>Failed to load</div>' },
  delay: 200,
  timeout: 5000,
  onError(err, retry, fail, attempts) {
    retryCount.value = attempts;
    if (attempts <= maxRetries) {
      console.warn(`Attempt ${attempts} failed, retrying...`, err);
      setTimeout(retry, 1000 * attempts); // 指数退避
    } else {
      hasError.value = true;
      fail();
    }
  },
});

const AsyncComp = createAsyncComponent();

// 错误边界
onErrorCaptured((err, instance, info) => {
  console.error('Error captured:', err, info);
  hasError.value = true;
  return false; // 阻止错误传播
});

function reload() {
  hasError.value = false;
  retryCount.value = 0;
  // 强制重新加载组件
  location.reload();
}
</script>

<template>
  <div>
    <div v-if="hasError" class="error-boundary">
      <h2>Failed to load component</h2>
      <p>Retried {{ retryCount }} times.</p>
      <button @click="reload">Reload Page</button>
    </div>
    
    <Suspense v-else>
      <template #default>
        <AsyncComp />
      </template>
      <template #fallback>
        <div class="loading">
          <LoadingSpinner />
          <p v-if="retryCount > 0">Retrying... ({{ retryCount }}/{{ maxRetries }})</p>
        </div>
      </template>
    </Suspense>
  </div>
</template>

<style scoped>
.error-boundary {
  padding: 24px;
  background: #fee;
  border: 1px solid #f88;
  border-radius: 4px;
  text-align: center;
}
.error-boundary button {
  margin-top: 12px;
  padding: 8px 16px;
  background: #f66;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

5.8 Vite 批量异步加载

// utils/loadComponents.js —— Vue 3.4+ + Vite 5
import { defineAsyncComponent } from 'vue';

/**
 * 批量异步加载组件
 * @param {string} glob - glob 模式,如 './components/*.vue'
 * @returns {Object} 组件映射表
 */
export function loadComponents(glob) {
  const modules = import.meta.glob(glob);
  const components = {};
  
  for (const [path, loader] of Object.entries(modules)) {
    // 从路径提取组件名:./components/UserCard.vue -> UserCard
    const name = path.match(/\/([^/]+)\.vue$/)[1];
    components[name] = defineAsyncComponent({
      loader,
      loadingComponent: () => import('./LoadingSpinner.vue'),
      errorComponent: () => import('./ErrorDisplay.vue'),
      delay: 200,
    });
  }
  
  return components;
}

// 使用
const components = loadComponents('./components/*.vue');
// 注册全局
for (const [name, component] of Object.entries(components)) {
  // app.component(name, component);
}

5.9 预加载策略

// utils/preload.js
const preloaded = new Set();

/**
 * 预加载路由组件
 * @param {string} routeName - 路由名称
 */
export async function preloadRoute(routeName) {
  if (preloaded.has(routeName)) return;
  preloaded.add(routeName);
  
  const route = router.getRoutes().find(r => r.name === routeName);
  if (route && typeof route.component === 'function') {
    await route.component();
  }
}

// 在导航栏 hover 时预加载
document.querySelectorAll('a[href]').forEach(link => {
  link.addEventListener('mouseenter', () => {
    const route = link.getAttribute('href');
    const matched = router.resolve(route);
    if (matched.name) {
      preloadRoute(matched.name);
    }
  }, { once: true });
});

// 在空闲时预加载关键路由
if ('requestIdleCallback' in window) {
  requestIdleCallback(() => {
    preloadRoute('dashboard');
    preloadRoute('profile');
  });
} else {
  setTimeout(() => {
    preloadRoute('dashboard');
    preloadRoute('profile');
  }, 3000);
}

5.10 组件加载状态可视化

<!-- AsyncLoader.vue —— 可视化加载状态 -->
<script setup>
import { defineAsyncComponent, ref, computed } from 'vue';

const props = defineProps({
  loader: { type: Function, required: true },
  delay: { type: Number, default: 200 },
  timeout: { type: Number, default: 10000 },
});

const state = ref('idle'); // idle, loading, loaded, error
const loadingTime = ref(0);
const error = ref(null);
let timer = null;
let startTime = 0;

const AsyncComponent = defineAsyncComponent({
  loader: props.loader,
  loadingComponent: {
    setup() {
      return () => null; // 由外层控制 loading UI
    },
  },
  delay: props.delay,
  timeout: props.timeout,
  onError(err, retry, fail, attempts) {
    error.value = err;
    state.value = 'error';
  },
});

const progress = computed(() => {
  if (state.value === 'loaded') return 100;
  if (state.value === 'loading') {
    return Math.min(90, (loadingTime.value / props.timeout) * 100);
  }
  return 0;
});

function startLoading() {
  state.value = 'loading';
  startTime = Date.now();
  timer = setInterval(() => {
    loadingTime.value = Date.now() - startTime;
  }, 100);
}

function stopLoading() {
  clearInterval(timer);
  state.value = 'loaded';
}

// 监听加载完成
// 实际实现需更复杂,此处示意
</script>

<template>
  <div class="async-loader">
    <div v-if="state === 'loading'" class="loading">
      <div class="progress-bar">
        <div class="progress" :style="{ width: progress + '%' }"></div>
      </div>
      <p>Loading... {{ loadingTime }}ms</p>
    </div>
    
    <div v-else-if="state === 'error'" class="error">
      <p>Failed to load: {{ error?.message }}</p>
      <button @click="$emit('retry')">Retry</button>
    </div>
    
    <Suspense @pending="startLoading" @resolve="stopLoading">
      <template #default>
        <AsyncComponent />
      </template>
    </Suspense>
  </div>
</template>

<style scoped>
.async-loader {
  position: relative;
}
.progress-bar {
  width: 100%;
  height: 4px;
  background: #eee;
  border-radius: 2px;
  overflow: hidden;
}
.progress {
  height: 100%;
  background: #007bff;
  transition: width 0.1s;
}
</style>

5.11 企业级异步组件注册器

// utils/asyncComponents.ts —— 企业级异步组件注册
import { defineAsyncComponent, type Component } from 'vue';
import LoadingSpinner from '@/components/LoadingSpinner.vue';
import ErrorDisplay from '@/components/ErrorDisplay.vue';

interface AsyncComponentOptions {
  loader: () => Promise<{ default: Component } | Component>;
  delay?: number;
  timeout?: number;
  retries?: number;
  retryDelay?: number;
}

const defaultLoading = LoadingSpinner;
const defaultError = ErrorDisplay;

export function createAsyncComponent(options: AsyncComponentOptions) {
  const {
    loader,
    delay = 200,
    timeout = 10000,
    retries = 3,
    retryDelay = 1000,
  } = options;
  
  let attempts = 0;
  
  return defineAsyncComponent({
    loader,
    loadingComponent: defaultLoading,
    errorComponent: defaultError,
    delay,
    timeout,
    onError(err, retry, fail) {
      attempts++;
      if (attempts <= retries) {
        console.warn(
          `[AsyncComponent] Loading failed (attempt ${attempts}/${retries})`,
          err
        );
        setTimeout(retry, retryDelay * attempts); // 指数退避
      } else {
        console.error('[AsyncComponent] Max retries reached', err);
        fail();
      }
    },
  });
}

// 集中注册所有异步组件
export function registerAsyncComponents(app) {
  const components = {
    'HeavyChart': () => import('@/components/HeavyChart.vue'),
    'MarkdownEditor': () => import('@/components/MarkdownEditor.vue'),
    'CodeBlock': () => import('@/components/CodeBlock.vue'),
    'DataGrid': () => import('@/components/DataGrid.vue'),
    'RichTextEditor': () => import('@/components/RichTextEditor.vue'),
  };
  
  for (const [name, loader] of Object.entries(components)) {
    app.component(name, createAsyncComponent({ loader }));
  }
}

// main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { registerAsyncComponents } from './utils/asyncComponents';

const app = createApp(App);
registerAsyncComponents(app);
app.mount('#app');

5.12 SSR 流式渲染示例

// server.js —— Vue 3.4+ SSR 流式渲染
import express from 'express';
import { createSSRApp, h } from 'vue';
import { renderToNodeStream } from 'vue/server-renderer';
import App from './App.vue';

const server = express();

server.get('*', async (req, res) => {
  const app = createSSRApp({
    render: () => h(App),
  });
  
  // 设置响应头
  res.setHeader('Content-Type', 'text/html');
  res.setHeader('Transfer-Encoding', 'chunked');
  
  // 输出 HTML 头部
  res.write(`
    <!DOCTYPE html>
    <html>
    <head>
      <title>Vue SSR Streaming</title>
    </head>
    <body>
    <div id="app">
  `);
  
  // 流式渲染
  const stream = renderToNodeStream(app);
  stream.pipe(res, { end: false });
  
  stream.on('end', () => {
    res.write(`
      </div>
      <script type="module" src="/client.js"></script>
      </body>
      </html>
    `);
    res.end();
  });
  
  stream.on('error', (err) => {
    console.error('SSR Error:', err);
    res.status(500).send('Server Error');
  });
});

server.listen(3000, () => {
  console.log('Server running at http://localhost:3000');
});

6. 对比分析 | Comparative Analysis

6.1 与 React.lazy/Suspense 的对比

维度Vue 3 defineAsyncComponentReact.lazy
API 形式defineAsyncComponent(options)React.lazy(loader)
loading 配置内置 loadingComponentSuspense 的 fallback
error 配置内置 errorComponentErrorBoundary
超时内置 timeout手动实现
重试内置 onError手动实现
async setup支持无对应概念
嵌套 Suspense支持支持
数据获取async setup() 集成React Query / SWR
SSR流式渲染流式渲染
并发渲染不支持(响应式)Concurrent Rendering
包体积较大较小

关键差异

  1. 配置完整性:Vue 内置 loading/error/timeout/重试,React 需配合 ErrorBoundary 与外部库。
  2. 数据获取集成:Vue 的 async setup() 原生支持组件级数据获取与 Suspense 集成,React 需借助 React Query 等。
  3. 并发渲染:React 18 的 Concurrent Rendering 支持中断与重试,Vue 基于响应式系统,无中断。

6.2 与 Solid.js lazy/Suspense 的对比

维度Vue 3Solid.js
异步组件defineAsyncComponentlazy()
Suspense内置内置
数据获取async setup()createResource()
信号系统ref/reactiveSignal
渲染粒度组件级节点级(细粒度)
性能优秀极佳

关键差异

  • Solid.js 的细粒度响应式使其性能更优,但学习成本较高。
  • Vue 的组件级渲染粒度更易理解,社区生态更成熟。

6.3 与 Svelte 异步组件的对比

维度Vue 3Svelte
异步组件defineAsyncComponent动态 import + await
Suspense内置无原生 Suspense
数据获取async setup()async module + await
编译时优化部分深度优化
包体积35KB5KB(编译后)

关键差异

  • Svelte 无原生 Suspense,需手动管理加载态。
  • Svelte 的编译时优化使其运行时极小,但灵活性受限。

6.4 与 Angular 异步路由的对比

维度Vue 3Angular
异步路由() => import()loadChildren
Suspense内置Router 内置
数据获取async setup()Resolve 守卫
依赖注入provide/inject完整 DI 容器
学习成本

6.5 综合选型决策矩阵

场景推荐方案
路由懒加载() => import()
组件懒加载defineAsyncComponent
多组件统一加载态Suspense
组件级数据获取async setup() + Suspense
错误边界onErrorCaptured + 错误组件
重试机制onError 回调
预加载import.meta.glob + hover 监听
SSR 流式renderToNodeStream + Suspense

7. 常见陷阱与最佳实践 | Pitfalls and Best Practices

7.1 陷阱:async setup() 在 Suspense 外使用

错误代码

<!-- Component.vue -->
<script setup>
// async setup 必须在 Suspense 内使用
const data = await fetch('/api/data').then(r => r.json());
</script>
<!-- App.vue —— 未使用 Suspense -->
<template>
  <Component /> <!-- 警告:async setup() used without Suspense -->
</template>

正确做法

<template>
  <Suspense>
    <template #default>
      <Component />
    </template>
    <template #fallback>
      <LoadingSpinner />
    </template>
  </Suspense>
</template>

7.2 陷阱:loading 闪烁

错误代码

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  loadingComponent: LoadingSpinner,
  delay: 0, // 立即显示 loading,快速加载时闪烁
});

正确做法

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  loadingComponent: LoadingSpinner,
  delay: 200, // 延迟 200ms,快速加载时不显示 loading
});

原理delay 防止快速加载时 loading 闪烁,提升用户体验。200ms 是经验值,根据实际加载时间调整。

7.3 陷阱:未处理超时

错误代码

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorDisplay,
  // 无 timeout,加载失败时永远显示 loading
});

正确做法

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorDisplay,
  timeout: 10000, // 10 秒超时
});

7.4 陷阱:Suspense 未捕获错误

错误代码

<Suspense>
  <template #default>
    <AsyncComponent />
  </template>
  <template #fallback>
    <LoadingSpinner />
  </template>
</Suspense>
<!-- 无 onErrorCaptured,错误向上传播导致整页崩溃 -->

正确做法

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

const error = ref(null);

onErrorCaptured((err) => {
  error.value = err;
  return false; // 阻止传播
});
</script>

<template>
  <div v-if="error">
    <ErrorDisplay :error="error" />
  </div>
  <Suspense v-else>
    <template #default>
      <AsyncComponent />
    </template>
    <template #fallback>
      <LoadingSpinner />
    </template>
  </Suspense>
</template>

7.5 陷阱:循环依赖导致加载失败

错误代码

// A.js
import B from './B.js'; // B 又 import A,循环依赖

// A.vue
export default {
  components: { B },
};

说明:循环依赖在异步加载时更易暴露,Webpack/Vite 可能返回 undefined。

解决

  • 重构代码,消除循环依赖。
  • 使用动态 import 延迟加载:const B = () => import('./B.vue')

7.6 陷阱:chunk 命名冲突

错误代码

// 多个异步组件未命名,Webpack 自动生成 0.js, 1.js...
const A = () => import('./A.vue');
const B = () => import('./B.vue');

正确做法

// 使用 webpackChunkName 注释命名
const A = () => import(/* webpackChunkName: "a" */ './A.vue');
const B = () => import(/* webpackChunkName: "b" */ './B.vue');

7.7 陷阱:Suspense 嵌套过深

错误代码

<Suspense>
  <Suspense>
    <Suspense>
      <Suspense>
        <Component /> <!-- 4 层嵌套,调试困难 -->
      </Suspense>
    </Suspense>
  </Suspense>
</Suspense>

建议:限制 Suspense 嵌套层级,通常 2 层足够。深层嵌套应重构组件结构。

7.8 陷阱:async setup 中使用生命周期钩子

错误代码

export default {
  async setup() {
    // onMounted 在 await 之后注册,已晚于挂载
    const data = await fetchData();
    onMounted(() => {
      console.log('mounted', data); // 警告:onMounted called after await
    });
  },
};

正确做法

export default {
  async setup() {
    onMounted(() => {
      console.log('mounted'); // 在 await 之前注册
    });
    
    const data = await fetchData();
    return { data };
  },
};

原理:Vue 的生命周期钩子需在 setup 同步执行期间注册,await 之后的代码异步执行,钩子注册失败。

7.9 最佳实践:合理使用 delay

// 根据 loader 平均加载时间调整 delay
const AsyncComp = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  loadingComponent: LoadingSpinner,
  delay: 200, // 若平均加载时间 < 200ms,loading 不显示
  timeout: 10000,
});

7.10 最佳实践:错误恢复

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  errorComponent: {
    template: `
      <div class="error">
        <p>Failed to load component</p>
        <button @click="$emit('retry')">Retry</button>
      </div>
    `,
    emits: ['retry'],
  },
  onError(err, retry, fail, attempts) {
    if (attempts <= 3) {
      setTimeout(retry, 1000 * attempts);
    } else {
      fail();
    }
  },
});

7.11 最佳实践:预加载关键路由

// router/index.ts
import { createRouter } from 'vue-router';

const routes = [
  { path: '/', component: () => import('./Home.vue') },
  { path: '/dashboard', component: () => import('./Dashboard.vue') },
  // 关键路由预加载
  { path: '/profile', component: () => import(/* webpackPrefetch: true */ './Profile.vue') },
];

const router = createRouter({ routes });

// 路由前置守卫中预加载下一页
router.beforeEach((to) => {
  if (to.meta.preloadNext) {
    const nextRoutes = findAdjacentRoutes(to);
    nextRoutes.forEach(route => route.component());
  }
});

7.12 最佳实践:chunk 分析与优化

// vite.config.ts —— 分析 chunk 大小
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    vue(),
    visualizer({
      filename: 'dist/stats.html',
      gzipSize: true,
      brotliSize: true,
    }),
  ],
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vue-vendor': ['vue', 'vue-router', 'pinia'],
          'ui-vendor': ['element-plus'],
          'chart-vendor': ['echarts', 'vue-echarts'],
        },
      },
    },
  },
});

7.13 最佳实践:SSR 错误降级

// SSR 中处理异步组件错误
async function render(url) {
  const app = createSSRApp(App);
  
  // 捕获 Suspense 错误,降级为客户端加载
  app.config.errorHandler = (err) => {
    console.error('SSR Error:', err);
  };
  
  try {
    const html = await renderToString(app, {
      onError(err) {
        console.warn('SSR async error, fallback to client:', err);
      },
    });
    return html;
  } catch (err) {
    // 降级为 CSR
    return renderCSR(url);
  }
}

8. 工程实践 | Engineering Practice

8.1 项目结构组织

src/
├── components/
│   ├── async/                # 异步组件目录
│   │   ├── HeavyChart.vue
│   │   ├── MarkdownEditor.vue
│   │   └── DataGrid.vue
│   ├── common/               # 通用组件
│   │   ├── LoadingSpinner.vue
│   │   ├── ErrorDisplay.vue
│   │   └── Skeleton.vue
│   └── ...
├── composables/
│   ├── useAsyncComponent.ts  # 异步组件 Composable
│   └── usePreload.ts         # 预加载 Composable
├── utils/
│   ├── asyncComponents.ts    # 异步组件注册器
│   └── chunkAnalyzer.ts      # chunk 分析工具
├── router/
│   └── index.ts              # 路由配置(含懒加载)
└── main.ts                   # 应用入口

8.2 Vite 配置优化

// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    vue(),
    visualizer({ open: false, gzipSize: true }),
  ],
  build: {
    target: 'es2020',
    cssCodeSplit: true, // CSS 代码分割
    rollupOptions: {
      output: {
        // 手动 chunk 分割
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (id.includes('vue') || id.includes('pinia')) {
              return 'vue-vendor';
            }
            if (id.includes('element-plus')) {
              return 'ui-vendor';
            }
            if (id.includes('echarts')) {
              return 'chart-vendor';
            }
            return 'vendor';
          }
        },
        // chunk 文件名带 hash
        chunkFileNames: 'assets/[name]-[hash].js',
        entryFileNames: 'assets/[name]-[hash].js',
        assetFileNames: 'assets/[name]-[hash].[ext]',
      },
    },
    // chunk 大小警告阈值
    chunkSizeWarningLimit: 500,
  },
  experimental: {
    // Vite 5+ 的预加载优化
    renderBuiltUrl(filename, { hostType }) {
      if (hostType === 'js') {
        return { relative: filename };
      }
      return filename;
    },
  },
});

8.3 Vue Router 懒加载

// router/index.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'home',
    // 首页直接加载,提升首屏速度
    component: () => import('@/views/Home.vue'),
  },
  {
    path: '/dashboard',
    name: 'dashboard',
    // 仪表盘懒加载
    component: () => import('@/views/Dashboard.vue'),
    meta: { requiresAuth: true },
  },
  {
    path: '/profile',
    name: 'profile',
    component: () => import('@/views/Profile.vue'),
    meta: { preload: true }, // 标记预加载
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'not-found',
    component: () => import('@/views/NotFound.vue'),
  },
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

// 预加载标记的路由
router.beforeEach((to) => {
  if (to.meta.preload) {
    // 加载下一级路由
    router.getRoutes().forEach(route => {
      if (route.meta?.preload && route.name !== to.name) {
        route.component?.();
      }
    });
  }
});

export default router;

8.4 单元测试

// tests/components/AsyncComponent.spec.ts
import { describe, it, expect, vi } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { defineAsyncComponent, h, Suspense } from 'vue';
import AsyncComponent from '@/components/AsyncComponent.vue';

describe('AsyncComponent', () => {
  it('renders loading state initially', async () => {
    const wrapper = mount({
      components: { AsyncComponent },
      template: `
        <Suspense>
          <template #default>
            <AsyncComponent />
          </template>
          <template #fallback>
            <div class="loading">Loading...</div>
          </template>
        </Suspense>
      `,
    });
    
    expect(wrapper.find('.loading').exists()).toBe(true);
  });
  
  it('renders component after loading', async () => {
    const wrapper = mount(Suspense, {
      slots: {
        default: h(AsyncComponent),
        fallback: h('div', { class: 'loading' }, 'Loading...'),
      },
    });
    
    await flushPromises();
    expect(wrapper.find('.loading').exists()).toBe(false);
    expect(wrapper.text()).toContain('Async Component');
  });
  
  it('retries on loading failure', async () => {
    let attempts = 0;
    const loader = vi.fn(() => {
      attempts++;
      if (attempts < 3) {
        return Promise.reject(new Error('Network error'));
      }
      return Promise.resolve({ default: { template: '<div>Loaded</div>' } });
    });
    
    const AsyncComp = defineAsyncComponent({
      loader,
      onError(err, retry, fail, attemptCount) {
        if (attemptCount <= 3) retry();
        else fail();
      },
    });
    
    const wrapper = mount(Suspense, {
      slots: {
        default: h(AsyncComp),
        fallback: h('div', 'Loading'),
      },
    });
    
    await flushPromises();
    expect(attempts).toBe(3);
    expect(wrapper.text()).toContain('Loaded');
  });
});

8.5 集成测试

// tests/integration/route.spec.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createRouter, createMemoryHistory } from 'vue-router';
import App from '@/App.vue';

describe('Route lazy loading', () => {
  it('loads route component on navigation', async () => {
    const router = createRouter({
      history: createMemoryHistory(),
      routes: [
        { path: '/', component: { template: '<div>Home</div>' } },
        { path: '/about', component: () => import('@/views/About.vue') },
      ],
    });
    
    const wrapper = mount(App, {
      global: {
        plugins: [router],
      },
    });
    
    await router.push('/');
    await router.isReady();
    expect(wrapper.text()).toContain('Home');
    
    await router.push('/about');
    await new Promise(resolve => setTimeout(resolve, 100)); // 等待异步加载
    expect(wrapper.text()).toContain('About');
  });
});

8.6 性能监控

// composables/useChunkPerformance.ts
import { onMounted, ref } from 'vue';

interface ChunkLoadMetric {
  name: string;
  loadTime: number;
  size: number;
  timestamp: number;
}

const metrics = ref<ChunkLoadMetric[]>([]);

export function useChunkPerformance() {
  onMounted(() => {
    // 监听 chunk 加载
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.name.includes('.js')) {
          metrics.value.push({
            name: entry.name,
            loadTime: entry.duration,
            size: entry.transferSize || 0,
            timestamp: Date.now(),
          });
        }
      }
    });
    
    observer.observe({ entryTypes: ['resource'] });
  });
  
  return {
    metrics,
    getSlowChunks: (threshold = 1000) => metrics.value.filter(m => m.loadTime > threshold),
    getTotalSize: () => metrics.value.reduce((sum, m) => sum + m.size, 0),
  };
}

8.7 SSR 流式渲染实践

// server/index.ts —— Nuxt 3 风格的 SSR
import express from 'express';
import { createSSRApp, h } from 'vue';
import { renderToNodeStream } from 'vue/server-renderer';
import { createRouter } from './router';
import App from './App.vue';

const server = express();

server.use(express.static('public'));

server.get('*', async (req, res) => {
  const app = createSSRApp({
    render: () => h(App),
  });
  
  const router = createRouter();
  app.use(router);
  await router.push(req.url);
  
  // 设置 HTML 头部
  res.writeHead(200, { 'Content-Type': 'text/html' });
  res.write('<!DOCTYPE html><html><head><title>SSR</title></head><body><div id="app">');
  
  // 流式渲染
  const stream = renderToNodeStream(app);
  
  stream.on('data', (chunk) => {
    res.write(chunk);
  });
  
  stream.on('end', () => {
    res.write('</div><script type="module" src="/client.js"></script></body></html>');
    res.end();
  });
  
  stream.on('error', (err) => {
    console.error('SSR stream error:', err);
    // 降级为客户端渲染
    res.write('</div><script>window.__SSR_FAILED__ = true;</script>');
    res.write('<script type="module" src="/client.js"></script></body></html>');
    res.end();
  });
});

server.listen(3000);

8.8 调试工具

// devtools/asyncInspector.ts
import type { App } from 'vue';

export function setupAsyncInspector(app: App) {
  if (!import.meta.env.DEV) return;
  
  // 监听 chunk 加载
  const originalImport = window.__vite__loadDynamicImport;
  window.__vite__loadDynamicImport = async function (...args) {
    const start = performance.now();
    const result = await originalImport.apply(this, args);
    const duration = performance.now() - start;
    
    console.debug(
      `[async] Loaded ${args[0]} in ${duration.toFixed(2)}ms`,
      { size: result.__chunkSize || 'unknown' }
    );
    
    return result;
  };
  
  // Vue Devtools 自定义面板
  if (window.__VUE_DEVTOOLS_GLOBAL_HOOK__) {
    window.__VUE_DEVTOOLS_GLOBAL_HOOK__.emit('custom-inspector', {
      id: 'async-components',
      label: 'Async Components',
      icon: '⚡',
      tree: () => getAsyncComponentTree(app._instance),
    });
  }
}

function getAsyncComponentTree(instance: any): any {
  // 遍历组件树,收集异步组件信息
  return {
    name: instance.type.name || 'Anonymous',
    isAsync: !!instance.type.__asyncLoader,
    children: (instance.subTree?.children || []).map(getAsyncComponentTree),
  };
}

9. 案例研究 | Case Studies

9.1 案例一:Nuxt 3 的路由懒加载

Nuxt 3 默认将所有页面组件懒加载,无需手动配置:

// nuxt.config.ts
export default defineNuxtConfig({
  // Nuxt 自动将 pages/*.vue 懒加载
  pages: true,
  
  experimental: {
    // 启用组件自动导入
    componentIslands: true,
  },
});

// pages/about.vue 自动懒加载
// 无需手动 import() 或 defineAsyncComponent

设计要点

  1. 零配置:Nuxt 自动处理页面懒加载,开发者无需关心。
  2. 预取策略:Nuxt 默认预取所有可见链接对应的 chunk。
  3. Suspense 集成:Nuxt 的 <NuxtPage> 内置 Suspense,管理加载态。

9.2 案例二:Element Plus 的按需加载

Element Plus 通过异步组件实现按需加载:

// plugins/element-plus.ts
import { defineAsyncComponent } from 'vue';

// 按需加载 Element Plus 组件
const components = {
  ElButton: () => import('element-plus/es/components/button'),
  ElInput: () => import('element-plus/es/components/input'),
  ElSelect: () => import('element-plus/es/components/select'),
  ElTable: () => import('element-plus/es/components/table'),
};

export default defineNuxtPlugin((nuxtApp) => {
  for (const [name, loader] of Object.entries(components)) {
    nuxtApp.vueApp.component(name, defineAsyncComponent(loader));
  }
});

设计要点

  1. ES Module 按需:从 element-plus/es/components/ 导入,避免打包整个库。
  2. 异步注册:组件首次使用时加载,未使用的组件不打包。
  3. Tree Shaking 配合:与 Vite/Webpack 的 Tree Shaking 协同,进一步减小体积。

9.3 案例三:VitePress 的异步加载

VitePress 大量使用异步组件加载 Markdown 渲染器:

// VitePress 源码(简化)
import { defineAsyncComponent } from 'vue';

const MarkdownRenderer = defineAsyncComponent({
  loader: () => import('./MarkdownRenderer.vue'),
  loadingComponent: { template: '<div>Loading content...</div>' },
  errorComponent: { template: '<div>Failed to load content</div>' },
  delay: 0, // 立即显示 loading
  timeout: 30000, // 30 秒超时
});

export default {
  components: { MarkdownRenderer },
};

9.4 案例四:Vue Router 的懒加载

Vue Router 4 推荐使用动态 import 实现懒加载:

// router/index.ts
const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'),
    // 路由独享的预加载
    meta: { 
      webpackPrefetch: true,
      webpackPreload: true,
    },
  },
];

const router = createRouter({ routes });

// 全局预加载策略
router.beforeEach(async (to) => {
  // 预加载目标路由的 chunk
  if (typeof to.matched[0]?.components?.default === 'function') {
    to.matched[0].components.default();
  }
});

9.5 案例五:企业级仪表盘的异步加载

<!-- Dashboard.vue —— 企业级仪表盘 -->
<script setup lang="ts">
import { defineAsyncComponent, ref, computed } from 'vue';

// 根据用户权限动态加载图表组件
const userPermissions = ref<string[]>([]);

const chartComponents = {
  sales: defineAsyncComponent(() => import('./charts/SalesChart.vue')),
  revenue: defineAsyncComponent(() => import('./charts/RevenueChart.vue')),
  users: defineAsyncComponent(() => import('./charts/UsersChart.vue')),
  performance: defineAsyncComponent(() => import('./charts/PerformanceChart.vue')),
};

// 权限过滤
const visibleCharts = computed(() => {
  return Object.entries(chartComponents)
    .filter(([key]) => userPermissions.value.includes(`view:${key}`))
    .reduce((acc, [key, component]) => ({ ...acc, [key]: component }), {});
});

// 加载状态
const loadingCharts = ref<Set<string>>(new Set());
const errorCharts = ref<Record<string, Error>>({});

function onChartLoading(name: string) {
  loadingCharts.value.add(name);
}

function onChartLoaded(name: string) {
  loadingCharts.value.delete(name);
}

function onChartError(name: string, error: Error) {
  errorCharts.value[name] = error;
  loadingCharts.value.delete(name);
}
</script>

<template>
  <div class="dashboard">
    <h1>Dashboard</h1>
    
    <div class="grid">
      <div
        v-for="(component, name) in visibleCharts"
        :key="name"
        class="chart-card"
      >
        <h2>{{ name }}</h2>
        
        <Suspense @pending="onChartLoading(name)" @resolve="onChartLoaded(name)">
          <template #default>
            <component :is="component" />
          </template>
          <template #fallback>
            <div class="chart-skeleton">
              <SkeletonBlock height="300px" />
            </div>
          </template>
        </Suspense>
        
        <div v-if="errorCharts[name]" class="chart-error">
          Failed to load: {{ errorCharts[name].message }}
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
  gap: 24px;
}
.chart-card {
  background: white;
  border-radius: 8px;
  padding: 16px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.chart-skeleton {
  height: 300px;
  background: linear-gradient(90deg, #f0f0f0, #e0e0e0, #f0f0f0);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 4px;
}
@keyframes shimmer {
  0% { background-position: -200% 0; }
  100% { background-position: 200% 0; }
}
</style>

9.6 案例六:电商商品详情页的异步加载

<!-- ProductDetail.vue -->
<script setup>
import { defineAsyncComponent, ref, watchEffect } from 'vue';

const props = defineProps<{ productId: string }>();

// 根据商品类型动态加载不同的详情组件
const product = ref(null);
const detailComponent = ref(null);

async function loadProduct() {
  product.value = await fetch(`/api/products/${props.productId}`).then(r => r.json());
  
  // 根据类型选择组件
  const componentMap = {
    physical: () => import('./PhysicalProductDetail.vue'),
    digital: () => import('./DigitalProductDetail.vue'),
    subscription: () => import('./SubscriptionDetail.vue'),
  };
  
  detailComponent.value = defineAsyncComponent(
    componentMap[product.value.type] || componentMap.physical
  );
}

watchEffect(() => {
  if (props.productId) {
    loadProduct();
  }
});
</script>

<template>
  <div class="product-detail">
    <Suspense>
      <template #default>
        <component :is="detailComponent" :product="product" />
      </template>
      <template #fallback>
        <ProductSkeleton />
      </template>
    </Suspense>
  </div>
</template>

10. 习题 | Exercises

10.1 选择题

题目 1defineAsyncComponentdelay 参数的作用是什么?

A. 加载超时时间。 B. 延迟显示 loading 组件,避免闪烁。 C. 加载失败后的重试间隔。 D. 组件挂载后的延迟执行时间。

答案:B

解析delay 是延迟显示 loading 组件的时间(毫秒)。若组件在 delay 时间内加载完成,则不显示 loading,避免快速加载时的闪烁。默认值 200ms。


题目 2:以下关于 Suspense 的描述,哪一项是错误的?

A. Suspense 等待所有异步依赖完成后渲染 #default 插槽。 B. Suspense 支持嵌套,内层 Suspense 独立管理加载态。 C. Suspense#fallback 插槽在异步依赖 pending 时渲染。 D. Suspense 可在任意组件中使用,无需包裹 async setup。

答案:D

解析async setup() 必须在 Suspense 内使用,否则 Vue 会抛出警告。Suspense 通过依赖追踪机制协调异步组件与 async setup()


题目 3defineAsyncComponentonError 回调的 retry 函数作用是什么?

A. 重新执行 loader 函数。 B. 重置组件状态。 C. 切换到 error 组件。 D. 取消当前加载。

答案:A

解析onError(error, retry, fail, attempts) 中的 retry 函数重新执行 loader,用于实现重试机制。fail 表示放弃重试,attempts 是当前重试次数。


题目 4:在 SSR 中,async setup() 的行为是?

A. 服务端跳过,仅在客户端执行。 B. 服务端同步等待 Promise resolve。 C. 服务端立即返回 fallback。 D. 服务端抛出错误。

答案:B

解析:SSR 中 async setup() 的 Promise 会被服务端同步等待,确保渲染的 HTML 包含完整数据。Vue 3.3+ 支持流式渲染,服务端在异步依赖完成时流式输出对应 HTML。


题目 5import('./Component.vue') 在 Vite/Webpack 中会被转换为?

A. 同步 require 调用。 B. 独立 chunk 文件,运行时动态加载。 C. 内联到主 bundle。 D. 编译时静态分析。

答案:B

解析:Vite/Webpack 将 import() 动态导入转换为独立 chunk 文件,运行时通过 <script> 标签或 fetch 加载。这是代码分割的核心机制。


10.2 填空题

题目 1defineAsyncComponent 的内部状态机包含 ________________________ 四种状态。

答案idleloadingloadederror

解析:异步组件包装器维护四态状态机:idle(初始)→ loading(加载中)→ loaded(加载成功)或 error(加载失败)。loaded 是吸收态,error 可通过 retry 回到 loading。


题目 2Suspense 通过维护 ______ 计数器追踪异步依赖,归零时触发 ______

答案depsresolve

解析:Suspense 内部维护 deps 计数器,每个异步依赖注册时递增,完成时递减。归零时 Suspense 切换到 resolved 状态,渲染默认插槽。


题目 3:Vite 使用 ______ 函数实现批量动态导入,支持 glob 模式。

答案import.meta.glob

解析:Vite 提供 import.meta.glob('./dir/*.vue') 实现 glob 模式的批量动态导入,返回路径到 loader 的映射表。


题目 4async setup() 中使用 onMounted 等生命周期钩子时,必须在 ______ 之前注册。

答案await

解析:Vue 的生命周期钩子需在 setup 同步执行期间注册,await 之后的代码异步执行,钩子注册失败。


题目 5:React 的 Suspense 基于 ______ Promise 模式,Vue 的 Suspense 基于 ______ 注册。

答案throw,显式依赖

解析:React 18 的 Suspense 通过 throw Promise 实现,数据获取库需配合 throw。Vue 的 Suspense 通过 async setup() 自动注册依赖,无需 throw。


10.3 编程题

题目 1:实现一个带指数退避重试的异步组件加载器。

// 参考答案
import { defineAsyncComponent, type Component } from 'vue';

interface RetryOptions {
  maxRetries: number;
  baseDelay: number;
  maxDelay: number;
}

export function createRetryableAsyncComponent(
  loader: () => Promise<{ default: Component } | Component>,
  loadingComponent: Component,
  errorComponent: Component,
  options: RetryOptions = { maxRetries: 3, baseDelay: 1000, maxDelay: 30000 }
) {
  let attempts = 0;
  
  return defineAsyncComponent({
    loader,
    loadingComponent,
    errorComponent,
    delay: 200,
    timeout: 10000,
    onError(err, retry, fail, attemptCount) {
      attempts = attemptCount;
      
      if (attemptCount <= options.maxRetries) {
        // 指数退避:baseDelay * 2^(attempts-1),上限 maxDelay
        const delay = Math.min(
          options.baseDelay * Math.pow(2, attemptCount - 1),
          options.maxDelay
        );
        
        console.warn(
          `[RetryableAsync] Attempt ${attemptCount}/${options.maxRetries} failed, ` +
          `retrying in ${delay}ms...`,
          err
        );
        
        setTimeout(retry, delay);
      } else {
        console.error(
          `[RetryableAsync] Max retries (${options.maxRetries}) reached`,
          err
        );
        fail();
      }
    },
  });
}

题目 2:实现一个基于 Suspense 的数据获取 Composable useResource

// 参考答案
import { ref, type Ref } from 'vue';

interface Resource<T> {
  data: Ref<T | null>;
  error: Ref<Error | null>;
  loading: Ref<boolean>;
  reload: () => Promise<void>;
}

export function useResource<T>(
  fetcher: () => Promise<T>,
  options: { immediate?: boolean } = {}
): Resource<T> {
  const data = ref<T | null>(null) as Ref<T | null>;
  const error = ref<Error | null>(null);
  const loading = ref(false);
  
  async function load() {
    loading.value = true;
    error.value = null;
    try {
      data.value = await fetcher();
    } catch (err) {
      error.value = err as Error;
      throw err; // 重新抛出,让 Suspense 捕获
    } finally {
      loading.value = false;
    }
  }
  
  if (options.immediate !== false) {
    // 在 async setup 中直接 await
    load();
  }
  
  return {
    data,
    error,
    loading,
    reload: load,
  };
}

// 使用:在 async setup 中
// export default {
//   async setup() {
//     const user = useResource(() => fetch('/api/user').then(r => r.json()));
//     return { user };
//   },
// };

题目 3:实现一个路由预加载插件,根据用户行为预测并预加载下一页。

// 参考答案
import type { Router } from 'vue-router';

interface PreloadOptions {
  // 鼠标 hover 链接时预加载
  hoverPreload: boolean;
  // 空闲时预加载高优先级路由
  idlePreload: boolean;
  // 高优先级路由列表
  priorityRoutes: string[];
}

export function createPreloadPlugin(router: Router, options: PreloadOptions) {
  const preloaded = new Set<string>();
  
  async function preloadRoute(name: string) {
    if (preloaded.has(name)) return;
    preloaded.add(name);
    
    const route = router.getRoutes().find(r => r.name === name);
    if (route && typeof route.components?.default === 'function') {
      try {
        await route.components.default();
        console.debug(`[preload] Route "${name}" loaded`);
      } catch (err) {
        console.warn(`[preload] Failed to load route "${name}"`, err);
        preloaded.delete(name); // 允许重试
      }
    }
  }
  
  function setupHoverPreload() {
    if (!options.hoverPreload) return;
    
    document.addEventListener('mouseover', (event) => {
      const link = (event.target as HTMLElement).closest('a[href]');
      if (!link) return;
      
      const href = link.getAttribute('href');
      if (!href) return;
      
      try {
        const resolved = router.resolve(href);
        if (resolved.name && typeof resolved.name === 'string') {
          preloadRoute(resolved.name);
        }
      } catch (err) {
        // 忽略无效路由
      }
    }, { passive: true });
  }
  
  function setupIdlePreload() {
    if (!options.idlePreload || !options.priorityRoutes.length) return;
    
    const preload = () => {
      options.priorityRoutes.forEach(name => preloadRoute(name));
    };
    
    if ('requestIdleCallback' in window) {
      requestIdleCallback(preload, { timeout: 5000 });
    } else {
      setTimeout(preload, 3000);
    }
  }
  
  return {
    install() {
      setupHoverPreload();
      setupIdlePreload();
    },
    preloadRoute,
  };
}

// 使用
// const preloadPlugin = createPreloadPlugin(router, {
//   hoverPreload: true,
//   idlePreload: true,
//   priorityRoutes: ['dashboard', 'profile'],
// });
// app.use(preloadPlugin);

10.4 思考题

题目 1:在大型应用中,如何设计一套完整的代码分割与预加载策略,平衡首屏性能、用户体验与带宽成本?

参考答案

策略设计

  1. 首屏优先:首屏路由直接 import,不懒加载,确保 LCP。
  2. 路由懒加载:非首屏路由懒加载,按需请求。
  3. 共享依赖抽离:Vue 运行时、UI 库等抽离为 vendor chunk。
  4. 预加载策略
    • Hover 预加载:鼠标 hover 链接时预加载,命中率高。
    • 空闲预加载requestIdleCallback 预加载高优先级路由。
    • 视口预加载:滚动到视口内的链接预加载。
  5. 优先级分类
    • P0:首屏、登录、首页(直接加载)。
    • P1:常用功能页(hover 预加载)。
    • P2:低频功能页(仅懒加载)。
  6. 监控与优化
    • 使用 Web Vitals 监控 LCP、FCP、TTFB。
    • 分析 chunk 加载时间,优化慢加载。
    • 定期审查 chunk 大小,合并过小的 chunk。

权衡

  • 首屏体积 vs 加载延迟:首屏直接加载,体积大但延迟低。
  • 预加载 vs 带宽:预加载提升体验,但消耗带宽,需根据用户网络情况调整。
  • chunk 数量 vs HTTP 请求数:过多 chunk 增加请求开销,过少 chunk 失去分割收益。

题目 2:分析 Suspense 在大型应用中可能引入的复杂性,并提出缓解策略。

参考答案

复杂性来源

  1. 嵌套 Suspense 的依赖追踪:深层嵌套时,依赖关系难以追踪,调试困难。
  2. 错误传播:错误向上传播路径复杂,需在合适层级捕获。
  3. 加载顺序:多个异步依赖完成顺序不确定,可能导致 UI 跳变。
  4. SSR 一致性:服务端与客户端的加载状态需保持一致,避免 hydration mismatch。
  5. 与状态管理的集成:Suspense 与 Pinia 等状态管理的协作需谨慎设计。

缓解策略

  1. 限制嵌套层级:通常 2 层足够,避免深层嵌套。
  2. 错误边界:在每层 Suspense 上添加 onErrorCaptured,局部处理错误。
  3. 加载占位设计:使用 Skeleton 保持 UI 结构稳定,避免跳变。
  4. SSR 数据预取:在路由守卫或 asyncData 中预取数据,避免 hydration 不一致。
  5. 状态管理集成:将异步数据获取与 Pinia store 结合,Suspense 仅管理组件加载。

未来演进

  • Vue 可能引入更细粒度的 Suspense 控制,如部分依赖 resolve 即渲染。
  • 工具链支持可视化 Suspense 依赖树,辅助调试。

题目 3:对比 Vue 的 async setup() 与 React 的数据获取模式(React Query、SWR),分析各自优劣。

参考答案

Vue async setup() 的优势

  1. 原生集成:与 Vue 响应式系统深度集成,无需额外库。
  2. Suspense 协调:自动与 Suspense 配合,统一管理加载态。
  3. 简洁语法await 直接获取数据,代码清晰。
  4. SSR 友好:服务端同步等待,无需额外配置。

Vue async setup() 的劣势

  1. 无缓存:每次组件挂载都重新获取,无客户端缓存。
  2. 无重试:需手动实现重试机制。
  3. 无失效策略:数据不会自动失效,需手动刷新。
  4. 无乐观更新:不支持乐观更新。

React Query 的优势

  1. 完整缓存:自动缓存查询结果,支持 stale-while-revalidate。
  2. 自动重试:内置重试机制,支持指数退避。
  3. 失效策略:支持 query invalidation,自动重新获取。
  4. 乐观更新:支持 mutation 的乐观更新与回滚。
  5. DevTools:强大的开发者工具,可视化查询状态。

React Query 的劣势

  1. 额外依赖:需引入第三方库,增加包体积。
  2. 学习成本:API 较多,学习曲线陡峭。
  3. Suspense 集成:需配置 suspense: true 选项。

综合建议

  • 简单场景:Vue async setup() 足够,无需额外库。
  • 复杂场景(缓存、重试、失效):考虑 VueUse 的 useFetchuseAsyncState 或 Pinia colada(Vue 官方的数据获取库)。
  • 大型应用:建议使用 Pinia colada 或类似 React Query 的库,获得完整的数据获取能力。

11. 参考文献 | References

本文档参考了以下学术文献、官方文档与技术专著,遵循 ACM Reference Format。

11.1 官方文档

[1] Evan You and the Vue.js Team. 2024. Vue.js 3 Official Documentation: Components in Depth - Async Components. Retrieved July 20, 2026 from https://vuejs.org/guide/components/async.html

[2] Evan You and the Vue.js Team. 2024. Vue.js 3 Official Documentation: Built-in Components - Suspense. Retrieved July 20, 2026 from https://vuejs.org/guide/built-ins/suspense.html

[3] Evan You and the Vue.js Team. 2024. Vue.js 3 API Reference: defineAsyncComponent. Retrieved July 20, 2026 from https://vuejs.org/api/general.html#defineasynccomponent

[4] Evan You and the Vue.js Team. 2024. Vue.js 3 Guide: Server-Side Rendering. Retrieved July 20, 2026 from https://vuejs.org/guide/scaling-up/ssr.html

[5] Vue Router Team. 2024. Vue Router 4 Documentation: Dynamic Route Matching. Retrieved July 20, 2026 from https://router.vuejs.org/guide/essentials/dynamic-matching.html

11.2 学术文献

[6] Addy Osmani. 2017. The Cost of JavaScript in 2017. Retrieved July 20, 2026 from https://medium.com/dev-channel/the-cost-of-javascript-in-2017-4446d428e434

[7] Addy Osmani. 2019. The Cost of JavaScript in 2019. Retrieved July 20, 2026 from https://v8.dev/blog/cost-of-javascript-2019

[8] Sebastian Markbåge. 2018. React 16.6 Release Notes: React.lazy and Suspense. Retrieved July 20, 2026 from https://react.dev/blog/2018/10/23/react-v-16-6

[9] Evan You. 2020. Vue 3.0 Released. Retrieved July 20, 2026 from https://blog.vuejs.org/posts/vue-3-one-piece

[10] Evan You. 2021. Vue 3.2 Released. Retrieved July 20, 2026 from https://blog.vuejs.org/posts/vue-3.2

11.3 相关框架文档

[11] Meta Platforms, Inc. 2024. React Documentation: Suspense. Retrieved July 20, 2026 from https://react.dev/reference/react/Suspense

[12] Meta Platforms, Inc. 2024. React Documentation: lazy. Retrieved July 20, 2026 from https://react.dev/reference/react/lazy

[13] Solid.js Team. 2024. Solid.js Documentation: Suspense and Lazy. Retrieved July 20, 2026 from https://www.solidjs.com/docs/latest#suspense

[14] Svelte Foundation. 2024. Svelte Documentation: Dynamic Components. Retrieved July 20, 2026 from https://svelte.dev/docs/svelte/svelte-component#dynamic-components

[15] Angular Team. 2024. Angular Documentation: Lazy Loading. Retrieved July 20, 2026 from https://angular.dev/guide/lazy-loading

11.4 技术专著

[16] Evan You. 2023. Vue.js 3 Design and Implementation (Vue.js 设计与实现). People’s Posts and Telecommunications Press, Beijing, China.

[17] Thiago Delgado Pinto. 2022. Vue.js 3 By Example: Build eight real-world applications from the ground up. Packt Publishing, Birmingham, UK.

[18] Alex Kyriakidis, Pablo De Garcia, and Christian Pan. 2023. The Vue Handbook: A Comprehensive Guide to Vue.js. Vue School.

11.5 论文与技术报告

[19] Evan You. 2019. Vue 3.0 RFC: Suspense. Retrieved July 20, 2026 from https://github.com/vuejs/rfcs/blob/master/active-rfcs/0000-suspense.md

[20] Lin Clark. 2017. Code Splitting with React.lazy and Suspense. Retrieved July 20, 2026 from https://web.dev/code-splitting-suspense/

[21] Vite Team. 2024. Vite Documentation: Dynamic Import. Retrieved July 20, 2026 from https://vitejs.dev/guide/features.html#dynamic-import

11.6 在线资源

[22] Vue School. 2024. Vue 3 Async Components. Retrieved July 20, 2026 from https://vueschool.io/courses/vue-3-async-components

[23] Vue Mastery. 2024. Vue 3 Suspense. Retrieved July 20, 2026 from https://www.vuemastery.com/courses/vue-3-suspense/

[24] Web.dev. 2024. Code Splitting Best Practices. Retrieved July 20, 2026 from https://web.dev/reduce-javascript-payloads-with-code-splitting/


12. 延伸阅读 | Further Reading

12.1 书籍

  1. 《Vue.js 设计与实现》——霍春阳

    • 深入剖析 Vue 3 异步组件、Suspense 的实现原理。
    • 包含源码级解析与性能分析。
  2. 《High Performance Browser Networking》——Ilya Grigorik

    • 浏览器网络性能权威指南,理解 chunk 加载的网络层。
  3. 《Web Performance in Action》——Jeremy Wagner

    • Web 性能优化实践,包含代码分割、预加载等策略。
  4. 《Vue.js 3 By Example》——Thiago Delgado Pinto

    • 通过实战项目讲解 Vue 3,包含异步组件的应用。

12.2 论文与 RFC

  1. Vue 3 Suspense RFChttps://github.com/vuejs/rfcs

    • Vue 官方的 RFC,包含 Suspense 的设计讨论。
  2. Vue 3 Source Codehttps://github.com/vuejs/core

    • Vue 3 源码,重点关注 packages/runtime-core/src/components/Suspense.tspackages/runtime-core/src/apiAsyncComponent.ts
  3. React Suspense RFChttps://github.com/reactjs/rfcs

    • React Suspense 的设计讨论,对比 Vue 与 React 的实现差异。

12.3 在线资源

  1. Vue Schoolhttps://vueschool.io/

    • Vue 官方推荐的在线学习平台,包含异步组件的视频教程。
  2. Vue Masteryhttps://www.vuemastery.com/

    • Vue 进阶学习平台,深入讲解 Suspense 与 SSR。
  3. VueUsehttps://vueuse.org/

    • Vue Composition API 工具库,包含 useAsyncStateuseFetch 等异步工具。
  4. Vite 文档https://vitejs.dev/

    • Vite 官方文档,详细说明动态导入与代码分割。
  5. Web.devhttps://web.dev/

    • Google 的 Web 性能指南,包含代码分割最佳实践。

12.4 开源项目参考

  1. Nuxt 3https://github.com/nuxt/nuxt

    • Vue 3 元框架,自动化路由懒加载与 Suspense 集成。
  2. VitePresshttps://github.com/vuejs/vitepress

    • Vue 驱动的静态站点生成器,大量使用异步组件。
  3. Element Plushttps://github.com/element-plus/element-plus

    • Vue 3 组件库,按需加载实现参考。
  4. Vue Router 4https://github.com/vuejs/router

    • 官方路由库,懒加载与预加载策略。
  5. Pinia Coladahttps://github.com/posva/pinia-colada

    • Vue 官方的数据获取库,类似 React Query。

12.5 社区与讨论

  1. Vue Discordhttps://discord.com/invite/vue

    • Vue 官方 Discord,讨论异步组件与 Suspense 实践。
  2. Vue Forumhttps://forum.vuejs.org/

    • Vue 官方论坛,搜索 Suspense 标签查找历史讨论。
  3. Vue RFC Discussionshttps://github.com/vuejs/rfcs/discussions

    • Vue RFC 讨论,参与 Suspense 的未来演进。
  4. Reddit r/vuejshttps://www.reddit.com/r/vuejs/

    • Vue 社区,分享异步加载的使用经验。
  5. Stack Overflowhttps://stackoverflow.com/questions/tagged/vue.js

    • 技术问答,搜索 async-component、suspense 相关问题。

附录 A:异步组件 API 速查

A.1 defineAsyncComponent

// 简单形式
function defineAsyncComponent(
  loader: () => Promise<Component>
): Component

// 完整配置
function defineAsyncComponent(options: {
  loader: () => Promise<Component>;
  loadingComponent?: Component;
  errorComponent?: Component;
  delay?: number;
  timeout?: number;
  suspensible?: boolean;
  onError?: (error: Error, retry: () => void, fail: () => void, attempts: number) => void;
}): Component

A.2 Suspense 组件

<Suspense
  @pending="onPending"
  @resolve="onResolve"
  @fallback="onFallback"
>
  <template #default>
    <!-- 异步内容 -->
  </template>
  <template #fallback>
    <!-- 加载占位 -->
  </template>
</Suspense>

A.3 事件

  • @pending:进入 pending 状态时触发。
  • @resolve:所有依赖 resolve 时触发。
  • @fallback:进入 fallback 状态时触发。

附录 B:常见错误信息

B.1 async setup() received a promise but is not wrapped in Suspense

原因async setup() 在 Suspense 外使用。

解决:将组件包裹在 <Suspense> 内。

B.2 Async component timed out after XXXms

原因:异步组件加载超过 timeout 阈值。

解决:增加 timeout,或检查网络/服务器问题。

B.3 Maximum call stack size exceeded

原因:异步组件循环依赖,无限重试。

解决:限制 onError 中的 retry 次数,或检查循环依赖。

B.4 Hydration node mismatch

原因:SSR 与客户端渲染的加载状态不一致。

解决:确保服务端与客户端使用相同的初始数据,使用 onServerPrefetch 预取数据。


附录 C:版本兼容性

Vue 版本异步组件特性
2.3首次引入异步组件(工厂函数)
2.5支持 import() 语法
3.0完整重构 defineAsyncComponent,引入 Suspense
3.2Suspense 稳定,支持嵌套
3.3SSR 流式渲染优化
3.4性能优化,内部实现改进
3.5稳定性提升,无 API 变化

升级建议

  • Vue 2 项目升级:异步组件 API 变化较大,需重构。
  • Vue 3 项目:建议使用 defineAsyncComponent 完整配置,配合 Suspense。
  • SSR 项目:Vue 3.3+ 的流式渲染显著提升性能,推荐升级。

结语

异步组件与 Suspense 是 Vue 3 应对大型应用体积膨胀的核心机制,通过代码分割与声明式加载状态管理,显著提升首屏性能与用户体验。本章节从历史动机、形式化定义、原理推导、代码示例、对比分析、最佳实践、工程实践、案例研究、习题等维度,系统化阐述了异步组件与 Suspense 的设计哲学与工程应用。

核心要点回顾

  1. 代码分割import() 动态导入实现 chunk 分割,按需加载减少首屏体积。
  2. defineAsyncComponent:内置 loading/error/timeout/重试配置,完整管理异步组件生命周期。
  3. Suspense 协调:等待多个异步依赖完成,统一管理加载占位。
  4. async setup():组件级数据获取与 Suspense 集成,声明式异步流程。
  5. 嵌套 Suspense:局部依赖独立管理,提升灵活性。
  6. SSR 流式渲染:服务端流式输出,提升 TTFB。
  7. 最佳实践:合理使用 delay 避免闪烁、错误边界捕获异常、预加载关键路由、chunk 分析优化。

掌握异步组件与 Suspense 的原理与最佳实践,是构建大型 Vue 应用的关键能力。在实际项目中,应根据场景灵活选择路由懒加载、组件懒加载、Suspense 协调等策略,平衡性能、用户体验与开发成本。

返回入门指南