0%

解析 webpack 源码

虽然 Webpack 看上去无所不能,但从其本质上来说,Webpack 实质就是一个“前端模块打包器”。前端模块打包器做的事情很简单:它帮助开发者将 JavaScript 模块(各种类型的模块化规范)打包为一个或多个 JavaScript 脚本文件。

回到最初起源:前端为什么需要一个模块打包器呢?其实理由很简单:

  • 不是所有浏览器都直接支持javascript
  • 前端需要管理依赖脚本,把控不同脚本加载的顺序
  • 前端需要按需加载不同类型的静态资源 想想一下,我们的 Web应用有这样一段内容:
1
2
3
4
5
6
7
8
<html>
<script src="/src/1.js"></script>
<script src="/src/2.js"></script>
<script src="/src/3.js"></script>
<script src="/src/4.js"></script>
<script src="/src/5.js"></script>
<script src="/src/6.js"></script>
</html>

每个javascript文件都需要额外的HTTP请求获取,并且因为依赖关系,1.js到6.js需要按顺序加载。因此,打包需求应运而生:

1
2
3
<html>
<script src="/dist/bundle.js"></script>
</html>

这里需要注意几点:

  • 随着HTTP/2技术的推广,未来长远上看,浏览器像上述代码一样发送多个请求不再是性能瓶颈,但目前来看还过于乐观
  • 并不是将所有脚本都打包在一起就是性能最优,/dist/bundle.js 的size一般较大,但这属于另外的 性能优化话题了

总之,打包器的需求就是前端“刚需”,实现上述打包需要也并不简单,需要考虑:

  • 如何维护不同脚本的打包顺序,保证bundle.js的可用性
  • 如何避免不同脚本、不同模块的命名冲突
  • 在打包过程中,如何确定真正需要的脚本,而不将没有用到的脚本排除在 bundle.js 之外

事实上,虽然当前 webpack 依靠loader机制实现了对于不同类型资源的解析和打包,依靠插件机制实现了第三方介入编译构建的过程,但究其本质,webpack只是一个“无所不能”的打包器,实现了:

1
a.js + b.js + c.js. => bundle.js

为了简化,以ESM模块化规范举例。假设我们有:

  • circle.js模块求圆形面积
  • square.js模块求正方形面积
  • app.js模块作为主模块

对应内容分别如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
const PI = 3.141;
export default function area(radius) {
return PI * radius * radius;
}
// filename: square.js
export default function area(side) {
return side * side;
}
// filename: app.js
import squareArea from './square';
import circleArea from './circle';
console.log('Area of square: ', squareArea(5));
console.log('Area of circle', circleArea(5));

经过 Webpack 打包之后,我们用 bundle.js 来表示 Webpack 处理结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// filename: bundle.js
const modules = {
'circle.js': function(exports, require) {
const PI = 3.141;
exports.default = function area(radius) {
return PI * radius * radius;
}
},
'square.js': function(exports, require) {
exports.default = function area(side) {
return side * side;
}
},
'app.js': function(exports, require) {
const squareArea = require('square.js').default;
const circleArea = require('circle.js').default;
console.log('Area of square: ', squareArea(5))
console.log('Area of circle', circleArea(5))
}
}
webpackBundle({
modules,
entry: 'app.js'
});

如上代码,我们维护了 module 变量,存储了不同模块信息,这个map中,key为模块路径名,value为一个被 wrapped 过的模块函数,我们先称之为 module factory function , 该函数形如:

1
2
3
function(exports, require) {
// 模块内容
}

这样做是为每个模块提供exports和require能力,同时保证了每个模块都处于一个隔离的函数作用域范围。

有了modules变量还不够,我们依赖webpackBundle方法,将所有内容整合在一起。webpackBundle方法接收modules模块信息以及一个入口脚本。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function webpackBundle({ modules, entry }) {
const moduleCache = {};

const require = moduleName => {
// 如果已经解析并缓存过,直接返回缓存内容
if (moduleCache[moduleName]) {
return moduleCache[moduleName];
}

const exports = {};
// 这里是为了防止循环引用
moduleCache[moduleName] = exports;
// 执行模块内容,如果遇见了 require 方法,则继续递归执行 require 方法
modules[moduleName](exports, require);

return moduleCache[moduleName];
};
require(entry);
}

手动实现打包器

核心思路如下:

  1. 读取入口文件 (比如entry.js)
  2. 基于AST分析入口文件,并产出依赖列表
  3. 使用Babel将相关模块编译到ES5
  4. 对每个依赖模块产出一个唯一的ID,方便后续读取模块相关内容
  5. 将每个依赖以及经过的Babel编译过后的内容,存储在一个对象中维护
  6. 遍历上一步中的对象,构建出一个依赖图
  7. 将各模块内容bundle产出

首先创建项目:

1
mkdir bundler-playground && cd $_

并启动npm

1
npm init -y

安装以下依赖:

  • @babel/parser用于分析源代码,产出 AST;
  • @babel/traverse用于遍历 AST,找到 import 声明;
  • @babel/core用于编译,将源代码编译为 ES5;
  • @babel/preset-env搭配@babel/core使用;
  • resolve用于获取依赖的绝对路径。