自定义 Webpack 插件 —— Dynamic Import 容灾

前言

hello,大家好我是 loboding 🥕

机缘巧合,在和朋友闲谈技术时聊到 Dynamic Import 资源请求失败的容灾,最近刚好有时间搞一下。

可能大家会说资源加载错误监听不就是 window.addEventListener('error', callback, true) 嘛,捕获阶段才能获取资源请求错误这道题我会,再插入 script 重新请求万事大吉。事情往往不出意外地出意外了,备用源是请求到了,遗憾的是对于 import() 这个 Promise 状态机,永远定格到了 rejected

好了,问题很明显,思路不清晰。废话不多说,我们接下来按 3 个步骤来尝试解决:

  1. 摸清 webpack 构建产物中是如何实现 import()
  2. 如何添加容灾补丁逻辑
  3. 如何通过 webpack plugin 实现上一步的解决方案

代码地址

demo 地址:github.com/crystalhug/...

📢: 项目代码来自于本人日常测试,不必深究,只需要关注 webpack.config.base.js & /plugins/DynamicImportRecoveryPlugin.js 即可。

调试不清楚的朋友可以自己搜搜,简单点就是 vscode debug terminal,只需要在不清楚的片段加断点 + 命令行输入构建命令即可。

webpack 如何实现 Dynamic Import

详细参考上篇文章 Webpack 中 Dynamic Import 打包产物是啥

回顾一下前置知识,webpack 构建产物可以分为两类:业务代码 + 运行时。业务代码指开发者自己写的代码,例如 demo 中 /src 下的;运行时顾名思义调度业务代码,让我们的代码跑起来。

将 runtime 打包到单个文件中,会看到很多变量定义,和一堆自执行函数,而 webpack 在构建的 make 阶段根据我们的业务代码遍历出需要的运行时依赖,而不是每次构建都生成同样的静态 runtime。这句话的意思解释一下,假设我们的业务代码中只有一个模块(可以理解为一个文件),代码中没有 import() ,那么产出的 runtime.bundle.js 中就不会有 __webpack_require__.e相关代码,因为这个功能函数的作用就是加载异步代码的。

了解过 webpack 构建原理的都知道底层用到了 Tapable 类来管理插件和触发插件的执行,而 runtime 的生成也是由分管各自功能的插件配合生成的。如果感兴趣可以查看 RuntimePlugin.js,最主要的功能就是在 compilation.hooks.runtimeRequirementInTree 的时机注册各个 xxxRuntimePlugin 子类。

言归正传,回到打包产物,总结一下与 Dynamic Import 相关的运行时代码:

  • __webpack_require__:通过 moduleId 取对应的模块函数
  • __webpack_mudules__:moduleId - module function 形式存储模块函数
  • __webpack_require__.e:异步取,返回 promise
  • __webpack_require__.f.j:生成 url 并请求
  • __webpack_require__.l:通过 script 标签请求异步 chunk
  • __webpack_require__.o:判断是否包含某些属性,底层实现 - hasOwnProperty

且我们发现改变 promise 为 rejected 状态的逻辑在 loadingEnded 中:

js 复制代码
__webpack_require__.f.j = (chunkId, promises) => {

    var loadingEnded = (event) => {
        if(__webpack_require__.o(installedChunks, chunkId)) {
            installedChunkData = installedChunks[chunkId];
            if(installedChunkData !== 0) installedChunks[chunkId] = undefined;
            if(installedChunkData) {
                // handle error...
                // installedChunkData = [resolve, reject, promise]
                installedChunkData[1](error);
            }
        }
    };
};

所以我们的补丁可以加到 loadingEnded 中,水到渠成,直接为下一步提供了思路。

容灾补丁逻辑

通过上一步了解,我们可以在以下位置插入新的 url 去请求异步 chunk 👇🏻

js 复制代码
var loadingEnded = (event) => {
    if(__webpack_require__.o(installedChunks, chunkId)) {
        installedChunkData = installedChunks[chunkId];
+        if (installedChunkData && installedChunkData !== 0) {
+            __webpack_require__.l(newUrl, loadingEnded, "chunk-" + chunkId, chunkId);
+            return;
+        }
        if(installedChunkData !== 0) installedChunks[chunkId] = undefined;
        if(installedChunkData) {
            installedChunkData[1](error);
        }
    }
};

对输出处理的方式有很多种,不过不想有更麻烦的事情发生还是按规矩办事简单一些,所以选择插件是更好地方案。

编写 plugin

前置知识

  1. plugin 是个类,必须定义原型方法 apply,入参为 compiler,可以通过 compiler.hooks 注册插件到这些事件点上,以执行自定义逻辑。
js 复制代码
// 创建 compiler,会准备运行环境、将插件定义的 callback 注册到对应 hooks 上
const createCompiler = rawOptions => {
    const options = getNormalizedWebpackOptions(rawOptions);
    // 执行各种 plugin 的 apply,并传入 compiler 实例
    new NodeEnvironmentPlugin({
        infrastructureLogging: options.infrastructureLogging
    }).apply(compiler);
    if (Array.isArray(options.plugins)) {
        for (const plugin of options.plugins) {
            if (typeof plugin === "function") {
                plugin.call(compiler, compiler);
            } else if (plugin) {
                plugin.apply(compiler);
            }
        }
    }
    // ...
    return compiler;
};
  1. CompilerCompilation 的区别

    Compiler 是 webpack 最核心的模块,每个 webpack 配置对应一个 compiler 对象,记录整个构建的生命周期。(childCompiler 另当其说,感兴趣可以结合 html-webpack-plugin 深入了解)

    Compilation 代表着单次构建,是构建最重要的核心部分,会将代码进行 module - chunk - asset 的转换。

    举一个更直观的例子,我们执行 webpack serve 会默认开启 watch 模式,每检测到文件变化都会重新构建,那么这个过程会在同一个 compiler 下构建,每一次构建创建一个新的 compilation。

  2. 了解一个 compilation 的钩子 - runtimeModule定义

    js 复制代码
    // webpack/lib/Compilation.js
    // constructor 中定义 this.hooks 中定义了 runtimeModule 为一个同步钩子
    /** @type {SyncHook<[RuntimeModule, Chunk]>} */
    runtimeModule: new SyncHook(["module", "chunk"]),

    📢 关于 Tapable 中同步、异步,basic、WaterfallHook、bail、loop,series、paralle 各种组合的 hook 如何使用,大家可以自行搜索,使用上很简单的,但还是需要知道每个的差异,并了解基础用法,这样方便理解 webpack 的一些行为。

    触发 - call

    js 复制代码
    // webpack/lib/Compilation.js
    addRuntimeModule(chunk, module, chunkGraph = this.chunkGraph) {
        // 建立 chunkGraph 的各种关联,添加运行时模块实例...
    
        // Call hook
        this.hooks.runtimeModule.call(module, chunk);
    }

    🌟🌟🌟 这部分可以展开很大地说,暂时先简单了解下触发 runtimeModule 之前,已经加入了当前运行时模块 - RuntimeModule。因此我们可以监听 addRuntimeModule hooks 去判断当前是否是生成 loadingEndedRuntimeModule,是则修改该实例对应的 generate。 请一定要注意上边这部分,很关键。

  3. 上一步我们说到要判断当前是否是生成 loadingEndedRuntimeModule,那生成 loadingEnded 的 Module 是什么呢? 答案是JsonpChunkLoadingRuntimeModule

    关于为什么是 JsonpChunkLoadingRuntimeModule,很简单,在 runtime.bundle.js 的产出文件中,每一部分的 IIFE 上边都有都有说明,可以通过该注释找到对应生成文件名。

    js 复制代码
    // dist/runtime.bundle.js
    // 对应 JsonpChunkLoadingRuntimeModule.js
    /* webpack/runtime/jsonp chunk loading */
    (() => {
        var installedChunks = {
            "runtime": 0
        };
    
        __webpack_require__.f.j = (chunkId, promises) => {}
    })
  4. Template 生成代码的模版函数,使用很简单,可以参考JsonpChunkLoadingRuntimeModule,这里就不解释了。

编写 Plugin

前置知识都掌握之后,一个简略的 plugin 就诞生了:

js 复制代码
module.exports = class DynamicImportRecoveryPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap(
      "DynamicImportRecoveryPlugin",
      (compilation) => {
        function generate() {
          const { runtimeTemplate } = compilation;
          // RuntimeGlobals 中保存着所有 runtime 函数命名,此处对应 __webpack_require__.f
          const fn = RuntimeGlobals.ensureChunkHandlers;

          return Template.asString([
            Template.asString([
              "var retryCount = 0;",
              "",
              `${fn}.j = ${runtimeTemplate.basicFunction(
                // ...
                `var loadingEnded = ${runtimeTemplate.basicFunction(
                  "event",
                  [
                    `if(${RuntimeGlobals.hasOwnProperty}(installedChunks, chunkId)) {`,
                    Template.indent([
                      "installedChunkData = installedChunks[chunkId];",
                      "if (installedChunkData && installedChunkData !== 0) {",
                      "if(retryCount < 1) {",
                      Template.indent([
                        "// retryCount 只是举例子可以添加容灾请求次数等限制",
                        `${ RuntimeGlobals.loadScript }(url, loadingEnded, "chunk-" + chunkId, chunkId${""});`,
                        "return;",
                      ]),
                      "}",
                      "}",
                      "if(installedChunkData !== 0) installedChunks[chunkId] = undefined;",
                      "if(installedChunkData) {",
                      Template.indent([
                        "var errorType = event && (event.type === 'load' ? 'missing' : event.type);",
                        "var realSrc = event && event.target && event.target.src;",
                        "error.message = 'Loading chunk ' + chunkId + ' failed.\\n(' + errorType + ': ' + realSrc + ')';",
                        "error.name = 'ChunkLoadError';",
                        "error.type = errorType;",
                        "error.request = realSrc;",
                        "installedChunkData[1](error);",
                      ]),
                      "}",
                    ]),
                    "}",
                  ]
                )};`,
                // ...
              )};`,
            ]),
            "",
          ]);
        }

        compilation.hooks.runtimeModule.tap(
          "RuntimeLoadDeferredChunksPlugin",
          (module, chunk) => {
            if (module instanceof JsonpChunkLoadingRuntimeModule) {
              const origGenerate = module.generate.bind(module);
              // 覆盖原来的 __webpack_require__.f.j
              module.generate = () => {
                const result = origGenerate();
                const newResult = generate();

                return [result, newResult].join("\n");
              };
            }
          }
        );
      }
    );
  }
}

例子中写明了测试专门做的一些错误尝试,明白即可,不要抄,要自己改的。

总结

看完是不是很简单,当然实际开发中要按照需求细化代码,除了 plugin 中有为了测试故意写错的 url,更重要的是换备用源逻辑。虽然只是个测试,但思路是通的,后续有时间会修改 plugin 写一个通用的方便大家直接使用,也会更新到 github demo 仓库。

总结一下解决问题的思路吧:

  1. 搞清楚 webpack 如何处理 Dynamic Import
  2. 找到生成相关代码的文件
  3. 了解 webpack 构建全链路,可以更轻松地找到切入点,即哪个时机干预、如何干预
  4. 话不多说,调试源码基本技能要有的。

最近在学 AIGC 相关知识,想做点有意思的东西练手,有大佬指点嘛,欢迎赐教。

相关推荐
web1350858863516 分钟前
前端node.js
前端·node.js·vim
m0_5127446417 分钟前
极客大挑战2024-web-wp(详细)
android·前端
潜意识起点41 分钟前
精通 CSS 阴影效果:从基础到高级应用
前端·css
奋斗吧程序媛1 小时前
删除VSCode上 origin/分支名,但GitLab上实际上不存在的分支
前端·vscode
IT女孩儿1 小时前
JavaScript--WebAPI查缺补漏(二)
开发语言·前端·javascript·html·ecmascript
m0_748256563 小时前
如何解决前端发送数据到后端为空的问题
前端
请叫我飞哥@3 小时前
HTML5适配手机
前端·html·html5
@解忧杂货铺5 小时前
前端vue如何实现数字框中通过鼠标滚轮上下滚动增减数字
前端·javascript·vue.js
F-2H7 小时前
C语言:指针4(常量指针和指针常量及动态内存分配)
java·linux·c语言·开发语言·前端·c++
gqkmiss7 小时前
Chrome 浏览器插件获取网页 iframe 中的 window 对象
前端·chrome·iframe·postmessage·chrome 插件