前端开发··1 阅读·预计 8 分钟

Vite 动态导入失败的工程化治理:从 chunk 加载错误到优雅降级的重试防线

为什么动态导入会失败

Vite 生产构建会把异步路由/组件拆成独立 chunk,浏览器在运行时按需加载。但网络抖动、CDN 刷新导致的旧 hash 失效、跨域或部署回滚,都会让 import() 返回的 Promise 变成 rejected。最常见的场景是:用户停留在一个已打开的页面上,后台发版后 chunk 文件名(hash)变化,用户点击路由跳转时浏览器去请求一个已不存在的 JS 文件,直接 404。

// 反例:裸 import,没有任何兜底
const LazyPage = () => import('./pages/HeavyPage.vue')

一旦这段代码失败,Error: Loading chunk xxx failed 会直接冒泡到全局,轻则白屏,重则整个 SPA 崩溃。

反例:吞掉错误比不处理更糟

一些人会用最粗暴的方式兜底:

const load = async () => {
  try {
    return await import('./pages/HeavyPage.vue')
  } catch (e) {
    console.error(e)
    // 静默失败,组件 return null?
    return { default: () => null }
  }
}

问题在于:静默降级把故障藏起来了。线上 chunk 全挂了,你只会看到空白区块,既没有监控告警,用户也得不到任何提示。失败被成功掩盖,是最隐蔽的工程事故。

正例:可重试的加载器

正确的做法是把「加载」抽象成一个带重试策略的函数,失败时给出真正的兜底 UI 并上报:

// retryLoad.ts
interface RetryOptions {
  retries?: number
  delayMs?: number
  onError?: (attempt: number, err: unknown) => void
}

export function retryImport<T>(
  loader: () => Promise<T>,
  { retries = 2, delayMs = 1000, onError }: RetryOptions = {},
): Promise<T> {
  return new Promise((resolve, reject) => {
    const attempt = (n: number) => {
      loader()
        .then(resolve)
        .catch((err) => {
          onError?.(n, err)
          if (n >= retries) return reject(err)
          // 指数退避:1000ms、2000ms
          setTimeout(() => attempt(n + 1), delayMs * (n + 1))
        })
    }
    attempt(0)
  })
}
// 结合 Vue 的错误边界(onErrorCaptured)与 React 的 ErrorBoundary
const HeavyPage = () =>
  retryImport(() => import('./pages/HeavyPage.vue'), {
    retries: 2,
    onError: (n, err) => {
      // 上报监控:第 n 次加载失败
      reportChunkError({ path: 'HeavyPage', attempt: n, err })
    },
  })

关键区别在于:重试解决瞬时网络抖动,而上报解决无法自愈的硬故障。两者缺一不可。

兜底 UI:让失败可见但不崩溃

重试仍失败时,应该渲染一个可交互的降级界面,而不是白屏:

// React 侧:ErrorBoundary 捕获 chunk 错误
class ChunkBoundary extends React.Component {
  state = { failed: false }
  static getDerivedStateFromError() {
    return { failed: true }
  }
  componentDidCatch(error) {
    reportChunkError({ err: error })
  }
  render() {
    if (this.state.failed) {
      return (
        <div className="chunk-fallback">
          <p>内容加载失败,请重试</p>
          <button onClick={() => location.reload()}>刷新页面</button>
        </div>
      )
    }
    return this.props.children
  }
}

注意:location.reload() 会重新拉取最新的入口 HTML,从而拿到新的 chunk 映射,是解决「旧 hash 失效」的最直接手段。

进阶:从源头降低失败概率

  1. 关闭不必要的过度分包build.rollupOptions.output.manualChunks 如果拆得过于激进,会产生大量小 chunk,放大请求失败面。
  2. 配合 HTTP 缓存策略:给 index.html 设置 no-cache,给带 hash 的 chunk 设置长缓存,避免用户拿到新旧混合的产物。
  3. preload 而不是只靠运行时:通过 modulepreload 提前拉取关键 chunk,减少用户点击时的等待窗口。
// vite.config.ts:收敛分包,避免碎 chunk
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['vue', 'vue-router', 'pinia'],
        },
      },
    },
  },
})

小结

动态导入不是「写了就不管」的语法糖,它是一条会失败的运行时链路。工程化的核心是三点:可重试(应对抖动)、可上报(暴露故障)、可降级(不白屏)。把加载失败当成一等公民来治理,远比事后排查一个白屏现场的成本低得多。

0 评论

评论区

登录 后参与评论