本文直接上手分析bundle,如果对模块化开发的发展历程、模块化规范还不是很熟悉,可以通过此链接了解一下:segmentfault.com/a/119000001…
开始分析之前,先创建一个小demo。
创建源码目录:
mkdir demo
cd demo
mkdir src
在src目录中创建四个文件,分别是入口文件index.js,esm文件module1.js、dynamic.js,commonjs文件common-module.js,代码分别如下:
// index.js
import Module1 from './module1';
import CommonJsModule from './common-module';
import('./dynamic').then((module) => {
console.log(`Dynamic module: ${module.default()}`);
});
console.log(`Module 1: ${Module1()}`);
console.log(`CommonJsModule: ${CommonJsModule()}`);
// module1.js
export default function() {
return 'This is Module1 .';
}
// dynamic.js
export default function() {
return `This is dynamic module`;
}
// common-module.js
module.exports = function() {
return `This is commonjs module.`;
}
模块中的代码不重要,重要的是引入模块的方式,在入口文件中,通过静态 import 引入 modules 模块,通过动态 import 引入 dynamic 模块,通过 import 引入 common-module 模块。
cd .. 回到demo目录下,分别创建webpac4、webpack5以及roooolup目录,分别用来安装不同的构建工具。
安装webpack4,配置webpack.config.js。
mkdir webpack4
cd webpack4
npm init -y
npm install -D webpack@^4 webpack-cli@^4
// webpack4/webpack.config.js
module.exports = {
mode: "development",
entry: '../src/index.js',
}
设置package.json中的script:
{
"scripts": {
"build": "webpack"
}
}
安装webpack5,配置webpack.config.js。
mkdir webpack5
cd webpack5
npm init -y
npm install -D webpack@^5 webpack-cli@^4
// webpack5/webpack.config.js 配置和webpac4相同
module.exports = {
mode: 'development',
entry: '../src/index.js',
}
设置package.json中的script,同webpack4 。
安装rollup,配置rollup.config.js。
mkdir roooolup
cd roooolup
npm init -y
npm install -D rollup
// rollup.config.js
import commonjs from '@rollup/plugin-commonjs';
export default {
input: '../src/index.js',
output: {
dir: './dist',
format: 'amd',
},
plugins: [
commonjs()
]
};
设置package.json中的script:
{
"scripts": {
"build": "rollup -c rollup.config.js"
}
}
下面将从分别分析webpack4、webpack5、rollup针对静态加载ESM、加载CommonJS模块、动态加载ESM三个方面进行分析,针对某种类型进行分析时,会注释掉其他模块的引入,只关注当前分析的模块类型。
一、webpack4 模块加载原理分析
(1)静态加载ESM
// index.js
import Module1 from './module1';
console.log(`Module 1: ${Module1()}`);
执行 npm run build之后,在 webpack4/dist 目录下,生成 main.js 文件。
默认development模式下生成的代码会有很多格式化用的注释,如下图所示:
为了方便查看编译后的代码,先清理一下注释。将代码进行折叠后,先看整体结构:
主体结构就是一个立即执行匿名函数—— function(modules) 。
展开执行函数的参数传的值,为一个以模块路径为Key,以一个使用eval执行代码的函数作为value的对象:
展开匿名函数,查看函数内的代码,基本逻辑如下图所示:
development模式下编译后的代码,可读性很强,注释清晰。
首先声明 installedModules 变量,存放模块缓存,然后声明 __webpack_require__ 函数,接着在 __webpack_require__ 函数对象上挂一些数据和工具方法,最后调用 __webpack_require__ 方法加载入口模块。
创建 index.html 文件引入 main.js ,通过断点调试,具体看一下模块的加载过程。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Webpack 4</title>
</head>
<body>
<script src="./dist/main.js"></script>
</body>
</html>
打开Chrome开发者工具中的 Sources 面板,找到 main.js 文件。从上面整体分析可以知道,模块的加载是从 __webpack_require__ 方法开始的,所有我们在该方法内打一个断点,然后刷新页面,重新执行JS。
代码执行到断点处,首先会判断模块是否存在于缓存对象中。
加载入口模块时,此时installedModules对象为空,所以直接跳过if里的逻辑,执行到 Line 11,创建一个模块对象,并用moduleId作为installedModules对象的key,存放到installedModules对象中,并赋值给module变量,该模块对象包含三个属性,i 属性为 模块id,l 设置为false,exports 属性为一个空对象{}。
接着代码执行到Line 17,通过 modules[moduleId] 获取模块函数,将
module, module.exports, __webpack_require__ 作为参数传入函数中,执行eval方法。
点击“Step”按钮,查看eval的代码如下:
模块代码的第一行,调用了 r 方法,现在我们看一下 r 方法要做什么事情,代码如下图所示。该方法的作用,是初始化模块对象的exports属性的值,创建module对象时,exports时空对象{}, 该方法给exports定义了两个属性:
- Symbol.toStringTag:exports的类型;
- __esModule: 是否为esm;
代码继续执行,因为入口文件中引入了module1模块,所以接着调用了
__webpack_require__ 函数,加载../src/module1.js模块,又开始了加载模块的逻辑。
模块module1中的eval的代码如下图所示,因为是ES模块,所以也执行了 r 方法初始化exports对象属性,接着添加了default属性到exports对象上,将模块默认导出的代码赋值给了exports.default属性。
然后module1模块代码执行结束,继续 __webpack_require__ 函数的逻辑,最后返回模块的exports对象。
代码继续执行,回到入口模块逻辑,此时将module1模块返回exports对象,赋值给
_module1__WEBPACK_IMPORTED_MODULE_0__ 变量,继续执行入口模块代码,则入口模块函数执行完毕。
继续执行入口模块的加载逻辑,设置加载状态为true,返回入口模块exports属性:
以上,静态加载ES模块的整个流程就结束了。做一个简单的总结,通过以上对模块加载过程的分析,可以了解到:
- 模块代码被编译为字符串,作为eval方法的参数,在模块函数中被执行;
- 每个模块存在一个exports属性,用来保存模块导出的内容,以及标记模块类型和是否为es模块;
- import方法被编译为
__webpack_required__方法,使用该方法加载模块内容;
(2)动态加载ESM
// index.js
import('./dynamic').then((module) => {
console.log(`Dynamic module: ${module.default()}`);
});
由于用到了异步加载模块,所以我们需要启动web服务器,而不只是文件浏览器,这里就简单地使用 http-server 模块启动web服务器。
另外由于index.html和dist目录是同级结构,所以webpack.config.js需要添加output.publicPath属性配置:
module.exports = {
mode: "development",
entry: '../src/index.js',
output: {
publicPath: '/dist/'
}
}
重新执行npm run build 命令编译源码,发现dist目录下出现了2个文件:main.js, 0.js。
查看编译后代码,依然是一个立即执行匿名函数,参数为modules,相比静态加载ES模块的例子,多了一些JSONP相关属性和方法,初始化后的环境变量如下图所示。modules此时只有入口模块,dynamic.js模块当前并未出现。
初始化完成之后,依然先调用
__webpack_require__(__webpack_require__.s = "../src/index.js");加载入口模块,
我们重点要看一下入口模块是如何异步加载dynamic.js模块的,所以将断点打在入口模块的eval函数。
入口模块编译后代码如下:
首先调用
__webpack_require__.e(0)加载chunkId为0的模块,这是加载chunk的关键方法,整理后的e方法如下;
该方法首先通过判断installedChunks对象中是否已经安装过该chunk,如果已经安装过,则返回
Promise.all([])。
目前是首次加载 0 chunk,所以会继续执行加载逻辑。首先创建一个promise对象,将promise对象中的回调函数拼成数组[resolve, reject],分别赋值给 installedChunks[chunkId] 和 installedChunkData,将 promise 对象赋值给 installedChunkData[2],将promise对象 push 进 promises 数组中。
接着创建script标签。资源的src是通过jsonpScriptSrc方法获取的,这个方法简单,但是也是关键的方法,该方法传入chunkId,然后和publicPath进行拼接成最后的请求资源url。
最后设置超时、加载事件回调函数,将script标签添加到head中,浏览器开始加载0.js。
0.js加载完毕,开始执行0.js中的代码,返回的代码内容如下:
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[0], {
"../src/dynamic.js": /*!*************************!*\
!*** ../src/dynamic.js ***!
\*************************/
/*! exports provided: default */
(function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony default export */ __webpack_exports__[\"default\"] = (function() {\n return `This is dynamic module`;\n});\n\n//# sourceURL=webpack:///../src/dynamic.js?");
}
)
}]);
在main.js立即执行函数中,已经初始化了webpackJsonp对象,该变量的值如下:
所以0.js第一行中的push方法,实际调用的是webpackJsonpCallback(data)函数。
该方法具体代码如下:
data参数是一个数组,data[0]表示模块列表对应的chunkId,data[1]为模块列表,当前0.js中包含的模块为"../src/dynamic.js",chunkId为0 。然后将chunkId添加到installedChunks对象中,标记当前chunk已经安装,并将模块列表添加到modules对象中。
最后将data赋值给window["webpackJsonp"],执行__webpack_require__.e(0)函数中创建的chunk的promise对象的resolove方法,chunk加载到此结束。
继续回到入口模块,执行第一个then方法。该方法调用__webpack_require__ 方法安装模块,此方法执行过程在静态加载ESM中已经分析过,这里不再赘述。
__webpack_require__.bind(null, /*! ./dynamic */ "../src/dynamic.js")
执行完__webpack_require__ 方法后,执行入口模块第二个then方法,该方法为入口模块加载完成dynamic.js模块后执行的代码,到此入口模块也执行完毕。
以上demo比较简单,chunk和module之间的关系单一,大家可以增加demo的复杂度,比如一个chunk包含多个模块,一个bundle包含多个chunk的情况等。
动态加载ESM总结:
- 异步加载的模块,webpack会将该模块构建为独立的bundle,并通过创建script标签的方式动态加载;
(3)加载CommonJS模块
修改入口模块代码,加载CommonJS模块:
// index.js
import CMJ from './common-module';
console.log(CMJ());
执行构建命令后,观察main.js编译后的代码,立即执行函数内的代码和静态加载ESM一致,我们直接查看模块编译后代码的差异。
依然将断点打在入口模块eval函数所在行。
入口模块编译后代码为:
common-module.js 编译后代码如下所示:
执行__webpack_require__("../src/common-module.js") 后modules['../src/common-module.js'].exports 属性为该模块导出的函数。
CMJ入口模块和静态加载ESM编译后代码对比,相比ESM,加载CMJ模块,多了一个__webpack_require.n方法的调用。
__webpack_require.n方法代码如下:
该方法返回获取模块导出的方法,CMJ模块则返回 getModuleExports() 方法,该方法直接返回module。
加载CommonJS模块总结:
- CommonJS模块编译后的代码中,不需要调用
__webpack_require__.r(__webpack_exports__);函数; - 安装CommonJS模块时,getter函数将 module.exports 赋值给installedModules[moduleId].exports属性;
- 调用CommonJS模块导出的方法时,需要调用
__webpack_require__.n函数,得到getter函数,通过getter函数得到对应的方法;
二、webpack5 模块加载原理分析
有了webpack4的分析基础,我们再去理解webpack5的模块加载原理就简单很多了。
首先还是准备好demo代码。
// index.js
import CMJ from './common-module';
import module1 from './module1';
console.log(module1());
console.log(CMJ());
import('./dynamic').then(module => {
console.log(module.default());
})
// webpack.config.js
module.exports = {
mode: "development",
entry: '../src/index.js',
output: {
publicPath: '/dist/'
}
}
构建后,webpack5 demo的文件结构如下:
webpack5编译后的入口bundle折叠后如下:
依然是一个立即执行函数,但是不再将模块列表作为参数。该函数内部主要分为了以下部分:
(1)初始化同步加载模块列表
(2)初始化模块加载相关属性和方法
(3)初始化webpack运行时相关的属性和方法
(4)开始加载入口模块
(5)加载模块方法
加载模块方法 __webpack_require__(moduleId)加载模块逻辑基本和webpack4相同,但是简化了module对象:
编译后的入口模块代码如下:
和webpack4 编译后的代码相同。
Common-module.js模块编译后代码,和webpack4相同:
module1.js 编译后代码,和webpack4不同,webpack5中,调用了
__webpack_require__.d方法:
该方法将模块导出变量赋值给module.exports对象,当实际上和webpack4做的事情是一样的。
异步加载模块,则通过__webpack_require__.e方法加载,该方法代码如下:
在初始化webpack运行时相关方法是,
__webpack_require.f 对象,添加了j方法,该方法为异步加载模块方法,与webpack4中的__webpack_require__.e 作用相同,代码如下:
src_dynamic_js.js 加载完成,返回的代码如下:
后续的逻辑和webpack4基本相同。
webpack5模块加载总结:
- 模块加载原理和webpack4基本相同,webpack5主要优化代码编译过程和结果;
- Webpack5 的优化内容,不是本文重点,可以参考此链接进一步了解:webpack.js.org/blog/2020-1…
三、Rollup 模块加载原理分析
Webpack和Rollup有很大的差别,第一个差异就在于Rollup并不像webpack那样提供模块加载机制,而是通过浏览器支持的ESM加载机制活着第三方的AMD、System等模块加载库来实现。
下面,我们在roooolup目录创建demo代码,具体分析一下构建不同模块规范的代码之间的差异。
在rooolup目录下创建index.html文件:
(1)ESM规范
修改rollup.config.js文件,我们首先构建es规范的模块,使用浏览器ESM加载机制:
编译后代码如下:
rollup将同步加载的模块都放在index.js中,异步加载的模块也是构建为独立的文件,然后通过import方法动态加载,这种方式与我们编写源码基本无缝切换。
但是由于Rollup默认不支持非ES模块,所以在rollup.config.js配置中使用了commonjs插件,通过插件编译commonjs模块为esm。由于Rollup将同步加载模块都编译到index.js中了,所以commonjs默认导出的方法,直接编译为index.js中的方法。
如果将commonjs模块改为动态加载,重新编译后,代码如下:
此时,可以看到Rollup将commonjs模块编译成了ES模块。
(2)AMD规范
将rollup.config.js中的output.format改为amd,重新执行npm run build构建代码。
修改index.html如下所示:
构建后代码如下:
Rollup模块加载原理总结:
- Rollup不提供默认的模块加载功能,需要开发者根据构建后代码使用的模块规范,根据实际情况添加特定的模块加载插件;
- 如果选择了 output.format 为 umd 或者 iife,Rollup 默认不支持使用 code-splitting,也就是无法使用异步模块加载机制,编译会报错;
- Rollup相比webpack构建后的代码更为简洁,主要得益于Rollup不提供额外的模块加载逻辑;
四、总结
本文通过分析webpack4/5和Rollup构建产物bundles,了解他们的模块加载机制,webpack实现了自定义的模块加载机制,但是Rollup不提供自定义模块加载机制。了解模块的加载机制,有助于我们理解如何实现模块懒加载、如何优化chunk等方面的问题,也为我们在选择构建工具时,提供思考的一个方向。