Webpack 模块加载动态引入机制源码示例解析
作者:mysteryven
TL;DR
本文基于 Webpack 5 进行讲解,适合不了解 Webpack 把资源编译成什么样子的同学,读完本文,你将理解下面几个问题的来龙去脉:
- Webpack 静态引入的实现逻辑,如
import App from './App'
- Webpack 的动态引入原理,也就是动态 import 是怎么实现的,如
import('./App')
- 模块联邦的原理(目前只给了大体的逻辑,超过 20 个赞会补充这部分的内容)
不仅如此,我们还将在每一个部分与 Vite 的实现进行对比,让大家能在更高的层次上掌握这部分知识。大多数讲解 Webpack 源码的内容都是截图源码,而笔者在阅读这些文章的时候就觉得体验不是特别好,往往看了几行便退出了。
本文会在保留原始函数名的基础上,抽离出主要的逻辑实现,相信这肯定能让大家更清晰的理解。
准备阶段
对某些同学来说,今天内容可能会稍微有一点难,在读完本文之后,可能还需要自己调试一下代码才能真正的理解,不过大家不用担心,笔者会尽力做到讲解清晰。最开始,先请大家和笔者一同配置下 Webpack 环境。
- 安装
pnpm
(非必须,不喜欢的同学请把后面的pnpm
替换为npm
,pnpx
替换为npx
)
npm install -g pnpm
- 初始化
mkdir webpack-demo pnpm init pnpm i webpack
- 生成模板(命令行提示缺什么,按照提示安装即可)
pnpm webpack init -f
- 使用下面的配置替换 webpack.config.js
const config = { entry: './src/index.js', mode: 'development', output: { path: path.resolve(__dirname, 'dist'), }, devServer: { open: true, host: 'localhost', }, devtool: 'source-map', optimization: { runtimeChunk: 'single' }, plugins: [ new HtmlWebpackPlugin({ template: 'index.html', }), ], module: { rules: [ { test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i, type: 'asset', }, ], }, }; module.exports = config
package.json
build 命令替换,不保留指定mode
为 production。
"scripts": { - "build": "webpack --mode=production --node-env=production", - "build:dev": "webpack --mode=development", - "build:prod": "webpack --mode=production --node-env=production", + "build": "webpack", "serve": "webpack serve" },
- 测试
pnpm build
、pnpm serve
都能正常运行,并且打包目录能看到独立的 runtime.js,说明已经配置好了。
Runtime
Runtime 又叫做运行时,它的作用是串联起各个模块,包括引入模块、下载模块、一些基础的公共方法。通过 Runtime 作为桥梁,我们就能把各个模块联系起来,最终让被 Webpack 打包的应用在浏览器跑起来。
除此之外,HMR 的能力也需要 Runtime 的支持,我们可以通过预先注入一系列 HMR 的工具函数(包括 WebSockect 通信,HMR API),来实现此功能。
如果你按照我们的准备阶段的提示成功把项目跑起来了,可以运行一下 pnpm build
命令,然后去 dist 目录查看 runtime.js 文件。搜索 __webpack_require__
关键词,它下面会有很多方法或对象,包括 __webpack_require__.m
、__webpack_require__.o
、__webpack_require__.e
, 这些就是我们今天要谈论的主角。
模块被打包成了什么样子?
这一部分我们不使用 Webpack 打包,而是模拟一下。
对于模块被打包要解决的问题,笔者有一些思考,认为有以下几个方面:
- 对 ES Module 出于兼容性的考虑,在 Webpack 出现的那个时代,ES Module 的支持性并不理想。
- 在 HTTP 1.X 的场景下,ES Module 带来的请求量不可预估,而 HTTP 层面队头阻塞的缺点,使得项目可能会造成网络阻塞的现象。除此之外, 在现在 ESM 支持性已经很好的场景下,即便我们使用了 HTTP 2 可以不用考虑并行的请求数,但是 import 的层级嵌套依然会带来网络层面上额外的 Road Trip 的消耗,同时依然存在 TCP 层面的队头阻塞。
- 对于一些相似性很高的内容,多个文件压缩到一块压缩效果也不差,可能会比两者分开请求要好。
模块被打包后可能需要考虑下面三点:
- 独立的模块作用域,两个模块之间不应该互相影响
- 缓存机制,模块被加载过一次就不用再发起请求了
- 环依赖问题
在上面的基础上,我们来看看 Webpack 把 ESM 的代码编译成立什么样子。
首先,我们的有三个文件:index.js、message.js、name.js,依赖关系如下面代码所示:
// filename: index.js // ** 入口文件 ** import message from './message.js'; console.log(message); // filename: message.js import {name} from './name.js'; export default `hello ${name}!`; // filename: name.js export const name = 'world';
最后的执行结果便是输出 hello world。
我们来看一下最后编译成的样子:
const modules = { 0: [ function (require, module, exports) { "use strict"; var _message = require("./message.js"); var _message2 = _interopRequireDefault(_message); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } console.log(_message2.default); }, { "./message.js": 1 }, ], 1: [ function (require, module, exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var _name = require("./name.js"); exports.default = "hello " + _name.name + "!"; }, { "./name.js": 2 }, ], 2: [ function (require, module, exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var name = exports.name = 'world'; }, {}, ], } function load(modules) { function require(id) { const [fn, mapping] = modules[id]; const module = { exports: {} }; function localRequire(name) { return require(mapping[name]); } fn(localRequire, module, module.exports); return module.exports; } require(0); } load(modules)
可以看到,我们所有模块的内容都被维护到了 modules
这个大对象里,import 语句被转换成立 require 语句,当调用 load 函数的时候,整个加载过程就开始了。如果第一次了解上面的格式,可能需要大家好好的品味一下。
再次说明,上面这段代码值得花时间好好看一下。
如果你有兴趣想了解是怎么转成这种格式的,推荐 minipack 这个库,如果不喜欢看英文,可以看笔者的这篇 mini webpack打包基础解决包缓存和环依赖
静态引入
自从社区涌现了了 Vite、Snowpack 等打包工具之后,Webpack 则被分到了一个新的营地 —— Bundler,与之相对的,Vite 则是 No-Bundler。在讲解完本小节内容最后,笔者会为大家对比 Vite 和 Webpack 在引用模块机制上的区别,届时大家可能从引用模块的这个角度对 Bundler 和 No-Bundler 有更深刻的理解,可能也会知道,No-Bundler 并非一定是银弹。
在此之前,让我们先聚焦于 Webpack 的模块引用机制。我们使用的例子依然是上一小节的例子,只不过打包工具换成了 Webpack。
首先,我们先运行一下 pnpm build
, 发现 dist 目录有两个 JS 文件:main.js、runtime.js。运行时的代码都在 runtime.js,而我们模块内容相关的都在 main.js。由 index.html 控制二者的下载:
<script defer src="runtime.js"></script> <script defer src="main.js"></script>
可以注意到先下载了 runtime.js, 再下载 main.js。这是必须的,因为首先我们需要在注册一些全局变量,注册好了之后,main.js 才可以通过全局变量来和运行时进行交互。加 defer 的作用是可以不阻塞 DOM 树的解析,异步下载内容,可以减少白屏时间(First Content Paint)。
最开始的,定义的 webpackChunkmy_webpack_project
这个全局变量,如下所示:
self["webpackChunkmy_webpack_project"] = self["webpackChunkmy_webpack_project"] || []; const chunkLoadingGlobal = self["webpackChunkmy_webpack_project"]
接着重写 webpackChunkmy_webpack_project
上的 push
方法:
chunkLoadingGlobal.push = webpackJsonpCallback.bind( null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal) );
上面这句话的含义是:
push
重置为webpackJsonpCallback
函数- 给
webpackJsonpCallback
绑定参数,this
为null
,但是函数的第一个参数为chunkLoadingGlobal
数组原来的的push
方法,也就是说,调用此方法可以往chunkLoadingGlobal
这个数组里加值。
我们可以在 window 打印这个值:
接下来我们看一下 main.js ,各位请注意,为了方便大家阅读,把 main.js 格式修改了,如果大家看源码建议搜索变量名。
const chunkIds = ["main"]; const moreModules = { "./src/index.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { // 内容暂时省略 }), "./src/message.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { // 内容暂时省略 }), "./src/name.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { // 内容暂时省略 }) } const runtime = __webpack_require__ => { var __webpack_exec__ = (moduleId) => (__webpack_require__(__webpack_require__.s = moduleId)) var __webpack_exports__ = (__webpack_exec__("./src/index.js")); } self["webpackChunkmy_webpack_project"].push([ ["main"], moreModules, runtime ]);
所以关键点还是来到了调用 webpackChunkmy_webpack_project
的 push
方法, 也就是 runtime 里的 webpackJsonpCallback
,接下来我们看这个函数做了什么,你可以先大概浏览一下。
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__.m[moduleId] = moreModules[moduleId]; } } if (runtime) var result = runtime(__webpack_require__); } if (parentChunkLoadingFunction) parentChunkLoadingFunction(data); for (; i < chunkIds.length; i++) { chunkId = chunkIds[i]; if (__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) { installedChunks[chunkId][0](); } installedChunks[chunkId] = 0; } return __webpack_require__.O(result); }
接下来逐行解释:
1. 函数的第一个参数是绑定数组原始的 push
方法,最开始就被 bind 了; 第二个参数是我们调用此函数入的参数,可以回看一下 main.js 最后的调用,是一个数组结构。
self["webpackChunkmy_webpack_project"].push([ ["main"], moreModules, runtime ]);
2. var [chunkIds, moreModules, runtime] = data;
解构出这些参数,其中 chunkIds
是 ["main"]
,剩下的以此类推。
3.
if (chunkIds.some((id) => (installedChunks[id] !== 0))) { for (moduleId in moreModules) { if (__webpack_require__.o(moreModules, moduleId)) { __webpack_require__.m[moduleId] = moreModules[moduleId]; } } if (runtime) var result = runtime(__webpack_require__); }
installedChunk 是用来缓存模块的加载状态的,其中 0 代表已经加载好了。所以 if 语句的意思就是,如果 chunkIds
有模块还没有加载好。
继续往下我们需要介绍两个函数。
a. __webpack_require__.o
:这个就是判断判断 key 值有没有在对象本身上:
__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
b. __webpack_require__.m
: 它维护的是所有的模块,因为我们可能有 main.js,main-1.js 都有模块需要管理,这时候就通过它去统一的注册上我们的模块里去。
var __webpack_modules__ = ({}); __webpack_require__.m = __webpack_modules__;
明白了上面两个工具函数,我们上面那段代码的含义就是把 moreModules
里的模块都注册到 __webpack_require__.m
上去。
注册完了之后剩下的就是执行了,也就是 runtime
函数做的事情 runtime
函数可以简化为 :
const runtime = __webpack_require__ => { __webpack_require__("./src/index.js")); }
而 __webpack_require__
的作用可以理解为和我们在 「模块被打包成了什么样子?」这一小节最后给出的 require
函数作用一样了,也就是说,执行 __webpack_require__.m
这个对象里 key 对应的 函数。具体的,就是执行刚才我们省略了的 moreModules 里的某一项:
"./src/index.js": ((_, _1, __webpack_require__) => { var _message_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/message.js"); console.log(_message_js__WEBPACK_IMPORTED_MODULE_0__["default"]); })
走到这一步,其实就已经可以串联起所有的模块了。不知你是否有种拨开云雾见日出的感觉。
4. 首先是 把 data
push 到我们的 webpackChunkmy_webpack_project
数组里,再接下来把加载好的做缓存,存储到 installedChunks
中去,返回值我们没有用到,所以这里就略过。
if (parentChunkLoadingFunction) parentChunkLoadingFunction(data); for (; i < chunkIds.length; i++) { chunkId = chunkIds[i]; // 这一步是动态引入的关键,这里暂时不分析 if (__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) { installedChunks[chunkId][0](); } installedChunks[chunkId] = 0; } return __webpack_require__.O(result);
通过上面我们可以感受到 Webpack 是怎么加载模块的,那在 Vite 中会是怎么样呢?还是举例子来说。
假设我们要请求一个 index.tsx 文件。在没有发起请求之前,Vite 的 Dev Server 不会帮我们预编译这个模块。当我们发起请求,此次 Vite 才会调用 ESBuild 编译这个模块,在这个过程中,会把 index.tsx 文件转译成 ESM 格式的 JS 文件,如果此时有 bare import ,还会有路径名重写成预编译的路径;有配置 alias ,也会把路径名改写成我们配置的 alias。最后,会把这个文件(index.tsx)的编译结果记录到 moduleGraph 中,moduleGraph 是 Vite 内部的模块图,是实现模块缓存、HMR 的关键。
接着,它会返回当前请求,当浏览器收到当前请求之后,当前文件可能有多个 import 请求,浏览器将并行的发出这些请求,Vite 也将重复上面操作,继续使用 ESBuild 的编译,然后记录到 moduleGraph 中,一直递归的进行到当前入口文件都请求完毕。
等到再次请求,我们就可以没有这么麻烦了,直接从 moduleGraph 中读取缓存的内容了。
大家可能发现了,从加载模块的这个角度,moduleGraph 和我们上面的 __webpack_require__.m
基本是一样的。只不过,Webpack 事先计算好了所有的内容,而 Vite 则按需计算。如果我们不做分包,一个很大的文件嵌套很多层,在 Dev Server 阶段最开始启动服务的时候, Vite 也并不一定比 Webpack 要快。
由于目前 Vite 没有对 moduleGraph 做缓存,重启 Server 则又会重新走一步编译-存储的流程,所以每次重启,在极端情况下,第一次加载可能都会比较慢;与之相对的,Webpack 现在对编译的结果做文件系统级别的缓存,这样子做了之后,甚至可以逼近 No-Bundler 的速度了。
module.exports = { cache: { type: 'filesystem', allowCollectingMemory: true, }, };
动态引入
通过上面那一小节的讲解,或许你会发现,假设我们动态引入的模块叫做 a.js,只要它也是形如这样去调用的:
// ... 前面省略 self["webpackChunkmy_webpack_project"].push([ ["a"], moreModules, runtime ]);
我们好像不用额外做什么,就能把这些模块注册到主模块并且运行了。又可以说,某种程度上我们的静态引入其实也算是「动态引入」。当然了,其实我们还是要做一些额外的工作的,因为我们的模块引入之后,往往有一些回调函数。但是主要的逻辑基本是一致的。
其实动态引入(Dynamic Import)的实现一般都是通过 JSONP 的形式,基本的实现原理如下所示:
function importModule(url) { return new Promise((resolve, reject) => { const script = document.createElement("script"); // 注册一个随机的全局变量,后面值挂上去 const tempGlobal = "__tempModuleLoadingVariable" + Math.random().toString(32).substring(2); script.type = "module"; script.textContent = `import * as m from "${url}"; window.${tempGlobal} = m;`; script.onload = () => { // 请求结束 resolve 掉。 resolve(window[tempGlobal]); delete window[tempGlobal]; script.remove(); }; script.onerror = () => { reject(new Error("Failed to load module script with URL " + url)); delete window[tempGlobal]; script.remove(); }; document.documentElement.appendChild(script); }); }
接下来我们改一下例子,运行 pnpm build
看看在 Webpack 中是怎么实现的:
// filename: index.js - import message from './message.js'; - console.log(message); + import ('./message.js').then(message => { + console.log(message) + })
首先我们发现多了一个文件 src_message_js.js。
再观察 main.js,import 语句被编译成了:
__webpack_require__.e( "src_message_js" ).then( __webpack_require__.bind( __webpack_require__, "./src/message.js" ) ).then(message => { console.log(message) })
看这段代码我们根据前面的知识可以进行猜想:
__webpack_require__.e
的作用就是下载 src_message_js 的内容,并把它注册到全局的__webpack_require__.m
上,这一步应该可以静态引入一样,不过它是异步完成,下载完了才注册。- 调用
__webpack_require__(./src/message.js)
可以从全局模块对象里拿到它对应的导出值 - 打印出结果
接下来我们开始实际调试验证对不对。
首先是 __webpack_require__.e
,它的作用就是遍历执行 __webpack_require__.f
上的方法,并且要再它上面所有的方法都执行完了才解决:
__webpack_require__.e = (chunkId) => { return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => { __webpack_require__.f[key](chunkId, promises); return promises; }, [])); };
值得高兴的是,__webpack_require__.f
就挂了一个方法,__webpack_require__.f.j
。它的作用就是通过 JSONP 的形式下载模块。
在这里先和大家讲解一下 installedChunks
的数据结构,讲完这个,大家应该可以结合着笔者给的注释看明白代码了。它也是一个缓存的对象,为了避免多次动态引入而发起多次请求。
- 第一种情况,已经安装的模块,value 会被置为 0,这样再引入便直接使用缓存。
const installedChunks = { runtime: 0 }
- 第二种情况,还在加载中的模块,value 是一个数组,三项分别是同一个 promise 对象的 resolve、reject、本身:
const installedChunks = { 'src_message_js': [resolve, reject, promise] }
接下来大家看代码不要纠结于细节,而是抓住主干:在请求到资源后,调用 onload 事件,整个流程就完了。
__webpack_require__.f.j = (chunkId, promises) => { var installedChunkData = __webpack_require__.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined; if (installedChunkData !== 0) { // 0 代表已经下载好了. // 有值说明还在 loading 中,已经开始下载了,没必要再次下载 // 但是把 promise 状态给出去 if (installedChunkData) { promises.push(installedChunkData[2]); } else { if ("runtime" != chunkId) { // 组装好 promise, 填入 installedChunks 中 var promise = new Promise((resolve, reject) => (installedChunkData = installedChunks[chunkId] = [resolve, reject])); promises.push(installedChunkData[2] = promise); // 根据 publicPath 拼出 合适的 URL var url = __webpack_require__.p + __webpack_require__.u(chunkId); var error = new Error(); var loadingEnded = (event) => { // 意义不大,暂时删掉 }; __webpack_require__.l(url, loadingEnded, "chunk-" + chunkId, chunkId); } else installedChunks[chunkId] = 0; // 标识已 loaded } } };
接下来就是 __webpack_require__.l
这个工具函数,它的作用就是根据传入的 URL 去请求,简化后如下:
__webpack_require__.l = (url, done, key, chunkId) => { var scripts = document.getElementsByTagName("script"); script.timeout = 120; script.src = url; document.head.appendChild(script) }
执行到这里 就回去下载 src_message_js.js 文件,你可能会好奇,怎么还没 resolve ? 其实 src_message_js.js 的加载方式和我们刚开始说的静态引入一样,也是调用 push 方法:
self["webpackChunkmy_webpack_project"] || []).push([ ["src_message_js"], // 后面省略 )
而此时就又会走到 webpackJsonpCallback
,在调用这个函数的时候,我们回顾一下,会先把模块注册到 __webpack_require__.m
上去,接下来有一点我们没有讲:
if ( __webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId] ) { installedChunks[chunkId][0](); }
上面代码就是关键,在这里去 resolve 掉了我们的异步引入,此时我们再把整理的函数放过来,你是不是对这个函数的每一行都理解了呢:
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__.m[moduleId] = moreModules[moduleId]; } } if (runtime) var result = runtime(__webpack_require__); } if (parentChunkLoadingFunction) parentChunkLoadingFunction(data); for (; i < chunkIds.length; i++) { chunkId = chunkIds[i]; if (__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) { installedChunks[chunkId][0](); } installedChunks[chunkId] = 0; } return __webpack_require__.O(result); }
好了,这就是 Webpack 动态引入的原理。笔者认为实现过程非常美丽、优雅。而在 Vite 中其实就没什么好说的了,它就是使用的原生的 import 。
模块联邦引入原理
这个的实现逻辑和动态引入很像,不同的是,动态引入只有一个工具函数,叫做 __webpack_require__.f.j
,而它有三个,分别是:
__webpack_require__.f.consumes
__webpack_require__.f.j
__webpack_require__.f.remotes
由这三个一起完成了模块联邦的神奇功能。
这部分容笔者留一个坑,其实,再讲解完上面两部分之后,同学们可以自己尝试一下是否可以明白原理了,如果有同学想看这部分原理,不妨留言,笔者择期进行补充。
和各位读者预告一下,下一篇,笔者将更新 Immer 的源码解读,这是一个笔者很喜欢的库,敬请期待。
更多关于Webpack 模块加载动态引入的资料请关注脚本之家其它相关文章!