从零实现一个 JS 模块打包器

2019 年的前端技术栈,无论你是用 Vue 还是用 React ,应该都离不开一样工具 – webpack。webpack 极大的简化了前端开发的构建过程,只需提供一个入口文件,webpack 就能自动帮我们分析出相关依赖,构建出 bundle 包。

webpack 很强大,但是大家知道 webpack 到底是怎么样打包的吗?本文将从一个很简单的例子,一步步带领大家探寻 webpack 打包的基本原理。

一、准备阶段

本文的代码详见webpack-mini step1
在本文中,我们不考虑任何优化操作,尽可能的保持代码最简单。

假设我们的项目目录及文件列表如下,我们将使用命令 node webpack-mini/index.jssrc 下的代码打包成一个 bundle.js

1
2
3
4
5
6
7
8
├── dist             // 打包出来 bundle 文件所在目录
│   ├── bundle.js
├── src // 业务代码目录
│   ├── bar.js
│   ├── foo.js
│   └── index.js // 入口文件
└── webpack-mini // mini 打包器代码目录
└── index.js
1
2
3
4
5
6
// src/index.js
import foo from './foo.js';
import { say } from './bar.js';

foo();
say();
1
2
3
4
// src/foo.js
export default function() {
console.log('foo: Hello I am foo!');
}
1
2
3
4
// src/bar.js
export const say = function() {
console.log('bar.say: Hello I am bar!');
}

二、分析结构

先来看入口文件, src/index.js 通过 ES6 的 import 语法引入了 foo 的 default 方法和 bar 的 say 方法。

如何解析 index.js 、foo.js、bar.js 之间的依赖关系呢?字符串查找?不行,这样要考虑的边界情况太多。正则?也不行,同样很复杂。那么正统的处理方式是什么?语法解析。

在这个 DEMO 中,我们使用 babel 来解析 JS 文件,生成抽象语法树。先看下 src/index.js 中的 import foo from './foo'; 通过在线语法解析网站 axtexplorer.net 生成的 json 结构(省略了部分无关节点信息)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
{
"type": "File",
"start": 0,
"end": 24,
"loc": {},
"program": {
"type": "Program",
"start": 0,
"end": 24,
"loc": {},
"sourceType": "module",
"body": [
{
"type": "ImportDeclaration", // 划重点
"start": 0,
"end": 24,
"loc": {},
"specifiers": [
{
"type": "ImportDefaultSpecifier", // 划重点
"start": 7,
"end": 10,
"loc": {},
"local": {
"type": "Identifier",
"start": 7,
"end": 10,
"loc": {},
"name": "foo"
}
}
],
"importKind": "value",
"source": {
"type": "Literal",
"start": 16,
"end": 23,
"loc": {},
"value": "./foo", // 划重点
"rawValue": "./foo",
"raw": "'./foo'"
}
}
]
},
"comments": [],
"tokens": []
}

我们的解析工具识别出了这是一个 import 声明(ImportDeclaration),并且是一个默认的声明(ImportDefaultSpecifier),声明标识符(Identifier)为 foo,引用的资源路径为 ./foo。解析工具帮我们把字符串代码转换为了结构化的对象,有了结构化的对象,我们就能进行下一步了。

三、生成依赖图

依赖解析

想要打包 JS 模块,先要拿到 JS 的依赖,上图就是生成依赖图的流程图。下面我们看下具体代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const path = require('path');
const { transformFileSync } = require('@babel/core');

/**
* 读取资源文件,修改 import 语句,ES6 import/export 语法转换成 require/exports 的形式,并生成依赖图
* @param {string} filePath - 资源文件路径
*/
function createGraph(filePath) {
if (!path.isAbsolute(filePath)) filePath = path.resolve(__dirname, filePath);
const dirPath = path.dirname(filePath);

const dependencies = [];

const visitor = {
// 我们要修改的节点是 import 声明节点。
ImportDeclaration({ node }) {
// 递归遍历 import 引用的资源文件,将相对路径转换为绝对路径,作为对应模块的 key
node.source.value = path.resolve(dirPath, node.source.value);
dependencies.push(createGraph(node.source.value))
}
};

const { code } = transformFileSync(filePath, {
presets: ['@babel/env'],
plugins: [
{
// babel 提供的访问者模式,详细解释可参考下文
// https://daweilv.com/2018/07/21/教练我想写一个-helloworld-Babel-插件/
visitor
}
]
});

return {
filePath,
code,
dependencies
}
}

这里需要说明的一点是,由于 ES6 import 的语法支持程度还很低,并且需要特殊的加载方式( <script type="module"> ),另外,我们后面还要兼容 commonjs 的 module.exports/exports 语法,所以我们需要用 @babel/env 转换一下代码。分别看下 src/index.jssrc/foo.js 转换后的样子,下一步我们将使用转换后的代码,在 exports 上做文章。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/index.js
"use strict";

var _foo = _interopRequireDefault(require("/Users/david/project/webpack-mini/src/foo.js"));

var _bar = require("/Users/david/project/webpack-mini/src/bar.js");

// 这个方法是为了统一 commonjs 和 es module 的 default 语法
// Modules in Common JS :
// module.exports = function () {};
//
// Modules in ES6 :
// export default function () {}
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }

(0, _foo["default"])();

// 这种语法与 Object(_bar.say)() 实现效果相同,使得 _bar.say 作为函数调用
// 而不是作为 _bar 的 say 方法,这与 es6 module 的 export 行为一致
(0, _bar.say)();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/foo.js
"use strict";

Object.defineProperty(exports, "__esModule", {
value: true
});

// export default function () {}
// default function 被挂载到了 exports 上
exports["default"] = _default;

function _default() {
console.log('foo: Hello I am foo!');
}

四、生成 bundle 文件

拿到依赖图后,我们就要开始组装 bundle 包了。

先遍历一遍依赖图,将依赖图转为数组,方便下一步生成入口文件的时候使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 递归遍历,将依赖图展开成平级的数组
* @param {object} graph - 依赖图
* @param {array} modules - 展开后的数组
*/
function flattenGraph(graph, modules) {
if (!modules) modules = [];
// 这里将文件的绝对路径作为 module 的id
modules.push({ id: graph.filePath, code: graph.code })
if (graph.dependencies.length) {
graph.dependencies.forEach(o => {
flattenGraph(o, modules)
})
}
return modules
}

拿到模块数组后,开始拼接代码块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 生成入口文件,拼接模块数组
* @param {array} modules 模块数组
*/
function createBundle(modules) {
return `(function (modules) {
function require(moduleId) {
let exports = {};
modules[moduleId](exports, require);
return exports;
}
require("${modules[0].id}")
})({${modules.map(module =>
(`"${module.id}":${generateModuleTemplate(module.code)}`)
)}})`
}

上述代码我们可以分两块看下,最外层实际上就是一个立即执行函数表达式,将 modulesTemplateObject 对象传给了 modules。

1
2
3
4
((function (modules) {
// 这里的 modules 就是最下面传过来的 kv 对象
// ...
})(modulesTemplateObject)}

再看下 generateModuleTemplate 做了什么,

1
2
3
4
5
6
7
function generateModuleTemplate(code) {
// 把每个 JS 文件中的代码块包在一个 function 里
// 将外部的 exports 和 require 传入 function 内
return `function (exports, require) {
${code}
}`
}

接着重头戏来了,我们本篇文章的核心代码,

1
2
3
4
5
6
7
8
function require(moduleId) {
let exports = {};
// 这里用 Object 包裹了 module,使得 module 作为值调用
modules[moduleId](exports, require);
return exports;
}
// 执行第一个 module,也就是 src/index.js
require("${modules[0].id}")

接着我们将整个过程串起来,

1
2
3
4
5
6
7
8
9
10
11
12
function webpackMini (fileEntry) {
const graph = createGraph(fileEntry)
// console.log(graph);
const modules = flattenGraph(graph)
// console.log(modules);
const bundle = createBundle(modules)
// console.log(bundle);
// 生成文件方法就不在此赘述了
generateFile(bundle)
}

webpackMini('../src/index.js')

执行程序,看看在 dist 目录下是不是得到了 bundle.js。运行 bundle.js ,得到输出:

1
2
foo: Hello I am foo!
bar.say: Hello I am bar!

至此,一个最简单的 JS 模块打包器就完成了!

总结

回顾一下,我们先是通过 babel 将 JS 字符串转换成了可供分析的语法结构树,然后遍历得到模块间的依赖关系,最后将依赖关系通过我们自己实现的 require 方法加载进来,这样就实现了一个最简单的 JS 模块打包器。

bundle

后续我们将继续完善代码,诸如打包 commonjs 代码、组件加载缓存、处理组件循环调用等功能。本文的代码保存在 webpack-mini,欢迎 star 关注。

评论