Skip to content

异步组件与懒加载降级重构

这次整理的对象主要来自下面这些本地素材:

  • 11Vue学习/vue2相关文档学习笔记/源码学习/03组件化/7异步组件.md
  • 11Vue学习/Nuxt4/Components/ClientOnly.md
  • 11Vue学习/Nuxt4/核心概念/4Rendering-modes.md
  • 11Vue学习/组件化思维/错误边界与客户端降级渲染重构.md

和前面的案例一样,这一轮不直接修改任何独立 demo,而是把其中最值得复用的“异步组件、占位内容、加载失败和懒加载边界”思路整理成一篇案例文档。

这类场景最容易被低估的地方,不是“组件能不能异步 import”,而是组件在真正加载出来之前,页面还能不能保持稳定:

  • 首屏阶段是否有明确占位内容
  • 组件加载中时用户会看到什么
  • 组件加载失败后有没有降级路径
  • 客户端专属大组件是否应该继续压进主包
  • 异步加载和业务错误是否被混在一起

如果这些边界不先理清,项目后面就很容易出现几个典型问题:

  • 想优化首屏,于是到处把组件改成异步,但没有统一占位策略
  • loading、error、timeout 逻辑散在各个页面里
  • 大组件虽然拆包了,但首屏体验反而更碎
  • 异步加载失败后没有恢复路径,只剩控制台报错

这类场景真正的复杂度中心

异步组件与懒加载场景里,最容易被忽略的复杂度中心,不是代码分割本身,而是“加载过程如何被设计”。

从当前素材里可以看到几个关键事实:

  • Vue 异步组件本质上不是一次渲染完成,而是“占位 -> 加载 -> 成功或失败”的过程
  • 高级异步组件天然就有 loadingerrortimeout 这几类状态
  • Nuxt 的 ClientOnly 和渲染模式又会进一步影响组件到底在什么时候加载

如果这些线没有统一结构,项目很快会出现:

  • 页面自己判断何时显示 loading
  • 组件自己处理超时和失败
  • 路由级懒加载、组件级懒加载、客户端专属加载彼此打架
  • 用户面对的只是“页面慢”或“模块空白”,没有可解释状态

所以这类重构的重点,不是先多写几个 import(),而是先把“占位、加载、失败、恢复”收口成一套稳定链路。

推荐的重构边界

更适合长期维护的结构,通常会把这类场景拆成下面几层:

  • 加载策略层:负责哪些组件值得异步化
  • 渲染边界层:负责 SSR、CSR 和客户端专属加载边界
  • 占位与失败层:负责 loading、error、timeout、fallback
  • 页面装配层:负责把异步组件和正常页面内容组合起来
  • 业务组件层:只负责业务本身,不负责系统级加载策略

如果换成更具体的职责,大概可以这样理解:

  • useAsyncFeaturePolicy():负责某个功能块是否值得延迟加载
  • AsyncFeatureBoundary:负责 loading / error / timeout / retry
  • ClientRenderBoundary:负责浏览器专属模块的客户端挂载
  • DashboardPage / ArticlePage:只负责装配异步模块与主体内容
  • ChartPanel / RichEditor / Viewer3D:只负责业务展示与交互

这里最重要的一条原则是:

  • 不是所有组件都值得异步化
  • 不是所有异步加载都应该裸露给页面自己处理
  • 懒加载的目标不是“晚点出现”,而是“更稳地出现”

异步组件不是“晚点 import”这么简单

当前源码材料已经把这件事讲得很清楚:异步组件的本质不是简单延迟加载,而是一个带状态机的渲染过程。

最关键的几类状态通常是:

  • loading
  • resolved
  • error
  • timeout

更稳的做法,是让页面消费层不要直接面对这些底层细节,而是统一通过边界组件来消费。

ts
export interface AsyncFeatureOptions {
  delay?: number
  timeout?: number
  retryable?: boolean
}

export interface AsyncFeatureState {
  status: 'idle' | 'loading' | 'resolved' | 'error' | 'timeout'
  errorMessage?: string
}

一旦这层结构稳定下来,后面讨论一个异步模块时,就不再只是问“怎么 import”,而是问:

  • 它的加载状态如何对外暴露
  • 它失败后怎么恢复
  • 它的 fallback 是什么形态

loading、error、timeout 要变成统一协议,而不是页面各自写一套

Vue 源码里的高级异步组件之所以值得借鉴,不是因为它 API 旧,而是因为它很早就把这几个状态明确区分了:

  • 什么时候显示 loading
  • 什么时候显示 error
  • 超时后是否切换到失败态
  • 加载成功后如何替换占位内容

这套思路在现代 Vue / Nuxt 项目里仍然非常有价值。

vue
<template>
  <Suspense>
    <component :is="component" />

    <template #fallback>
      <SkeletonPanel :title="title" />
    </template>
  </Suspense>
</template>

如果再结合错误边界与重试逻辑,页面层只需要关心:

  • 这个模块是什么
  • 它的占位内容是什么
  • 它失败后给用户什么恢复路径

而不需要把异步状态细节散在每个页面组件里。

懒加载要先判断“值不值得拆”,不是能拆就拆

很多项目一到性能优化阶段,就喜欢把能拆的都拆掉。但不是所有组件都值得进入异步加载链路。

更稳的判断方式通常是:

适合异步化的

  • 首屏不是关键内容的增强模块
  • 体积较大、依赖重的编辑器、图表、地图、3D 组件
  • 只在特定交互后才会出现的功能块
  • 浏览器专属、天然适合客户端延迟挂载的模块

不适合异步化的

  • 首屏关键信息本体
  • 页面主体结构骨架
  • 过于细小、拆包收益很低的通用组件
  • 会导致首屏语义断裂的基础模块

如果这个判断不先做,异步组件很容易从性能优化手段,变成体验碎片化来源。

ClientOnly 和异步组件要配合使用,但职责不同

这也是这类场景里最容易混淆的一点。

  • ClientOnly 解决的是“这个组件不能进入 SSR 路径”
  • 异步组件解决的是“这个组件可以晚点加载,减少首屏主包压力”

这两者可以组合,但不能互相替代。

vue
<template>
  <ClientOnly fallback-tag="div" fallback="图表模块准备中...">
    <AsyncFeatureBoundary :component="AsyncChartPanel" title="图表加载中" />
  </ClientOnly>
</template>

在这套结构里:

  • 服务端阶段不渲染真实图表
  • 客户端阶段图表以异步模块方式加载
  • 加载中和失败态由边界层统一处理

这样页面主体内容和高风险增强模块就能稳定分层。

懒加载失败要有恢复路径,不要只剩 error 组件

很多项目对异步组件失败态的处理,停留在“有个 error 组件就行”。但真正可维护的做法,应该把恢复动作也设计进去。

更稳的方式是让失败态至少具备这三件事:

  • 告诉用户发生了什么
  • 告诉用户当前还能做什么
  • 给出显式重试或替代入口
vue
<template>
  <RetryPanel
    title="模块加载失败"
    message="当前增强模块暂时不可用,你可以稍后重试。"
    @retry="$emit('retry')"
  />
</template>

这样异步模块失败就不再只是一个终点,而是一条可恢复链路的一部分。

路由级懒加载、页面级异步块、局部客户端增强要区分层次

真实项目里,最常见的问题不是不会懒加载,而是三种懒加载混在一起:

  • 路由级:页面组件本身按路由拆包
  • 页面级:页面内部某个功能块延后加载
  • 客户端级:只能在浏览器环境运行的大组件再延后挂载

如果这三层不分开,很快就会出现:

  • 页面本体被拆得太碎
  • 页面主体内容也被推迟出现
  • 客户端模块和普通异步模块使用同一套兜底,语义不清

更稳的方式是:

  • 路由级懒加载优先解决“页面入口拆包”
  • 页面级异步块优先解决“首屏非关键功能延后加载”
  • 客户端级增强优先解决“浏览器专属模块隔离”

只有这样,懒加载链路才会越用越清晰,而不是越用越像临时补丁。

更适合现代 Vue / Nuxt 的组织方式

如果把这类旧式“直接写异步工厂函数”的结构迁到现代项目,更推荐的目录边界通常是:

  • components/async/AsyncFeatureBoundary.vue
  • components/fallback/SkeletonPanel.vue
  • components/fallback/AsyncErrorPanel.vue
  • components/boundary/ClientRenderBoundary.vue
  • composables/async/useAsyncFeaturePolicy.ts
  • pages/**/*.vue

在这套结构里:

  • 加载策略层只负责值不值得拆
  • 边界组件只负责 loading / error / timeout / retry
  • fallback 组件只负责替代内容
  • 页面组件只负责把异步功能和主体内容组合起来

这和前面几个案例的方法完全一致:

  • 先找到复杂度中心
  • 先稳定边界与协议
  • 再拆页面与能力层
  • 最后才优化加载时机与体验细节

这类项目最值得先检查的 6 个问题

以后再遇到类似“图表、编辑器、地图、富客户端增强模块很多”的页面,可以先检查这几个问题:

  1. 哪些模块真的值得异步化
  2. loading、error、timeout 是否已经变成统一协议
  3. 客户端专属组件是否已经和普通异步模块分层
  4. fallback 是否在设计阶段就已经存在
  5. 失败后用户是否有显式恢复路径
  6. 首屏主体内容是否仍然保持完整可用

如果这 6 个问题答不清楚,就说明当前项目的异步加载链路还没有真正收口。

这篇案例最后沉淀出的核心方法

这轮最重要的不是把更多组件改成 import(),而是沉淀出一条可复用的异步加载重构思路:

  • 先判断组件值不值得异步化
  • 再把 loading / error / timeout 收口成统一边界
  • 再把客户端专属模块和普通异步模块分层
  • 最后让页面只负责组合这些边界与业务模块

这样以后面对图表工作台、富文本后台、地图库页面、重交互内容页这些场景时,异步加载和懒加载降级都能落到同一套稳定框架里,而不会每个页面各自写一套临时 loading 与报错逻辑。

共 20 个模块,1301 篇 Markdown 文档。