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 失效」的最直接手段。
进阶:从源头降低失败概率
- 关闭不必要的过度分包:
build.rollupOptions.output.manualChunks如果拆得过于激进,会产生大量小 chunk,放大请求失败面。 - 配合 HTTP 缓存策略:给
index.html设置no-cache,给带 hash 的 chunk 设置长缓存,避免用户拿到新旧混合的产物。 - preload 而不是只靠运行时:通过
modulepreload提前拉取关键 chunk,减少用户点击时的等待窗口。
// vite.config.ts:收敛分包,避免碎 chunk
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router', 'pinia'],
},
},
},
},
})
小结
动态导入不是「写了就不管」的语法糖,它是一条会失败的运行时链路。工程化的核心是三点:可重试(应对抖动)、可上报(暴露故障)、可降级(不白屏)。把加载失败当成一等公民来治理,远比事后排查一个白屏现场的成本低得多。
0 评论
评论区
登录 后参与评论