自定义 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 相关知识,想做点有意思的东西练手,有大佬指点嘛,欢迎赐教。

相关推荐
无敌的黑星星10 分钟前
Java8 CompletableFuture 实战指南
linux·前端·python
雁鸣零落23 分钟前
如何在 Chrome 中查看其他浏览器的书签?书签空间订阅与侧边栏只读切换指南
前端·chrome·edge浏览器
hpoenixf1 小时前
一天上线 + 零返工:我如何给复杂前端需求建立“安全感”
前端
广州华水科技2 小时前
单北斗GNSS变形监测系统在水利工程安全保障中的应用与优势分析
前端
yqcoder2 小时前
CSS 外边距重叠(Margin Collapsing):现象、原理与完美解决方案
前端·css
山楂树の3 小时前
图像标注大坑:img图片 + Canvas 叠加标注,同步放大后标注位置偏移、对不齐?详解修复方案及亚像素处理原理
前端·css·学习·canva可画
本山德彪3 小时前
我做了一个拼豆图纸生成器,把照片秒变图纸
前端
DTrader3 小时前
用TS无法实盘量化? - 实盘均线策略
前端·api
进击的夸父3 小时前
vfojs:Vue 超集架构,外壳React灵魂Vue
前端
编程老船长3 小时前
解决不同项目需要不同 Node.js 版本的问题
前端·vue.js