背景
从入职到现在,报警群里每个月总会有几个Loading chunk xxx failed的告警,尝试过联系CDN组的同学帮忙排查,但也查不出问题,最终还是不了了之,后续用引进了资源retry的机制,但在React使用ErrorBoundary组件的场景下,一旦触发组件渲染,此时UI界面已经发生变化,retry的机制又显的无关紧要。难道就没有什么办法很好的解决这个问题吗?答案是有的。
什么是Loading chunk failed
当你在代码里使用了按需加载或者说懒加载文件时,例如React里面的lazy方法,Vue项目路由中的() => import(),Webpack会根据optimization.splitChunks配置项对按需加载的文件进行单独打包成一个chunk,Vite类似,当用户访问站点按需加载的模块时,当其中js文件或者css文件加载失败时,webpack动态加载的code就会抛出一个错误:Loading chunk failed。
webpack动态加载核心实现
流程
创建加载脚本
创建Script标签加载远程脚本到浏览器,并执行
(() => {
var inProgress = {};
var dataWebpackPrefix = "normal:";
__webpack_require__.l = (url, done, key, chunkId) => {
var script, needAttach;
if (!script) {
needAttach = true;
script = document.createElement("script");
script.charset = "utf-8";
script.timeout = 120;
script.setAttribute("data-webpack", dataWebpackPrefix + key);
script.src = url;
}
inProgress[url] = [done];
var onScriptComplete = (prev, event) => {
// avoid mem leaks in IE.
script.onerror = script.onload = null;
clearTimeout(timeout);
var doneFns = inProgress[url];
delete inProgress[url];
script.parentNode && script.parentNode.removeChild(script);
doneFns && doneFns.forEach((fn) => fn(event));
if (prev) return prev(event);
};
script.onerror = onScriptComplete.bind(null, script.onerror);
script.onload = onScriptComplete.bind(null, script.onload);
needAttach && document.head.appendChild(script);
};
})();
安装模块
1、按需加载在编译后实际上就是一个对象,key是文件名,value是函数,函数里面的上下文正好是对应文件的模块,所以浏览器加载远程模块后,会执行webpackChunknormal.push方法,将对象通过JSONP回调的方式注入__webpack_require__上。
(self["webpackChunknormal"] = self["webpackChunknormal"] || []).push([["src_add_js"],{
"./src/add.js": () => {
// TODO something
}
}]);
2、加载./src/add.js对应的函数挂载在__webpack_require__["./src/add.js"]对象上
var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
var [chunkIds, moreModules, runtime] = data;
var moduleId, chunkId, i = 0;
if(chunkIds.some((id) => (installedChunks[id] !== 0))) {
for(moduleId in moreModules) {
if(__webpack_require__.o(moreModules, moduleId)) {
__webpack_require__[moduleId] = moreModules[moduleId];
}
}
}
}
var chunkLoadingGlobal = self["webpackChunknormal"] = self["webpackChunknormal"] || [];
chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));
})();
执行脚本
__webpack_require__.bind(__webpack_require__, /*! ./add */ "./src/add.js").then(() => TODO Somthing)
解决方案
了解过文件动态加载的过程,原因是动态创建的脚本加载失败,同时返回的是一个Promise对象,所以修复这个问题只需要做的是再次请求文件,同时在项目崩溃页面前做完。
方案一
简单粗暴,全局监听onerror方法,判断是Loading chunk failed错误时,刷新页面就好,但是它的缺点显而易见,就是页面会reload,会对用户造成不好的体验。同时还有一个致命的问题是,当这个资源一直加载失败,那么页面会陷入反复循环的过程。
window.addEventListener('error', e => {
// prompt user to confirm refresh
if (/Loading chunk [\d]+ failed/.test(e.message)) {
window.location.reload();
}
});
方案二
由于webpack是将lazy(() => import(path))中path打包成一个chunk(开启抽离css文件功能的话,就是两个文件:一个js、一个css),同时ErrorBoundary组件作为兜底组件,一般都是存在组件的最外层,所以只需要保证lazy时能拿到想要的文件即可。
大致的思路就是:由于动态加载中import是返回一个promise,在promise中捕获Loading chunk failed错误,之后retry这个资源,当这个资源可以访问的时候,我们重新导入模块。这样做的好处是Suspense是一直处在loading的状态,直到lazy完成加载。
const formatURL = (e: Error) => {
const message = get(e, 'message', '');
const url = message.match(/\(http.*\)/g)[0];
return url.replace('(', '').replace(')', '');
};
const loadingChunk = <T,>(path: string): ReactNode => {
return new Promise((resolve, reject) => {
import(path).then(resolve).catch(e => {
const retryURL = formatURL(e);
try {
const url = new URL(retryURL);
if (retryURL.endsWith('.css')) {
const linkTag = document.createElement('link');
linkTag.rel = 'stylesheet';
linkTag.type = 'text/css';
linkTag.onload = () => {
import(path).then(module => {
resolve(module);
});
};
linkTag.onerror = () => {
// report Error
document.removeChild(linkTag);
reject(e)
};
document
.getElementsByTagName('head')[0]
.appendChild(linkTag);
}
} catch (error) {
}
});
});
};