
1. 先弄明白报错在说什么别上来就改代码Loading chunk {n} failed这个报错几乎所有做前端工程化的人都见过。它通常长这样控制台里一行红字Uncaught (in promise) ChunkLoadError: Loading chunk 5 failed.或者Loading chunk vendors~xxx failed紧接着页面白屏、路由点不动、弹窗按钮点下去没反应。用户截图发过来你问什么操作触发的对方说我也不知道就点了一下。1.1 一句话拆解报错链路这个错误的本质非常简单浏览器去下载一个 JS 文件没下载成功。而这个 JS 文件不是入口文件入口文件在 HTML 里写死了script src加载失败是另一种白屏它是运行时按需下载的异步 chunk。流程大概是这样用户点了订单详情代码里import(./OrderDetail)执行打包器提前把OrderDetail拆成了一个独立文件OrderDetail.a3f9c1.js。运行时拿着这个文件名通过document.createElement(script)或者fetch去请求它。请求成功执行页面正常请求失败——注意失败的定义是拿不到一个 HTTP 200 且内容正确的响应——运行时就抛出ChunkLoadError外层的promise变成 rejected如果没人 catch就变成你看到的那行红字。关键在于这个错误本身没有告诉你为什么失败。404、500、超时、被运营商劫持返回一段 HTML、被 Service Worker 缓存了一个旧文件、被内嵌 WebView 拦截了跨域请求全都长成同一个样子。这就是为什么很多人第一反应去改代码改了半天没用——问题根本不在代码里。1.2{n}不是变量名是 chunk id 占位{n}这个写法容易误导人它不是某个具体的变量而是打包器给 chunk 分配的id 占位符。webpack 5 默认用数字 id5、12也可以配置成deterministic生成短哈希Vite/Rollup 走的是 ESM 原生动态 import产出的文件名带内容哈希比如OrderDetail-Dx7Kq2Lm.jsRspack、esbuild 又各有各的命名策略。所以你会看到各种变体Loading chunk 5 failed—— webpack 数字 idLoading chunk vendors~main failed—— 命名 chunkFailed to fetch dynamically imported module—— 原生 ESM 报错Vite 常见Importing a module script failed—— Safari 下的表述error loading dynamically imported module—— Firefox 下的表述这些是同一个病不同浏览器/不同打包器的不同说法。排查时不要被字面差异带偏。你可以在搜索问题时把这几个关键词一起搜能捞到更多案例。1.3 哪些项目最容易中招我梳理了一下自己经手过的项目命中率最高的几类项目类型高发原因典型表现频繁发版的中后台老页面持有旧 HTML引用的 chunk 已被新版本覆盖删除用户挂着页面不动隔天点按钮就炸移动端 H5弱网、切后台、系统回收内存导致请求中断地铁里点一下就白屏内嵌 WebView 的 App本地缓存策略激进、跨域限制、离线包版本不一只在 App 里复现浏览器正常多 CDN / 多云部署各节点文件版本不同步按地域、按时间段随机复现用了 Service Worker 的 PWA缓存了旧 index.html又清了旧 chunk升级后第一次打开必炸注意如果你的报错是每次刷新都必现、所有用户都必现那大概率不是版本错位而是publicPath 配错了或者文件根本没被部署上去。这两种情况的排查路径完全不同别混着查。2. chunk 是怎么被生成、被找到、被加载的想解决问题得先知道这个文件从哪儿来、运行时怎么找它。这段原理看起来枯燥但它是后面所有解决方案的地基。2.1 动态 import 在打包器眼里是什么你写的import(./pages/OrderDetail)在打包器眼里是一条拆包指令。它会把OrderDetail整个依赖子树抽出来形成一个独立 chunk同时把原位置的代码替换成类似这样的运行时调用// 打包后的产物示意 __webpack_require__.e(5).then(__webpack_require__.bind(__webpack_require__, 128))__webpack_require__.e就是那个加载器它的工作只有三件事拼出文件 URL、插一个script标签、等它 load 或 error。加载成功后chunk 里的模块注册到模块表里then里拿到的就是真正的模块。这里有个很容易被忽略的点URL 是运行时拼的不是构建时写死的。拼接规则是publicPath chunk 文件名 后缀。这就意味着只要publicPath在运行时算错了所有异步 chunk 全都会 404。2.2 runtime 加载 chunk 的完整链路拆细一点一次成功的 chunk 加载要过五关拿到文件名从构建时生成的 chunk 映射表里查。这份映射表在 runtime 里硬编码随 entry 一起加载。拼 URLpublicPath filename。webpack 5 允许用__webpack_public_path__运行时动态改这也是做多环境部署的常用手段。发起请求老版本 webpack 用 JSONPscript标签新版本和 Vite 用fetch/ ESM。JSONP 方式对跨域反而更宽容因为它不受 CORS 限制。超时判定webpack 有chunkLoadTimeout默认 120 秒。超过就判定失败。这个默认值在弱网下偏长很多人调短到 30 秒以内让用户尽早看到重试提示。执行模块请求成功但内容不是 JS比如 CDN 返回了一个 200 的 HTML 错误页会在执行阶段抛语法错误表现和加载失败类似但栈不一样。第 5 点特别值得说。CDN 或网关返回 200 但内容是!DOCTYPE html...的情况非常常见尤其是配置了 SPA fallback所有未匹配路径返回 index.html的场景。浏览器拿到一段 HTML 当 JS 执行报错是Uncaught SyntaxError: Unexpected token 。如果你看到这个错误别再纠结网络问题了去查网关的 fallback 规则。2.3 publicPath 一错全盘皆错publicPath是这一切的枢纽。它的作用就是告诉运行时我们的静态资源放在哪个地址前缀下。常见配法// webpack.config.js module.exports { output: { // 相对路径跟随当前 HTML 所在目录适合不确定部署路径的场景 publicPath: auto, // webpack 5 推荐自动推导 // 或写死 CDN 地址 // publicPath: https://static.example.com/app/, filename: js/[name].[contenthash:8].js, chunkFilename: js/[name].[contenthash:8].chunk.js, }, };publicPath: auto是 webpack 5 的新特性它会从当前脚本的src反推目录。听起来很省心但有个坑如果入口脚本本身是内联的、或者被script动态注入了推导可能出错。我遇到过一次某个接入了离线包的 App入口 JS 被注入到了head且带了>// utils/lazyRetry.js const RETRY_KEY chunk_retry_count; const MAX_RETRY 2; export function lazyRetry(factory, chunkName) { return new Promise((resolve, reject) { // 用 sessionStorage 记录重试次数避免死循环 const retried Number(sessionStorage.getItem(RETRY_KEY) || 0); factory() .then((mod) { sessionStorage.removeItem(RETRY_KEY); resolve(mod); }) .catch((err) { // 只处理 chunk 加载类错误业务错误直接抛出 const isChunkError /Loading chunk|Loading CSS chunk|dynamically imported module|Importing a module script/i .test(err err.message || ); if (!isChunkError || retried MAX_RETRY) { reject(err); return; } sessionStorage.setItem(RETRY_KEY, String(retried 1)); // 加时间戳绕过浏览器/CDN 缓存模拟重新加载 const url new URL(window.location.href); url.searchParams.set(_t, Date.now()); window.location.replace(url.toString()); }); }); }配合 React 的用法// router/index.jsx import React, { lazy } from react; import { lazyRetry } from /utils/lazyRetry; const OrderDetail lazy(() lazyRetry(() import(/pages/OrderDetail), OrderDetail));Vue Router 的用法// router/index.js import { lazyRetry } from /utils/lazyRetry; const routes [ { path: /order/:id, component: () lazyRetry(() import(/views/OrderDetail.vue), OrderDetail), }, ];几个必须注意的点都是我踩过的重试次数不能省。没有上限的话一旦服务器真的挂了用户会陷入刷新→失败→刷新的死循环体验比白屏还差。sessionStorage而不是localStorage。用localStorage的话用户关了浏览器再打开计数还在容易被误判。用sessionStorage关标签页就重置。刷新前一定要清计数。加载成功的分支里要removeItem否则下次遇到真问题时没有重试额度。只用location.replace别用location.href。前者不会往历史记录里塞一条用户点返回不会回到崩溃页。别在 catch 里无脑刷新。要先判断错误类型。业务代码里throw new Error(库存不足)也会进 catch这时候刷新页面就是灾难。4.2 路由层兜底刷新与版本号校验除了在加载器上做文章还可以在路由层做统一兜底这样不用改每一个懒加载点。思路是在全局错误监听里捕获ChunkLoadError然后做一次带节流的刷新// utils/chunkErrorHandler.js let lastReloadAt 0; const RELOAD_COOLDOWN 10000; // 10 秒内只允许刷新一次 function tryReload(reason) { const now Date.now(); if (now - lastReloadAt RELOAD_COOLDOWN) return; lastReloadAt now; const count Number(sessionStorage.getItem(global_chunk_reload) || 0); if (count 2) { // 连续失败不再刷新展示友好提示 showFatalTip(页面资源加载失败请检查网络后手动刷新); return; } sessionStorage.setItem(global_chunk_reload, String(count 1)); const url new URL(location.href); url.searchParams.set(_r, String(now)); location.replace(url.toString()); } window.addEventListener(error, (e) { const msg e?.message || ; if (/Loading chunk|dynamically imported module/i.test(msg)) { tryReload(window.error); } }, true); window.addEventListener(unhandledrejection, (e) { const msg (e?.reason (e.reason.message || String(e.reason))) || ; if (/Loading chunk|dynamically imported module/i.test(msg)) { tryReload(unhandledrejection); } }); function showFatalTip(text) { // 这里换成你自己的 UI 组件 const el document.createElement(div); el.textContent text; el.style.cssText position:fixed;inset:0;display:flex;align-items:center; justify-content:center;background:#fff;z-index:99999;font-size:15px;color:#333; document.body.appendChild(el); }为什么要加冷却时间因为unhandledrejection和error事件在某些浏览器里会同时触发同一个错误不加节流就会连刷两次。而连刷两次的后果是用户刚刷新完页面还没渲染又被刷了一次白屏时间翻倍。为什么连续失败两次后不再刷新因为这时候大概率不是版本错位而是服务真的不可用了。继续刷新只是浪费用户流量还会让监控里的报错量翻倍。这时候展示一个明确的提示反而更专业。还有一个进阶做法版本号校验。在 HTML 里注入一个构建版本号前端定时比如每 5 分钟拉取最新的版本号接口发现不一致就提示用户有新版本点击刷新。这种方式体验最好因为是在用户操作之前就发现问题而不是等点击之后才崩。// utils/versionCheck.js const CURRENT_VERSION __BUILD_VERSION__; // 构建时注入 export function startVersionPolling(interval 5 * 60 * 1000) { setInterval(async () { try { const res await fetch(/version.json?_t${Date.now()}, { cache: no-store }); const { version } await res.json(); if (version version ! CURRENT_VERSION) { showUpdateTip(); // 展示发现新版本的轻提示 return true; } } catch (e) { // 静默失败版本检测不能影响主流程 } return false; }, interval); }4.3 构建配置加固分包、publicPath、跨域前端能在构建层面做的事情其实不少很多问题在构建阶段就能规避掉。第一稳定的 chunk 命名。webpack 5 默认的 chunk id 是基于模块路径的确定性哈希但如果你从 webpack 4 升级上来可能还带着旧配置。建议显式声明// webpack.config.js module.exports { output: { filename: js/[name].[contenthash:8].js, chunkFilename: js/[name].[contenthash:8].chunk.js, publicPath: auto, clean: true, // 构建前清理但注意别清掉要保留的旧版本 crossOriginLoading: anonymous, // fetch 方式加载需要 }, optimization: { moduleIds: deterministic, // 关键模块 id 不随构建顺序变化 chunkIds: deterministic, // 关键chunk id 稳定 runtimeChunk: single, // 把 runtime 单独抽出来便于长缓存 splitChunks: { chunks: all, maxInitialRequests: 6, cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, priority: -10, reuseExistingChunk: true, }, }, }, }, };moduleIds和chunkIds设成deterministic是我最想强调的一条。默认配置下你新增一个模块可能导致一堆 chunk id 变化进而导致所有文件名变化、所有缓存失效。设成确定性之后只有真正改动的 chunk 才会换名字。第二maxInitialRequests别调太大。我见过有人为了极致拆包把它设成 30结果是首屏要发 30 个请求。HTTP/2 下虽然有复用但在弱网高延迟场景下30 个 RTT 叠加起来体验极差。经验值是 6 到 10 之间。第三Vite 项目的对应配置。Vite 用 Rollup 打包配置项不一样// vite.config.js export default defineConfig({ base: ./, // 相对路径部署到任意子目录都能跑 build: { rollupOptions: { output: { chunkFileNames: assets/[name]-[hash].js, entryFileNames: assets/[name]-[hash].js, assetFileNames: assets/[name]-[hash][extname], manualChunks(id) { if (id.includes(node_modules)) { // 把体量大的库单独拆出来 if (id.includes(echarts)) return vendor-echarts; if (id.includes(lodash)) return vendor-lodash; return vendor; } }, }, }, chunkSizeWarningLimit: 800, }, });Vite 项目里报错文案是Failed to fetch dynamically imported module因为走的是原生 ESM。这时候要注意原生 ESM 的加载受 CORS 限制比 JSONP 严格得多。如果你的静态资源在独立域名下必须正确配置 CORS 响应头否则会出现浏览器里能打开这个 JS 地址但页面里就是加载失败的诡异现象。4.4 服务端与 CDN 侧的配合动作前端做得再稳也需要服务端配合。我总结了几条必须落地的约定动作目的落地方式旧版本资源保留 3 天以上给用户刷新留窗口构建产物按版本号分目录不覆盖HTML 不缓存或强校验保证用户拿到最新入口Cache-Control: no-cache ETag带 hash 的静态资源长缓存提升二次访问速度Cache-Control: max-age31536000, immutable静态资源路径 404 不返回 HTML避免语法错误掩盖真实问题网关规则按路径前缀区分跨域头正确支持 fetch 方式加载Access-Control-Allow-Origin按需配置构建产物按版本号分目录这条我认为是最有价值的。具体做法是每次发布把产物上传到/app/2024-06-12-a1b2c3/这样的目录下HTML 里引用对应版本的绝对路径。这样旧版本的 HTML 引用的还是旧版本的资源永远不会 404用户不刷新也不会崩。等旧版本流量趋近于零再清理。代价是存储空间但静态资源本来就便宜多留三天成本可以忽略不计。比起用户白屏的损失这笔账太好算了。另外一个细节Cache-Control: no-cache和no-store不是一回事。no-cache是可以用缓存但每次必须向服务器校验配合 ETag 能省带宽又能保证新鲜度no-store是完全不缓存每次全量下载 HTML。HTML 文件通常很小两者差别不大但no-cache更优雅。4.5 监控闭环怎么知道真的解决了改完代码不算完得能证明问题消失了。这需要在监控侧做几件事错误分类上报。别把所有 JS 错误混在一起。给ChunkLoadError单独打标签附带chunk 名、页面 URL、UA、网络类型navigator.connection.effectiveType、构建版本号、是否刷新过。建立基线。上线前统计一周的日均报错量上线后对比。如果从每天 200 次降到 20 次说明有效如果没变化说明根因判断错了。区分用户影响面和报错量。有些 chunk 加载失败发生在用户已经离开页面的瞬间对体验没影响。真正要关注的是失败后走没走到兜底逻辑、用户有没有看到可用页面。// 上报示例 function reportChunkError(info) { const payload { type: chunk_load_error, chunk: info.chunkName, url: location.href, version: __BUILD_VERSION__, ua: navigator.userAgent, network: navigator.connection?.effectiveType || unknown, retried: Number(sessionStorage.getItem(chunk_retry_count) || 0), ts: Date.now(), }; // 用 navigator.sendBeacon 保证页面跳转时也能发出 if (navigator.sendBeacon) { navigator.sendBeacon(/api/monitor/error, JSON.stringify(payload)); } else { fetch(/api/monitor/error, { method: POST, body: JSON.stringify(payload), keepalive: true }); } }sendBeacon这个 API 值得单独提一句它在页面 unload 的时候依然能把请求发出去而普通fetch会被中断。因为我们的兜底逻辑是先上报再刷新如果用普通 fetch请求很可能还没发出去页面就跳走了监控里什么都看不到。5. 常见问题速查表与踩坑实录5.1 速查表报错原文大概率原因第一步该做什么Loading chunk 5 failed版本错位 / 文件不存在看 Network 里该请求的状态码Loading chunk vendors~main failed公共包被清理检查发布流程是否删了旧产物Failed to fetch dynamically imported moduleCORS 或 ESM 加载失败检查资源的 CORS 响应头Uncaught SyntaxError: Unexpected token 静态资源被 fallback 成 HTML检查网关的 SPA fallback 规则ChunkLoadError: Loading chunk ... failed (timeout)弱网超时调短chunkLoadTimeout加重试Importing a module script failedSafari同上浏览器表述差异别按字面搜索用通用关键词只有 App 里报错WebView 缓存/内核问题抓 UA 和内核版本刷新后必好版本错位上前端兜底 保留旧产物5.2 几个我实际踩过的坑坑一把location.reload()写在catch里结果死循环。有一次服务端配置错了静态资源目录整个 404前端catch里无脑reload()用户看到的是页面疯狂闪烁。后来加了sessionStorage计数和冷却时间才解决。任何自动刷新逻辑都必须有次数上限这是我现在的铁律。坑二preload的link标签帮了倒忙。为了优化首屏我在 HTML 里对几个高频异步 chunk 加了link relpreload asscript。结果发版后用户手里的旧 HTML 依然在 preload 旧文件名的 chunk控制台一堆 404 警告。结论是不要对带内容哈希的异步 chunk 做 preload或者只在 Server 端渲染时动态生成。坑三Service Worker 缓存把新旧版本混在一起。PWA 项目里SW 缓存了旧index.html但缓存清理策略又把旧 chunk 删了导致HTML 是旧的、chunk 也没了。修复方法是给 SW 加版本号activate阶段清理所有非当前版本的缓存。核心代码是// sw.js const CACHE_VERSION v20240612; self.addEventListener(activate, (event) { event.waitUntil( caches.keys().then((keys) Promise.all( keys.filter((k) k ! CACHE_VERSION).map((k) caches.delete(k)) ) ).then(() self.clients.claim()) ); });坑四本地测试用http-server起静态服务Content-Type不对。有些简易静态服务器对.chunk.js这种双后缀文件识别不了 MIME 类型返回的Content-Type是text/plain浏览器拒绝执行。这种情况 Chrome 会报Refused to execute script但如果你只看错误消息的开头会误以为是加载失败。排查时一定要看完整报错别只看前几个词。坑五把publicPath设成绝对地址但忘了配 CORS。静态资源挪到独立域名之后JSONP 方式加载没问题但切到fetch方式或者 Vite 的 ESM就全挂了。原因是跨域请求需要响应头支持。改配置的时候一定要注意加载方式的差异。5.3 关于这个问题的一些个人看法做前端这些年Loading chunk {n} failed是我觉得最冤枉的一类问题。它明明是个部署和缓存策略问题却总被当成代码 bug 来查白白浪费很多时间。我现在的习惯是新项目立项时就把这几件事写进部署清单——产物按版本分目录保留三天、网关按路径前缀区分静态资源和页面路由、前端全局捕获 chunk 错误并带上版本号上报、懒加载统一走重试包装器。这四件事加起来大概半天工作量能挡掉后面 90% 的同类问题。另外我越来越倾向于把资源加载当成一个独立的可观测维度来对待而不是塞在通用 JS 错误里。因为它的根因分布完全不同——它更多时候是环境问题、部署问题而不是代码逻辑问题。给它单独建一个监控面板看报错量随发版时间的曲线比看堆栈有用得多。最后分享一个小技巧如果你的项目发版频繁可以在 CI 里加一步版本错位检测——把上一次的构建产物清单和这一次做 diff如果发现有文件被删除但同时还有 HTML 引用它就报警。这一步能提前发现旧产物被误删这类问题比等用户报障要主动得多。