分析Webpack、Rollup bundle的模块加载机制

1,529 阅读11分钟

本文直接上手分析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模式下生成的代码会有很多格式化用的注释,如下图所示:

image-20210319233956543.png

为了方便查看编译后的代码,先清理一下注释。将代码进行折叠后,先看整体结构:

image-20210319234356267.png 主体结构就是一个立即执行匿名函数—— function(modules) 。

展开执行函数的参数传的值,为一个以模块路径为Key,以一个使用eval执行代码的函数作为value的对象:

image-20210319234825717.png 展开匿名函数,查看函数内的代码,基本逻辑如下图所示:

image-20210319235005518.png 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。

image-20210320002103328.png 代码执行到断点处,首先会判断模块是否存在于缓存对象中。

image-20210320002335160.png 加载入口模块时,此时installedModules对象为空,所以直接跳过if里的逻辑,执行到 Line 11,创建一个模块对象,并用moduleId作为installedModules对象的key,存放到installedModules对象中,并赋值给module变量,该模块对象包含三个属性,i 属性为 模块id,l 设置为false,exports 属性为一个空对象{}。

image-20210320002625434.png 接着代码执行到Line 17,通过 modules[moduleId] 获取模块函数,将module, module.exports, __webpack_require__ 作为参数传入函数中,执行eval方法。

image-20210320003521452.png 点击“Step”按钮,查看eval的代码如下:

image-20210320003818360.png 模块代码的第一行,调用了 r 方法,现在我们看一下 r 方法要做什么事情,代码如下图所示。该方法的作用,是初始化模块对象的exports属性的值,创建module对象时,exports时空对象{}, 该方法给exports定义了两个属性:

  • Symbol.toStringTag:exports的类型;
  • __esModule: 是否为esm;

image-20210320004145940.png 代码继续执行,因为入口文件中引入了module1模块,所以接着调用了__webpack_require__ 函数,加载../src/module1.js模块,又开始了加载模块的逻辑。

模块module1中的eval的代码如下图所示,因为是ES模块,所以也执行了 r 方法初始化exports对象属性,接着添加了default属性到exports对象上,将模块默认导出的代码赋值给了exports.default属性。

image-20210320004901654.png

然后module1模块代码执行结束,继续 __webpack_require__ 函数的逻辑,最后返回模块的exports对象。

image-20210320005643933.png 代码继续执行,回到入口模块逻辑,此时将module1模块返回exports对象,赋值给_module1__WEBPACK_IMPORTED_MODULE_0__ 变量,继续执行入口模块代码,则入口模块函数执行完毕。

image-20210320010057618.png 继续执行入口模块的加载逻辑,设置加载状态为true,返回入口模块exports属性:

image-20210320010309730.png 以上,静态加载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。

image-20210320175520400.png 查看编译后代码,依然是一个立即执行匿名函数,参数为modules,相比静态加载ES模块的例子,多了一些JSONP相关属性和方法,初始化后的环境变量如下图所示。modules此时只有入口模块,dynamic.js模块当前并未出现。

image-20210320183841030.png 初始化完成之后,依然先调用__webpack_require__(__webpack_require__.s = "../src/index.js");加载入口模块,

我们重点要看一下入口模块是如何异步加载dynamic.js模块的,所以将断点打在入口模块的eval函数。

image-20210320184218345.png 入口模块编译后代码如下:

image-20210320184247625.png 首先调用__webpack_require__.e(0)加载chunkId为0的模块,这是加载chunk的关键方法,整理后的e方法如下;

image-20210320184546174.png 该方法首先通过判断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。

image-20210320185031830.png 最后设置超时、加载事件回调函数,将script标签添加到head中,浏览器开始加载0.js。

image-20210320173246213.png 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对象,该变量的值如下:

image-20210320190344106.png 所以0.js第一行中的push方法,实际调用的是webpackJsonpCallback(data)函数。

该方法具体代码如下:

image-20210320190619756.png 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模块后执行的代码,到此入口模块也执行完毕。

image-20210320175058891.png

以上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函数所在行。

image-20210320204649897.png 入口模块编译后代码为:

image-20210320204715947.png common-module.js 编译后代码如下所示:

image-20210320205717215.png

执行__webpack_require__("../src/common-module.js") 后modules['../src/common-module.js'].exports 属性为该模块导出的函数。

CMJ入口模块和静态加载ESM编译后代码对比,相比ESM,加载CMJ模块,多了一个__webpack_require.n方法的调用。

image-20210320003818360.png __webpack_require.n方法代码如下:

image-20210320205101678.png 该方法返回获取模块导出的方法,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的文件结构如下:

image-20210320213844519.png webpack5编译后的入口bundle折叠后如下:

image-20210320214518056.png 依然是一个立即执行函数,但是不再将模块列表作为参数。该函数内部主要分为了以下部分:

(1)初始化同步加载模块列表

image-20210320214713410.png

(2)初始化模块加载相关属性和方法

image-20210320214907441.png

(3)初始化webpack运行时相关的属性和方法

image-20210320215146551.png

image-20210320215226529.png

(4)开始加载入口模块

image-20210320215250859.png

(5)加载模块方法

加载模块方法 __webpack_require__(moduleId)加载模块逻辑基本和webpack4相同,但是简化了module对象:

image-20210320221041489.png 编译后的入口模块代码如下:

image-20210320221327172.png 和webpack4 编译后的代码相同。

Common-module.js模块编译后代码,和webpack4相同:

image-20210320221458611.png module1.js 编译后代码,和webpack4不同,webpack5中,调用了__webpack_require__.d方法:

image-20210320221606198.png

image-20210320221835369.png 该方法将模块导出变量赋值给module.exports对象,当实际上和webpack4做的事情是一样的。

异步加载模块,则通过__webpack_require__.e方法加载,该方法代码如下:

image-20210320222359536.png 在初始化webpack运行时相关方法是,__webpack_require.f 对象,添加了j方法,该方法为异步加载模块方法,与webpack4中的__webpack_require__.e 作用相同,代码如下:

image-20210320222937071.png src_dynamic_js.js 加载完成,返回的代码如下:

image-20210320223443826.png 后续的逻辑和webpack4基本相同。

webpack5模块加载总结:

  • 模块加载原理和webpack4基本相同,webpack5主要优化代码编译过程和结果;
  • Webpack5 的优化内容,不是本文重点,可以参考此链接进一步了解:webpack.js.org/blog/2020-1…

三、Rollup 模块加载原理分析

Webpack和Rollup有很大的差别,第一个差异就在于Rollup并不像webpack那样提供模块加载机制,而是通过浏览器支持的ESM加载机制活着第三方的AMD、System等模块加载库来实现。

下面,我们在roooolup目录创建demo代码,具体分析一下构建不同模块规范的代码之间的差异。

在rooolup目录下创建index.html文件:

image-20210320224746951.png

(1)ESM规范

修改rollup.config.js文件,我们首先构建es规范的模块,使用浏览器ESM加载机制:

image-20210320231054295.png 编译后代码如下:

image-20210320225016750.png

image-20210320225024888.png rollup将同步加载的模块都放在index.js中,异步加载的模块也是构建为独立的文件,然后通过import方法动态加载,这种方式与我们编写源码基本无缝切换。

但是由于Rollup默认不支持非ES模块,所以在rollup.config.js配置中使用了commonjs插件,通过插件编译commonjs模块为esm。由于Rollup将同步加载模块都编译到index.js中了,所以commonjs默认导出的方法,直接编译为index.js中的方法。

如果将commonjs模块改为动态加载,重新编译后,代码如下:

image-20210320225618739.png

image-20210320225630923.png

image-20210320225735443.png 此时,可以看到Rollup将commonjs模块编译成了ES模块。

(2)AMD规范

将rollup.config.js中的output.format改为amd,重新执行npm run build构建代码。

修改index.html如下所示:

image-20210320231601356.png 构建后代码如下:

image-20210320231631565.png

image-20210320231649539.png

image-20210320231720583.png Rollup模块加载原理总结:

  • Rollup不提供默认的模块加载功能,需要开发者根据构建后代码使用的模块规范,根据实际情况添加特定的模块加载插件;
  • 如果选择了 output.format 为 umd 或者 iife,Rollup 默认不支持使用 code-splitting,也就是无法使用异步模块加载机制,编译会报错;
  • Rollup相比webpack构建后的代码更为简洁,主要得益于Rollup不提供额外的模块加载逻辑;

四、总结

本文通过分析webpack4/5和Rollup构建产物bundles,了解他们的模块加载机制,webpack实现了自定义的模块加载机制,但是Rollup不提供自定义模块加载机制。了解模块的加载机制,有助于我们理解如何实现模块懒加载、如何优化chunk等方面的问题,也为我们在选择构建工具时,提供思考的一个方向。