前言
hello,大家好我是 loboding 🥕
机缘巧合,在和朋友闲谈技术时聊到 Dynamic Import
资源请求失败的容灾,最近刚好有时间搞一下。
可能大家会说资源加载错误监听不就是 window.addEventListener('error', callback, true)
嘛,捕获阶段才能获取资源请求错误这道题我会,再插入 script
重新请求万事大吉。事情往往不出意外地出意外了,备用源是请求到了,遗憾的是对于 import()
这个 Promise 状态机,永远定格到了 rejected。
好了,问题很明显,思路不清晰。废话不多说,我们接下来按 3 个步骤来尝试解决:
- 摸清 webpack 构建产物中是如何实现
import()
的 - 如何添加容灾补丁逻辑
- 如何通过
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
前置知识
- 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;
};
-
Compiler
和Compilation
的区别Compiler
是 webpack 最核心的模块,每个 webpack 配置对应一个 compiler 对象,记录整个构建的生命周期。(childCompiler 另当其说,感兴趣可以结合 html-webpack-plugin 深入了解)Compilation
代表着单次构建,是构建最重要的核心部分,会将代码进行module - chunk - asset
的转换。举一个更直观的例子,我们执行
webpack serve
会默认开启watch
模式,每检测到文件变化都会重新构建,那么这个过程会在同一个 compiler 下构建,每一次构建创建一个新的 compilation。 -
了解一个
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 去判断当前是否是生成loadingEnded
的RuntimeModule
,是则修改该实例对应的generate
。 请一定要注意上边这部分,很关键。 -
上一步我们说到要判断当前是否是生成
loadingEnded
的RuntimeModule
,那生成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) => {} })
-
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 仓库。
总结一下解决问题的思路吧:
- 搞清楚 webpack 如何处理
Dynamic Import
- 找到生成相关代码的文件
- 了解 webpack 构建全链路,可以更轻松地找到切入点,即哪个时机干预、如何干预
- 话不多说,调试源码基本技能要有的。
最近在学 AIGC 相关知识,想做点有意思的东西练手,有大佬指点嘛,欢迎赐教。