Skip to content

10|懒加载与错误处理:chunk 失败不是普通 404 ​

第六章概览 · 目标:理解动态 import 的加载时机、失败原因和恢复责任;让网络断开、旧部署缓存和真正的应用错误走不同路径。

路由懒加载改变了什么 ​

静态导入会在首包中带上组件;路由懒加载把组件变成一个只在首次访问时执行的动态 import。访问路径本身先匹配成功,随后才等待 chunk;所以“路由匹配成功”和“页面组件可执行”是两个阶段。

ts
const routes = [
  { path: '/', component: () => import('./views/HomeView.vue') },
  { path: '/reports', component: () => import('./views/ReportsView.vue') },
  { path: '/chunk-error', component: () => import('./views/ErrorDemo.vue') }
]

成功后,模块通常由浏览器和 bundler 缓存;失败可能来自离线、超时、CDN 404 或部署后旧 HTML 指向已经删除的 hash。不要把所有失败都改成“资源不存在”,用户需要知道是否应该重试、刷新还是稍后再试。

只允许一次安全重试 ​

全局 router.onError 能观察到异步组件加载失败。可以给一次重新加载机会,但必须避免无限刷新:记录本次构建版本或 session 标记,第二次失败就进入稳定错误页。

ts
// 在客户端入口调用;把存储、页面跳转传入,便于模拟两次应用启动。
export function createChunkRecovery(runtime: {
  storage: Pick<Storage, 'getItem' | 'setItem'>
  buildId: string
  online: () => boolean
  href: (fullPath: string) => string
  navigate: (href: string) => void
  show: (kind: 'offline' | 'stale-deployment', path: string) => void
}) {
  return function recover(fullPath: string) {
    if (!runtime.online()) {
      runtime.show('offline', fullPath)
      return 'offline'
    }
    const key = `route-reloaded:${runtime.buildId}:${fullPath}`
    try {
      if (runtime.storage.getItem(key)) {
        runtime.show('stale-deployment', fullPath)
        return 'error'
      }
      runtime.storage.setItem(key, '1') // 必须在重载前保存
    } catch {
      runtime.show('stale-deployment', fullPath)
      return 'error' // 不能证明标记已保存时,不自动刷新
    }
    runtime.navigate(runtime.href(fullPath))
    return 'reloading'
  }
}

const recover = createChunkRecovery({
  storage: window.sessionStorage,
  buildId: 'demo-build-1', // 生产构建注入真实版本
  online: () => navigator.onLine,
  href: path => router.resolve(path).href, // 保留 /admin/ 等 history base
  navigate: href => window.location.assign(href),
  show: (kind, path) => showRouteLoadError({ kind, path }),
})
router.onError((error, to) => {
  if (/chunk|import|fetch/i.test(error.message)) recover(to.fullPath)
})

showRouteLoadError 是应用壳提供的普通错误提示入口,应显示原路径和手动重试按钮;它必须随首包加载,不能再依赖失败的路由 chunk。存储对象的获取本身也可能被浏览器策略拒绝,客户端入口应捕获此异常并直接显示错误提示。

第一次失败保存标记并重载;第二次启动会重新创建闭包,但同一标签页的 sessionStorage 仍保留标记,因此直接显示错误。内存布尔值无法跨页面加载存活,不能承担这个合同。新构建使用新 key,才获得自己的重载机会。

这是体验层的恢复策略,不是对所有动态 import 错误的可靠分类器。生产代码应结合错误码、构建版本、网络状态和监控事件;不要只凭一段错误字符串决定是否刷新。

页面重载和函数重试是两个实验 ​

下面练习的 loader 重试在同一次 JavaScript 运行中完成,它能验证“最多两次尝试”,却不能证明刷新后标记仍存在。前面的恢复器还需要另一个实验:两个 createChunkRecovery 实例共享同一 fake storage,第一次返回 reloading,第二次返回 error,navigate 总计一次。两种实验分别验证运行内计数和跨启动记忆。

与组件异步加载的边界 ​

路由组件的动态 import 负责“页面 chunk 什么时候到”;defineAsyncComponent 还可以在已渲染页面内表达 loading、error、超时和重试。二者都需要失败 UI,但重试按钮必须重新创建一个可追踪的加载尝试,而不是把同一个 rejected Promise 永远返回出去。

ts
const ReportChart = defineAsyncComponent({
  loader: () => import('./ReportChart.vue'),
  loadingComponent: ChartSkeleton,
  errorComponent: ChartError,
  timeout: 8_000,
  onError(error, retry, fail, attempts) {
    if (attempts <= 1 && navigator.onLine) retry()
    else fail()
  }
})

页面级 chunk 失败通常需要保留原始 URL 并给出刷新/离线提示;局部组件失败则应保留页面其余内容。把两者都交给全局白屏会扩大故障半径。

练习:实现一条可恢复加载路径 ​

写一个 loadRouteWithOneRetry(loader, state, online) 测试夹具,不要求真实网络。state 初始为 { phase: 'idle', error: null },online() 返回布尔值;状态 phase 只允许 idle/loading/retrying/ready/error:

  • 第一次 loader reject、第二次 resolve 时,状态依次为 loading → retrying → ready,loader 恰好调用两次。
  • 两次都 reject 时进入 error,不得继续调用 loader。
  • 首次调用 online() 为 false 时直接 error/offline,loader 调用零次;首次失败后掉线则不发第二次,错误类型仍为 offline。
  • 再把它接入路由错误提示,保留 to.fullPath,并写一条可观察日志。

答案见第六章练习答案。

本篇验收 ​

  • 能说出匹配、下载、执行和渲染是四个不同阶段。
  • 能区分一次重试、无限刷新和稳定错误页的后果。
  • 在离线、CDN 旧 chunk 和局部组件失败三种变化下,用户仍有下一步动作。

用心记录每一步成长