从零实现一个简易模块打包器

Posted by Neil Ning on 2024-12-27
学习

前言

在前端开发中,我们经常会使用到模块化开发,但是浏览器对模块化支持很不好,所以我们一般需要Webpack这类打包工具将我们的模块打包成浏览器可以识别的代码。我们通过实现一个简易的模块打包器,来学习模块打包器的基本原理。

依赖分析

打包器一般需要一个入口文件,从入口文件开始通过递归的形式分析模块的相互依赖关系,这个依赖关系被称之为依赖图。我们首先创建一个辅助函数,他能收集某个文件的依赖模块,并将该文件转换为ES5代码,代码如下:

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
// src/minipack.js

const fs = require('fs');
const path = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const { transformFromAst } = require('@babel/core');

let ID = 0;

function createAsset(filename) {
// 读取文件内容
const content = fs.readFileSync(filename, 'utf-8');

// 调用babel编译器,将代码解析成AST
const ast = parser.parse(content, {
sourceType: 'module'
});

// 使用@babel/traverse遍历AST的import语句收集该文件依赖
const dependencies = [];
traverse(ast, {
ImportDeclaration: (path) => {
dependencies.push(path.node.source.value);
}
});

// 收集完依赖之后,还需要将AST转换为浏览器兼容的ES5代码
const code = transformFromAst(ast, null, {
presets: ['@babel/preset-env']
}).code;

const id = ID++;
return {
id, // 模块id
filename, // 文件的绝对路径
dependencies, // 文件的依赖
code // ES5代码
};

}

在demo文件夹创建三个文件来测试打包效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// main.js
import { add } from './math.js';
import { print } from './utils.js';

function main() {
const sum = add(123, 2344);
print(sum);
}

main();

// math.js
export const add = (a, b) => a + b;

// utils.js
export function print(value) {
console.log(value);
}

然后在minipack.js中添加如下代码:

1
2
const asset = createAsset('./demo/main.js');
console.log(asset);

执行node ./src/minipack.js可以看到测试效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
id: 0,
filename: './demo/main.js',
dependencies: [ './math.js', './utils.js' ],
code: '"use strict";\n' +
'\n' +
'var _math = require("./math.js");\n' +
'var _utils = require("./utils.js");\n' +
'function main() {\n' +
' var sum = (0, _math.add)(123, 2344);\n' +
' (0, _utils.print)(sum);\n' +
'}\n' +
'main();'
}

依赖图

辅助函数完成之后,紧接着需要创建一个函数,从入口文件开始,递归的分析所有的依赖,并调用createAsset函数为每个文件创建模块对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 从入口文件开始
function createGraph(entry) {
// 首先分析入口文件
const mainAsset = createAsset(entry);

// 使用队列,进行广度搜索优先的方式遍历依赖树
const queue = [mainAsset];
for (const asset of queue) {
const dirname = path.dirname(asset.filename);
// 记录每个依赖相对路径和id的映射
asset.mapping = {};

asset.dependencies.forEach(relativePath => {
const absolutePath = path.join(dirname, relativePath);
// 分析依赖的依赖
const child = createAsset(absolutePath);
asset.mapping[relativePath] = child.id;
queue.push(child);
});
}
return queue;
}

此时添加如下测试代码:

1
2
const graph = createGraph('./demo/main.js');
console.log(graph)

执行node ./src/minipack.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
40
41
42
43
44
45
46
[
{
id: 0,
filename: '/xxx/minipack/demo/main.js',
dependencies: [ './math.js', './utils.js' ],
code: '"use strict";\n' +
'\n' +
'var _math = require("./math.js");\n' +
'var _utils = require("./utils.js");\n' +
'function main() {\n' +
' (0, _utils.print)((0, _math.add)(123, 2344));\n' +
'}\n' +
'main();',
mapping: { './math.js': 1, './utils.js': 2 }
},
{
id: 1,
filename: '/xxx/minipack/demo/math.js',
dependencies: [],
code: '"use strict";\n' +
'\n' +
'Object.defineProperty(exports, "__esModule", {\n' +
' value: true\n' +
'});\n' +
'exports.add = void 0;\n' +
'var add = exports.add = function add(a, b) {\n' +
' return a + b;\n' +
'};',
mapping: {}
},
{
id: 2,
filename: '/xxx/minipack/demo/utils.js',
dependencies: [],
code: '"use strict";\n' +
'\n' +
'Object.defineProperty(exports, "__esModule", {\n' +
' value: true\n' +
'});\n' +
'exports.print = print;\n' +
'function print(value) {\n' +
' console.log(value);\n' +
'}',
mapping: {}
}
]

模块加载

接下来需要创建一个函数,将依赖图转换为浏览器可执行的代码,函数的输入是上一步得到的依赖图,输出的是代码打包后的代码字符串。

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
function bundle(graph) {
let modules = '';
// 遍历graph数组中的所有模块,所有的模块最终会组成一个对象,对象的key是模块的id,value是一个数组,
// 数组的第一个元素是模块的代码,代码会被放入到一个函数作用域中,这样每个模块内定义的变量就不会影响其他模块,
// 另外观察上一步每个模块的输出,可以看到代码被编译成ES5之后,里边的import语句都被转换成require语句,
// 浏览器是不支持require语句的,所以函数的入参有require函数,还有module和exports对象,他们会被导出到模块外部。
// 第二个元素是模块的依赖。
graph.forEach(mod => {
modules += `${mod.id}: [
function (require, module, exports) {
${mod.code}
},
${JSON.stringify(mod.mapping)}
],`;
});

// 最后返回一个自执行函数,函数的入参是上面组装好的对象,函数内部先定义了一个require函数,从调用require(0)来启动整个程序
const result = `
(function(modules) {
function require(id) {
const [fn, mapping] = modules[id];
function localRequire(name) {
return require(mapping[name]);
}
const module = { exports: {} };
fn(localRequire, module, module.exports);
return module.exports;
}
require(0);
})({${modules}})
`;

return result;
}

添加测试代码:

1
2
3
const graph = createGraph('./demo/main.js');
const bundleCode = bundle(graph);
fs.writeFileSync('bundle.js', bundleCode);

再次执行node ./src/minipack.js,可以看到在项目根目录下生成了一个bundle.js文件,就是我们打包后的文件。

为了理解为什么代码后的代码能在浏览器中执行,我们将关键require函数提取出来看下它的执行流程:

1
2
3
4
5
6
7
8
9
10
11
function require(id) {
// 首先获取入口文件的对象
const [fn, mapping] = modules[id];
function localRequire(name) {
return require(mapping[name]);
}
const module = { exports: {} };
// 调用函数,将module.exports对象传入,模块内部会将需要导出的变量都挂载在module.exports上
fn(localRequire, module, module.exports);
return module.exports;
}

另外math.js模块的fn的形式如下:

1
2
3
4
5
6
7
8
9
10
11
function (require, module, exports) {
"use strict";

Object.defineProperty(exports, "__esModule", {
value: true
});
exports.add = void 0;
var add = exports.add = function add(a, b) {
return a + b;
};
}

该函数的第一个参数require函数的实现是一个递归调用,通过mapping对象来找到依赖模块的id,然后调用require函数加载依赖模块

1
2
3
4
function localRequire(name) {
// mapping[name]就是模块id
return require(mapping[name]);
}

函数的第二个参数module是一个包含exports对象的对象,模块内部的变量都挂载在module.exports上

1
const module = { exports: {} };

所以为了理解这个函数的执行流程,以上面的三个文件为例,其中main.js和math.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
// main.js
function (require, module, exports) {
"use strict";
var _math = require("./math.js");
var _utils = require("./utils.js");
function main() {
var sum = (0, _math.add)(123, 2344);
(0, _utils.print)(sum);
}
main();
}

// math.js
function (require, module, exports) {
"use strict";

Object.defineProperty(exports, "__esModule", {
value: true
});
exports.add = void 0;
var add = exports.add = function add(a, b) {
return a + b;
};
}

require函数返回一个module.exports对象,math.js导出的add函数被挂载到module.exports对象,同时exports对象还有一个__esModule属性,这个属性是为了兼容ES6模块的导出规范,utils.js也是类似的。

其他

以上代码在输出时,注意到源码的函数调用经历了如下转换:

1
2
3
4
5
6
7
// main.js
const sum = add(123, 2344);
print(sum);

// 打包后的代码如下
var sum = (0, _math.add)(123, 2344);
(0, _utils.print)(sum);

所以(0, _math.add)(123, 2344)这种语法是什么意思?

首先(0, _math.add)是一个逗号表达式,它的形式如下(value1, value2, ....valueX),这个表达式的返回值是最后一个值,所以(0, _math.add)的返回值其实就是_math.add,而后面的括号相当于add函数的参数。这种写法本质就是调用了add函数,前边的0只是一个占位符,无实际意义。那babel将函数调用转换成这种语法呢?

我们打包后的math模块被赋值给了一个_math变量,然后通过_math.add来调用add函数,如果我们直接使用_math.add(123, 2344)的形式调用,add函数内的this变量会指向_math对象,这样的this指向是错误的,而通过逗号操作符和()的形式调用,add函数内的this会指向global对象,或者在严格模式下是undefined,这样就避免了this指向错误的问题。
如下代码可以验证这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
var foo = { 
fullName: "Peter",
sayName: function() { console.log("My name is", this.fullName); }
};

window.fullName = "Shiny";

foo.sayName(); // My name is Peter

(foo.sayName)(); // My name is Peter

(0, foo.sayName)(); // My name is Shiny

总结

到这里我们已经实现了一个简易的模块打包器,我们通过递归的方式分析模块的依赖关系,然后将依赖图转换为浏览器可执行的代码。这个打包器还有很多不足之处,比如没有处理循环依赖,没有处理异步加载等等,但是通过这个简单的实现,我们可以了解模块打包器的基本原理。

参考资料