☰
ChunkLoadError自动恢复:前端chunk失败生产方案
2026/10/1 1:33:59 网站建设 项目流程

凌晨两点,手机上的监控告警群连着弹出十几条同样文案的消息:Loading chunk 18 failed。打开大盘一看,受影响用户占比 3.7%,机型集中在 Android 微信内置浏览器,时间点卡在我们上一版发布后的第 40 分钟。这种场景,只要做过两年前端的人大概率都遇到过——Loading chunk {n} failed这行字几乎是所有用了动态导入、路由懒加载、按需分包项目的老熟人。它不难理解,但难在它每一次出现的原因都不太一样,而国内绝大多数团队的处理方式还停留在"让用户清缓存刷新一下",这在生产环境里基本等于放弃治疗。

这篇内容我想干三件事:把这行报错从浏览器到构建产物的整条链路拆开讲清楚;给出一套我实际在项目里跑了一年多、能上生产的自动恢复方案;最后把那些"名字也叫 chunk failed 但其实跟前端半毛钱关系没有"的报错区分开——包括最近热词里那条数据侧的transmit chunk rpc failed,很多人搜同一个关键词被带到完全错误的方向上。适合正在被这个问题折磨的前端、也适合做发布流程和 CDN 配置的运维同学对着看。

1. 先把这条报错定位清楚:它到底是谁抛出来的

1.1 浏览器控制台里那句红字其实来自 webpack runtime

很多人第一反应是"浏览器报的错",其实不是。浏览器原生只会告诉你"某个 URL 请求失败了",而Loading chunk 18 failed这句话,是构建产物里的 runtime 代码在script标签onerror之后自己拼出来并抛出的。

webpack 4 时代,__webpack_require__.e负责加载异步 chunk,它的实现大致是创建一个<script>标签,src指向__webpack_require__.p + jsonpScriptSrc(chunkId),然后在onerror里 reject 一个new Error("Loading chunk " + chunkId + " failed.")。所以这句话的完整含义是:我去请求 chunk 18 对应的那个 JS 文件,请求失败了——注意,是"请求失败",不是"文件不存在"。404、超时、DNS 失败、被拦截、服务器返回了 HTML,全都会走到这个分支。

webpack 5 做了一点改进,错误对象上多了几个有用的字段:error.name = 'ChunkLoadError',error.type记录了失败类型(timeout、error、missing等),error.request是真实请求的 URL。这几个字段在我看来是排查效率的分水岭,因为error.request能直接告诉你当时请求的是哪个带 hash 的文件名。有了它,你只需要把这个 URL 拿去比对一下当前线上发布的 manifest,几秒钟就能判断出"是版本错位"还是"网络抖动"。

提示:如果你在 Sentry、自建监控里看到的 chunk 错误没有request字段,多半是因为监控 SDK 只采集了message,没采集自定义属性。改造采集逻辑比在业务代码里到处打日志划算得多。

1.2 报错文案的几种变体与它们各自的上游

同一个根因,会因为构建工具和插件不同,长出七八种不同的文案。我整理了一张对照表,建议把它贴在团队的排查文档里,省得每次都要重新查:

报错文案出自真实含义
Loading chunk 18 failed.webpack 4 / 5JS chunk 的 script 请求失败
Loading chunk 18 failed. (missing: 20 21)webpack 5不只 18,依赖链上的 20、21 也没加载上
Loading CSS chunk 5 failed.mini-css-extract-pluginCSS chunk 请求失败,通常是 link 标签加载失败
ChunkLoadErrorwebpack 5 的 error.name上面那几种的统一标识,抓监控时用这个最省事
Failed to fetch dynamically imported moduleVite / Rollup动态 import 的模块请求失败,语义等价
Unable to preload CSS for …Vite预加载 CSS 失败,常和上面的错一起出现
MIME type ('text/html') is not a supported stylesheet浏览器请求 JS/CSS 却拿到了 index.html,多半是路径回退

最后一条特别值得说。它看起来是 MIME 类型问题,实际上九成是因为服务器把所有找不到的路径都 rewrite 到了index.html(SPA 的常规配置),于是浏览器请求一个已经不存在的/static/js/18.a1b2c3.js,服务器很客气地返回了首页 HTML,状态码还是 200。这种情况下onerror反而不会触发,但浏览器会因为 MIME 不匹配拒绝执行,最终表现依然是页面白屏加一堆 chunk 报错。排查时如果看到 200 状态码但页面还是挂了,先去看响应体是不是 HTML。

1.3 一个心法:所有 chunk 失败只有两种底色

排查到最后,你会发现根因都能归到两个方向里:

  • 资源无法被找到:发布删了旧文件、CDN 缓存错位、publicPath 配错、CI 产物不一致。
  • 资源无法被取回:弱网超时、离线、被扩展拦截、跨域配置缺失、服务端限流。

这两类的处理策略完全不同。第一类重试毫无意义,因为那个 URL 在服务端已经不存在了,你重试一百次也是 404,正确的做法是让页面重新加载、拿到新的资源清单。第二类重试是有效的,网络抖动一秒后可能就通了。我在项目里最终落地的方案就是先做这个二分判断,再决定走哪条恢复路径,误判率比"一律刷新"低很多,用户体感也好得多。

2. 报错文案背后的四种真实场景

2.1 发版即失联:旧页面追着新清单跑

这是占比最高的一种,我估过我们项目的线上数据,能占到七成以上。典型的触发链路是这样:用户下午三点打开页面,浏览器把index.html和当时的app.aaa111.js、18.bbb222.js一起下载并缓存;下午四点我们发了一版,18这个 chunk 的内容变了,hash 变成ccc333,旧的bbb222被构建产物覆盖删掉;用户还在那个标签页里刷着,五点他点了一个路由跳转,runtime 拿着内存里记着的bbb222去请求,服务器上早就没有了,404,报错。

这里有个容易被忽略的细节:用户不需要"停留在旧页面很久"才会中招。只要他在你发布的那一瞬间正好开着页面,哪怕只开了三分钟,也一样会踩到。尤其是持续挂着后台的 B 端系统、客服工作台、监控大屏,这些页面一开就是一整天,每次发版都会有一批人报错。

更隐蔽的是"半个身子换过去了"的情况。假设一个页面同时用了三个 chunk,用户请求时前两个还在、第三个已经被新版本替换,那么他会看到一个部分渲染、部分空白的中间态,而不是干净的白屏。这种半死不活的状态最难排查,因为错误堆栈看起来毫无规律。

注意:判断是不是这个原因,最快的办法是拿用户上报的error.request里的文件名,去比对当前线上index.html里的资源引用。对不上,就是版本错位,直接结案。

2.2 灰度与 CDN 的缓存错位

如果说第一种是"新老版本互相打架",这一种就是"同一版本的资源在 CDN 上打架"。常见于三种操作:

一是灰度发布按机器分批。十台机器先发两台,用户的请求经过负载均衡,第一次拿到新版本的index.html,第二次落到老机器上取 chunk,文件名对不上,报错。这种问题的特点是"发版期间错误率上升,发完自动消失",很容易被当成偶发噪音忽略掉。

二是CDN 缓存刷新不及时。你更新了index.html,但边缘节点还缓存着旧的(因为没配no-cache),用户拿到旧 HTML,里面的 chunk 名字当然是旧的。这个和第一种现象一样,但根因在缓存策略,改前端代码是治不好的。

三是跨区域回源不一致。这个在小团队少见,但做多地域部署的一定遇到过:华东节点已经刷新了,华南节点还在回源老文件,用户漫游或者切网络的时候就会撞上。

我在实际项目里判断这类问题有个土办法:看错误上报里的客户端 IP 归属地分布。如果错误集中在某一个省份或者某一个运营商,基本可以锁定是那一片 CDN 节点的缓存问题,直接去刷那个区域就行。

2.3 弱网、代理拦截与浏览器扩展

这一类跟版本没关系,纯粹是请求没走通。移动端地铁里、电梯里、酒店 Wi-Fi 认证页面前,用户点了一个大 chunk 的路由,请求超时,webpack 默认chunkLoadTimeout是 120 秒,实际上大多数用户等不到 120 秒就自己关掉了,但错误还是上报了。

还有两种更微妙的情况。一种是浏览器扩展拦截,某些广告拦截、隐私保护插件会把 URL 里带特定关键词的请求直接掐掉,表现为net::ERR_BLOCKED_BY_CLIENT。另一种是企业内网的出口代理,会因为资源域名不在白名单里而返回一个自定义的错误页。这两种的共同点是"只影响特定人群",错误率很低但一直不断,很容易被误判成偶发。

判断方法很简单:看同一个用户是不是反复出现。如果某个用户的设备 ID 在一天内报了五六次且分散在不同 chunk 上,那基本就是他的网络环境或浏览器环境有问题,而不是你的代码有问题。

2.4 构建侧自己出问题

这一种最少见,但一旦出现就非常折磨人,因为它会表现为"所有人都挂"或者"某个特定版本必挂"。

典型的有:CI 上开了持久化缓存但缓存的是旧 manifest,导致产出的index.html引用的 chunk 名字和实际生成的对不上;publicPath配置成了相对路径,页面在二级路由下访问时 chunk 请求跑到了错误的目录;多入口项目里两个入口共享的 chunk 被错误地切分,运行时找不到依赖。还有一种是构建过程中断,产出了不完整的dist目录,某些 chunk 文件根本没生成。

这类问题的特征是本地复现得了。你本地跑一遍npm run build再用静态服务器打开,如果在无痕模式下也能稳定复现,那就不用怀疑线上环境了,老老实实去查构建配置。

3. 兜底方案:一套能上生产的自动恢复策略

说完原因,进入正题。用户不会因为"这是发版导致的"就原谅白屏,所以我们必须在代码里做兜底。下面这套方案我在两个 C 端项目和一个 B 端系统上都跑过,错误恢复率能到 90% 以上。

3.1 监听写在哪、抓什么

入口文件最顶部,越早越好,最好在框架加载之前就注册好。两个事件都要监听:

const CHUNK_ERROR_RE = /Loading chunk [\w-]+ failed|Loading CSS chunk|ChunkLoadError|Failed to fetch dynamically imported module/; function extractReason(e) { const reason = e && (e.reason || e.error || e); if (!reason) return null; const msg = reason.message || String(reason); if (!CHUNK_ERROR_RE.test(msg)) return null; return { message: msg, request: reason.request || '', // webpack 5 才有 type: reason.type || '', name: reason.name || '', }; } window.addEventListener('unhandledrejection', (e) => { const info = extractReason(e); if (info) handleChunkError(info); }); window.addEventListener('error', (e) => { // 资源加载失败不会冒泡到 window,必须用捕获 const info = extractReason(e); if (info) handleChunkError(info); }, true);

这里有两个坑必须点出来。第一,window.addEventListener('error')第三个参数一定要传true,否则捕获不到资源加载错误,因为资源的 error 事件不冒泡。第二,动态import()的失败是以 Promise reject 的形式抛出的,走的是unhandledrejection,所以两个事件一个都不能少。我最早只写了unhandledrejection,结果漏掉了 CSS chunk 和部分 preload 错误,监控上看错误量只有实际的六成。

3.2 为什么优先 reload 而不是原地重试

很多同学的直觉是"失败了就再试两次",代码写起来也确实优雅。但如果你在第 2 节里跟着看下来了,应该已经能想明白:对于占比七成的版本错位类失败,重试是无效的,那个 URL 在服务端已经不存在了。重试三次,用户多等三秒,最后还是白屏。

所以我的策略是分两步走:

async function handleChunkError(info) { // 第一步:记录,但先不刷 report(info); // 第二步:区分类型 if (info.type === 'timeout' || isNetworkLike(info)) { // 网络类,原地重试一次,间隔 800ms if (await retryOnce()) return; } // 其他情况,或者重试也失败,走整页刷新 safeReload(); }

retryOnce的实现不用太花哨,核心就是拿到失败的模块重新 import 一次。这里有个技巧:不要试图去 hook webpack 的__webpack_require__.e,不同版本内部实现差异很大,升级一次 webpack 就可能失效。更稳的做法是在业务层的懒加载封装里做重试,这点在第 5 节会展开。

至于刷新,location.reload()就够了。有人喜欢用location.href = location.href,效果类似但多了一次历史记录,某些浏览器上会影响返回键行为,我不太推荐。

3.3 防刷新死循环的三道闸

location.reload()一旦不加限制,就是灾难现场:服务器真的挂了,用户打开页面 → 报错 → 刷新 → 又报错 → 又刷新,页面像抽搐一样不停闪,用户手机发烫电量狂掉。我见过最夸张的一个案例是用户手机被刷到没电。

我现在固定用三道闸:

const RELOAD_KEY = '__chunk_reload_at__'; const RELOAD_COUNT_KEY = '__chunk_reload_count__'; function safeReload() { const now = Date.now(); const last = Number(sessionStorage.getItem(RELOAD_KEY) || 0); const count = Number(sessionStorage.getItem(RELOAD_COUNT_KEY) || 0); // 闸一:时间窗,10 秒内只允许刷一次 if (now - last < 10000) return; // 闸二:次数上限,一个会话最多刷三次 if (count >= 3) { showFriendlyError('页面资源加载失败,请稍后重试'); return; } sessionStorage.setItem(RELOAD_KEY, String(now)); sessionStorage.setItem(RELOAD_COUNT_KEY, String(count + 1)); location.reload(); }

闸三是在刷新前加一个版本校验:请求一个带时间戳的/version.json,如果发现服务端版本和内存里记录的版本一致,说明不是版本问题,那就别刷了,直接提示用户。这一道能进一步降低无效刷新的比例。用sessionStorage而不是localStorage的原因也很简单——用户关掉标签页重新打开,就应该是一次全新的机会,会话级存储正好符合这个语义。

提示:刷新的时候可以用location.reload(),但如果你的项目跑在微信内置浏览器或者某些 App 的 WebView 里,偶尔会遇到 reload 不生效的情况,这时可以退化成location.replace(location.href)。我在两个项目里都加了这层兜底。

3.4 上报要做的取舍

监控上报不是越多越好。我们踩过的坑是:一开始把每一笔 chunk 错误都上报,结果发版那半小时的错误量把整个大盘的告警阈值冲爆了,值班同学直接关掉了告警,反而错过了真正的问题。

后来改成三条规则:同一用户同一 chunk 五分钟内只报一次(用设备 ID + chunk ID 做去重键);自动恢复成功的降低采样率(比如只报 10%),因为这类属于可自愈问题,不需要人工介入;自动恢复失败的 100% 上报,这类才是真的影响用户。另外一定要把error.request、页面版本号、navigator.connection.effectiveType一起带上,排查效率完全不是一个量级。

4. 事前预防:把失败窗口压到最小

兜底是止血,真要少出问题还得靠事前。这块其实比写代码更重要,而且很多成本是一次性的。

4.1 index.html 与 hash 资源的缓存策略

核心原则只有一条:入口 HTML 和带 hash 的静态资源,要用两套完全相反的缓存策略。

# 入口 HTML:绝对不能缓存 location = /index.html { add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Pragma "no-cache"; etag off; if_modified_since off; } # 带 hash 的静态资源:缓存一年都可以 location ~* ^/static/.*\.[0-9a-f]{8,}\.(js|css|woff2|png|svg)$ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; access_log off; }

这套配置的道理很直白:index.html是"资源清单",它里面的文件名带 hash,内容一变名字就变,所以每次都必须拿最新的;而 chunk 文件名本身已经带了内容指纹,内容不变名字不变,缓存再久也不会出错。反过来配的话,把index.html也缓存一年,那用户一年都拿不到新版本,每次发版都是一场灾难。

我在项目上还加了一条保险:index.html的响应头里带上X-App-Version,前端启动时读一次存起来。等到真的出 chunk 错误,就能对比"当前运行的版本"和"服务端最新版本",判断是不是需要刷新。

4.2 多版本共存的发布方式

这一条是被最多团队忽略、但性价比最高的措施:发布时不要删旧文件,保留最近 N 个版本的静态资源。

具体做法是在构建产物目录上按版本号分目录,比如/static/2024.11.20-1/、/static/2024.11.20-2/,index.html里的publicPath指向当前版本目录。发布新版本时,旧目录不动。这样即使用户停留在旧页面,他的 chunk 请求打到旧目录,还能正常拿到文件,页面继续正常工作,只是他自己不知道有新版本而已。

保留多少个版本?我的经验是保留最近 5 到 10 个,视发布频率而定。日更的项目保留一周的量就够了,周更的可以保留一个月。磁盘成本相比用户投诉成本,完全不值一提。

清理旧版本有两种方式:一种是发布脚本里顺手删除超过阈值的目录;另一种是给每个版本目录加一个expire-at的元数据文件,由定时任务扫描清理。我倾向第二种,逻辑更收敛,也不容易在发布失败时误删。

4.3 构建与网络参数调优

几个我实际调过的参数,都很小但有用:

// webpack.config.js module.exports = { output: { chunkLoadTimeout: 30000, // 默认 120s,移动端建议缩到 30s crossOriginLoading: 'anonymous', // CDN 跨域时加 crossorigin 属性 }, };

chunkLoadTimeout从 120 秒缩到 30 秒的理由是:移动端用户根本不会等两分钟,与其让他对着白屏发呆,不如早点失败早点进恢复流程。crossOriginLoading则是在资源走独立 CDN 域名时必配的,否则错误信息会被浏览器屏蔽成一句"Script error.",什么都查不到。

另外,prefetch的用法也要注意。给路由级 chunk 加上webpackPrefetch: true确实能让空闲时预取,降低点击时的失败率,但预取失败的错误也会走到全局监听里,如果你不加过滤,就会看到一大堆用户还没点过的页面在报 chunk 错误。我的处理是在上报时判断error.request对应的 chunk 是否在当前路由的依赖链上,不在的就降级成低优先级日志。

4.4 Service Worker 场景下的版本切换

如果项目上了 PWA,情况会更复杂一层,因为 SW 自己有一份缓存。经典的现象是:代码已经发布了,用户刷新了好几次都还是老页面,最后报了一个 chunk 错误。

核心是两件事。一是让新 SW 尽快接管:

self.addEventListener('install', (e) => { e.waitUntil(self.skipWaiting()); }); self.addEventListener('activate', (e) => { e.waitUntil( (async () => { await caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) ); await self.clients.claim(); })() ); });

二是千万别把index.html放进 SW 的预缓存列表后就不管了。我见过最坑的一次配置是 SW 缓存了入口 HTML,然后index.html一年不更新,用户永远停在老版本上。正确做法是把 HTML 设为NetworkFirst策略,JS/CSS 走CacheFirst(因为带 hash,命中了就是对的)。用 Workbox 的话,一行cleanupOutdatedCaches()能省掉很多手动清理的代码。

5. 框架层落地:Vue、React、微前端怎么写

全局监听是兜底,框架层能做的事情更多,因为你知道"这个 chunk 是哪个路由要用的"。

5.1 Vue Router 懒加载的重试封装

Vue 里最常见的就是component: () => import('./views/Home.vue')。把它包一层:

function lazyLoad(importFn, retries = 2) { return () => new Promise((resolve, reject) => { const attempt = () => { importFn() .then(resolve) .catch((err) => { if (retries-- > 0) { setTimeout(attempt, 600); } else { reject(err); } }); }; attempt(); }); } const routes = [ { path: '/dashboard', component: lazyLoad(() => import('./views/Dashboard.vue')), }, ];

关键点是重试次数别给多,两次足够。两次之后还失败,基本可以断定不是网络问题,交给全局兜底去刷新页面。另外重试间隔别用固定值,业务上我一般用 600ms 和 1200ms 的指数退避,避免服务端刚好在抖动时被连续打。

还有一个细节值得说:在router.onError里补一道钩子,因为路由懒加载失败有时不会走到全局unhandledrejection,而是被 router 内部消化掉:

router.onError((err) => { if (/Loading chunk|ChunkLoadError/.test(err.message)) { handleChunkError(err); } });

5.2 React.lazy 加 ErrorBoundary 的组合

React 这边的思路一样,但要注意React.lazy的失败会往上冒泡到最近的 Suspense 边界,需要 ErrorBoundary 接住。

const lazyRetry = (importFn, name) => React.lazy(() => { const key = `retry-${name}`; return new Promise((resolve, reject) => { const attempt = (n) => { importFn() .then(resolve) .catch((err) => { if (n > 0) { setTimeout(() => attempt(n - 1), 600); } else { reject(err); } }); }; attempt(2); }); });

ErrorBoundary 里除了展示降级 UI,还要做一件事:判断错误是不是 chunk 类,是的话触发恢复流程。我一般会在componentDidCatch里调用同一个handleChunkError,保证全局策略统一。这样不管错误是从哪里冒出来的,最终的恢复行为都是一致的,维护成本低。

顺带说一句,如果你的页面有比较重的首屏依赖,可以考虑把关键路由的 chunk 改成preload而不是懒加载。代价是首屏体积变大,收益是少一次网络往返。这个取舍要看业务,B 端系统我更倾向保守一点,直接把主路径的 chunk 打进主包。

5.3 微前端与内嵌场景的特殊处理

微前端是 chunk 问题的重灾区,因为主应用和子应用各有各的构建产物,版本还可能独立发布。最常见的故障是:主应用发了新版,子应用没发,主应用加载子应用入口时拿到了旧的资源清单,一堆 404。

我的处理原则是三条。第一,子应用入口 HTML 必须加时间戳参数,避免被浏览器或 CDN 缓存,形如/child/index.html?v=20241120143000。第二,加载子应用失败要有降级页面,而不是让主应用的整个路由崩掉,qiankun 的loadMicroApp支持传timeout,配合 catch 就能做到。第三,主应用和子应用的资源目录要按各自的版本号隔离,不要混在一个/static/下,否则清理旧版本的时候很容易误删。

至于 iframe 内嵌的场景,问题会更直接:iframe 里加载的页面是别人的域名,你控制不了缓存策略。这种情况我建议在 URL 上加一个版本参数,并且在内嵌页里也部署同一套 chunk 错误监听,通过postMessage把错误抛给父页面处理。

6. 同名不同病:也叫 chunk failed 的后端报错

写到这里必须插一节,因为最近搜"chunk failed"的人里面,有很大一部分找的根本不是前端问题。

6.1 数据侧那条 transmit chunk rpc failed

最近热词里有一条starrocks transmit chunk rpc failed,还有stream disconnected before completion: failed to process mtmd chunk。这两条跟前面讲的完全是两个世界的东西。

StarRocks 里的 chunk 指的是执行引擎在节点之间传输数据块的单位。一条查询在多个 BE 节点上并行执行,中间结果需要以 chunk 为单位在节点之间通过 RPC 传递。出现transmit chunk rpc failed,通常意味着某次 RPC 传输没完成,常见诱因是节点间的网络抖动、目标节点负载过高导致响应超时、查询并发太高把传输队列打满,或者单个 chunk 太大造成内存压力。

后面那条stream disconnected before completion则是流式传输中途断开,多出现在处理多模态数据这类大块内容的场景,本质上是"数据还没传完,通道先关了"。它可能是上游主动取消(比如查询被 kill)、下游异常退出,也可能是超时保护触发。

这两类的排查方向和前端完全不同:要看的是集群节点的网络质量、负载均衡、并发配置、超时参数,而不是浏览器缓存和 chunk hash。

6.2 三句话判断你遇到的是哪一类

经常有人在群里贴一句"chunk failed 怎么解决",然后被引导到完全不相关的文档上。我给你三句话的判断方法:

看报错位置。在浏览器控制台里,是前端;在服务端日志、数据库日志、集群监控里,是后端。

看有没有文件名。前端报错一定带一个xxx.hash.js之类的资源路径,因为error.request就是那个文件;后端报错带的是节点 ID、查询 ID、任务 ID。

看能不能刷新恢复。前端 chunk 失败刷新一下大概率好了;后端数据传输失败,刷新页面是没有任何用的,得去查集群。

把这三条记住,基本就不会走错路。

7. 排查路径与我踩过的坑

7.1 从用户反馈到复现的六步走

我把这几年处理这类问题的流程固化成了六步,团队里的新人照着走基本不会跑偏:

  1. 拿到原始报错。一定要原始的,不要用户转述的"打不开"。让用户截图控制台,或者直接从监控里捞error.request。
  2. 比对版本。把error.request里的文件名和当前线上index.html引用的文件名对一遍,对不上就是版本错位。
  3. 确认缓存策略。用curl -I看一下index.html的Cache-Control,是不是no-cache或者no-store。
  4. 看分布。错误是按时间集中(发版窗口)还是按地域集中(CDN 节点)还是按用户集中(个人网络问题)。
  5. 本地复现。用无痕模式 + 禁用缓存(DevTools 里勾上 Disable cache)跑一遍,看能不能稳定复现。
  6. 确认清理策略。检查发布脚本是不是把旧版本目录删了,保留份数够不够。

这六步走下来,绝大多数问题在第二步就能定位。

7.2 几个我印象深刻的坑

第一个坑是只监听了一个事件。前面提过,我早期只监听unhandledrejection,结果 CSS chunk 加载失败完全捕获不到,监控上的错误量只有真实值的六成,导致我们对问题严重性判断偏低,拖了两个月才认真处理。

第二个坑是刷新逻辑用了localStorage。上线后收到用户投诉说"页面每隔一会儿就自己刷一下"。查了半天发现是某个用户的环境本身有问题,localStorage的计数永不过期,他一旦触发过三次,之后每次打开页面都直接进降级提示。改成sessionStorage之后问题消失。

第三个坑是重试和刷新同时触发。路由层在重试,全局监听也在刷新,两者打架,用户看到页面闪一下又回去了,体验极差。后来加了一个全局的"恢复锁",同一时间只允许一个恢复动作执行,另一个直接跳过。这个锁用一个模块级变量加时间戳就能实现,代码不到十行,但解决了一个非常难复现的问题。

第四个坑比较有意思,是CDN 的 404 页面被当成了资源。某家 CDN 在资源不存在时会返回一个自定义的错误页,状态码是 200,内容类型是text/html。结果我们的重试逻辑一直判定"请求成功但解析失败",反复重试,直到超时。后来改成检查response.headers.get('content-type'),只要不是 JS 类型就直接判定失败。这个改动之后,版本错位的检测速度快了一个数量级。

第五个坑是忽略了chunkLoadTimeout的副作用。把超时从 120 秒缩到 30 秒之后,有一批在网络条件比较差的地区(比如偏远地区的移动网络)的用户,本来等 40 秒能加载出来的 chunk,现在 30 秒就失败了,进而在恢复流程里刷新页面,可刷新之后网络还是差,形成了一次失败循环。后来折中改成 60 秒,并给恢复流程加了"网络类型判断"——如果是effectiveType为2g或slow-2g的用户,就不刷新了,直接给一个带重试按钮的提示页。

最后再分享一个小技巧:如果你的项目用了大量按需加载的第三方库,可以在构建产物里加一份chunk 依赖清单,把"哪个路由依赖哪些 chunk"导出成一个 JSON 文件随包发布。出问题的时候,拿到error.request就能立刻反查出是哪个页面、哪条业务线受影响。这份清单我一般用 webpack 的stats输出配合一个几十行的脚本生成,成本很低,但对定位问题帮助极大。

这套东西加起来大概几百行代码,集中在三个文件里,一次投入之后,我们线上因为 chunk 加载失败导致的用户投诉基本清零了。剩下来的都是网络环境本身的问题,那部分我们处理不了,也不该由前端来兜。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询