webpack之Loading chunk failed几种解决方案

2,912 阅读3分钟

背景

从入职到现在,报警群里每个月总会有几个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动态加载核心实现

流程

image.png

创建加载脚本

创建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) {
        }
      });
    });
};