手写一个Webpack明白打包原理

AST

为什么要谈AST(抽象语法树)?

如果你查看目前任何主流的项目中的devDependencies,会发现前些年的不计其数的插件诞生。我们归纳一下有:javascript转译、代码压缩、css预处理器、elint、pretiier,Tree-Sharking等。有很多js模块我们不会在生产环境用到,但是它们在我们的开发过程中充当着重要的角色。

所有的上述工具,不管怎样,都建立在了AST这个巨人的肩膀上

image

  • 代码风格,语法的检查,IDE中的错误提示,格式化,自动补全等等
  • 优化变更代码,代码压缩,Tree-Sharking等等
  • es6转es5,以及TypeScript、JSX等转化为原生Javascript等等

整体思路

写个简单的有引入的文件,然后npx webpack打包一下看下打包出来的内容是什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
(function (modules) { 
var installedModules = {};
function __webpack_require__(moduleId) {
//....太长了 省略一些代码
//这边就是webpack自己实现的一个require方法
return module.exports;
}
//"./src/index.js"入口文件
return __webpack_require__(__webpack_require__.s = "./src/index.js");
})
//传入的模块依赖关系
({
"./src/a.js": (function (module, exports, __webpack_require__) {
eval("var b = __webpack_require__(\"./src/b.js\");\n\nvar a = 'a' + b;\nmodule.exports = a;\n\n//# sourceURL=webpack:///./src/a.js?");
}),
"./src/b.js": (function (module, exports) {
eval("b = 'b';\nmodule.exports = b\n\n//# sourceURL=webpack:///./src/b.js?");
}),
"./src/index.js": (function (module, exports, __webpack_require__) {
eval("var a = __webpack_require__(\"./src/a.js\");\n\nconsole.log(a)\n\n//# sourceURL=webpack:///./src/index.js?");
})
});

2

AST解析语法树

这一步就是parse的内容,解析读取到的模块的内容。

  • 替换require,替换依赖的路径,把修改后的模板放进sourceCode
  • 把依赖放进dependencies数组中

用到了几个库来做这件事:

  • babylon 主要是把源码解析成AST
  • @babel/traverse 遍历节点(遍历到对应的节点)以深度优先的形式遍历AST,并进行修改和转化
  • @babel/types 替换遍历到的节点
  • @babel/generator 替换好的结果生成,根据AST生成新的代码
  • (traverse和generator是es6模块 引用的时候要require(‘@babel/traverse’).default 不然默认导出的是一个对象)
  • @babel/core Babel核心库,被很多babel配套设施依赖,用于加载 preset 和 plugin

Babel转化的核心链路:原始代码-原始AST -转化后的AST-转化后的代码

在线生成AST

可以在这个网站直观的查看AST节点: https://astexplorer.net/

实现

由于ES6转ES5中需要用到babel,所以要用到一下插件

1
cnpm i @babel/core @babel/parser @babel/traverse @babel/preset-env -D

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"name": "FC_webpack",
"version": "1.0.0",
"description": "a simple webpack",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.1.2",
"@babel/highlight": "^7.0.0",
"@babel/parser": "^7.1.3",
"@babel/preset-env": "^7.1.0",
"@babel/traverse": "^7.1.4"
},
"dependencies": {
"js-beautify": "^1.6.14"
}
}

需要的文件

使用webpack肯定少不了原文件,我们会涉及三个需要打包的js文件(entry.jsmessage.jsname.js

1
2
3
4
// entry.js

import message from './message.js';
console.log(message);

1
2
3
4
// message.js

import {name} from './name.js';
export default `hello ${name}!`;
1
2
3
// name.js

export const name = 'world';
1
2
3
4
5
6
7
//bundler.js 
// 读取文件信息,并获得当前js文件的依赖关系
function createAsset(filename) {//代码略}
// 从入口开始分析所有依赖项,形成依赖图,采用广度遍历
function createGraph(entry) {//代码略}
// 根据生成的依赖关系图,生成浏览器可执行文件
function bundle(graph) {//代码略}

entry.js 就是我们的入口文件,文件的依赖关系是,entry.js依赖message.jsmessage.js依赖name.js

bundler.js 是我们简易版的webpack

目录结构

1
2
3
4
5
- example
- entry.js
- message.js
- name.js
- bundler.js

如何分析依赖

webpack分析依赖是从一个入口文件开始分析的,当我们把一个入口的文件路径传入,webpack就会通过这个文件的路径读取文件的信息(读取到的本质其实是字符串),然后把读取到的信息转成AST(抽象语法树),简单点来说呢,就是把一个js文件里面的内容存到某种数据结构里,里面包括了各种信息,其中就有当前模块依赖了哪些模块。我们暂时把通过传文件路径能返回文件信息的这个函数叫 createAsset

createAsset返回什么

第一步我们肯定需要先从 entry.js 开始分析,于是就有了如下的代码,我们先不关心createAsset具体代码是怎么实现的,具体代码我会放在最后。

1
createAsset("./example/entry.js");

当执行这句代码,createAsset 会返回下面的数据结构,这里包括了模块的id文件路径依赖数组entry.js依赖了message.js,所以会返回依赖的文件名),code(这个就是entry.js ES6转ES5的代码)
image
通过 createAsset 我们成功拿到了entry.js的依赖,就是 dependencies 数组。

createGraph返回什么,如何找下一个依赖

我们通过上面可以拿到entry.js依赖的模块,于是我们就可以接着去遍历dependencies 数组,循环调用createAsset这样就可以得到全部模块相互依赖的信息。想得到全部依赖信息需要调用 createGraph 这个一个函数,它会进行广度遍历,最终返回下面的数据

image

我们可以看到返回的数据,字段之前都和大家解释了,除了 mappingmapping这个字段是把当前模块依赖的文件名称 和 模块的id 做一个映射,目的是为了更方便查找模块。

bundle返回什么 && 最后步骤

我们现在已经能拿到每个模块之前的依赖关系,我们再通过调用bundle函数,我们就能构造出最后的bundle.js,输出如下图

image

源码

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
const fs = require("fs");
const path = require("path");
const babylon = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const babel = require("@babel/core");


let ID = 0;
//读取文件信息,并获得当前js文件的依赖关系
function createAsset(filename) {
//获取文件,返回值是字符串
const content = fs.readFileSync(filename, "utf-8");

//讲字符串为ast(抽象语法树, 这个是编译原理的知识,说得简单一点就是,可以把js文件里的代码抽象成一个对象,代码的信息会存在对象中)
//babylon 这个工具是是负责解析字符串并生产ast。
const ast = babylon.parse(content, {
sourceType: "module"
});

//用来存储 文件所依赖的模块,简单来说就是,当前js文件 import 了哪些文件,都会保存在这个数组里
const dependencies = [];

//遍历当前ast(抽象语法树)
traverse(ast, {
//找到有 import语法 的对应节点
ImportDeclaration: ({ node }) => {
//把当前依赖的模块加入到数组中,其实这存的是字符串,
//例如 如果当前js文件 有一句 import message from './message.js',
//'./message.js' === node.source.value
dependencies.push(node.source.value);
}
});

//模块的id 从0开始, 相当一个js文件 可以看成一个模块
const id = ID++;

//这边主要把ES6 的代码转成 ES5
const { code } = babel.transformFromAstSync(ast, null, {
presets: ["@babel/preset-env"]
});

return {
id,
filename,
dependencies,
code
};
}

//从入口开始分析所有依赖项,形成依赖图,采用广度遍历
function createGraph(entry) {
const mainAsset = createAsset(entry);

//既然要广度遍历肯定要有一个队列,第一个元素肯定是 从 "./example/entry.js" 返回的信息
const queue = [mainAsset];


for (const asset of queue) {
const dirname = path.dirname(asset.filename);

//新增一个属性来保存子依赖项的数据
//保存类似 这样的数据结构 ---> {"./message.js" : 1}
asset.mapping = {};

asset.dependencies.forEach(relativePath => {
const absolutePath = path.join(dirname, relativePath);

//获得子依赖(子模块)的依赖项、代码、模块id,文件名
const child = createAsset(absolutePath);

//给子依赖项赋值,
asset.mapping[relativePath] = child.id;

//将子依赖也加入队列中,广度遍历
queue.push(child);
});
}
return queue;
}

//根据生成的依赖关系图,生成对应环境能执行的代码,目前是生产浏览器可以执行的
function bundle(graph) {
let modules = "";

//循环依赖关系,并把每个模块中的代码存在function作用域里
graph.forEach(mod => {
modules += `${mod.id}:[
function (require, module, exports){
${mod.code}
},
${JSON.stringify(mod.mapping)},
],`;
});

//require, module, exports 是 cjs的标准不能再浏览器中直接使用,所以这里模拟cjs模块加载,执行,导出操作。
const result = `
(function(modules){
//创建require函数, 它接受一个模块ID(这个模块id是数字0,1,2) ,它会在我们上面定义 modules 中找到对应是模块.
function require(id){
const [fn, mapping] = modules[id];
function localRequire(relativePath){
//根据模块的路径在mapping中找到对应的模块id
return require(mapping[relativePath]);
}
const module = {exports:{}};
//执行每个模块的代码。
fn(localRequire,module,module.exports);
return module.exports;
}
//执行入口文件,
require(0);
})({${modules}})
`;

return result;
}

const graph = createGraph("./example/entry.js");
const ret = bundle(graph);

// 打包生成文件
fs.writeFileSync("./bundle.js", ret);

打包生成:

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
49
50
51
52
53
54
55
56
57
58

(function (modules) {
// 创建一个require()函数: 它接受一个 模块ID 并在我们之前构建的模块对象查找它.
function require(id) {
const [fn, mapping] = modules[id];
function localRequire(relativePath) {
// 根据mapping的路径,找到对应的模块id
return require(mapping[relativePath]);
}
const module = { exports: {} };
// 执行每个模块的代码。
fn(localRequire, module, module.exports);
return module.exports;
}
// 执行入口文件,
require(0);
})({
0: [
function (require, module, exports) {
"use strict";

var _message = _interopRequireDefault(require("./message.js"));

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

console.log(_message.default);
},
{ "./message.js": 1 },
], 1: [
function (require, module, exports) {
"use strict";

Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;

var _name = require("./name.js");

var _default = "hello ".concat(_name.name, "!");

exports.default = _default;
},
{ "./name.js": 2 },
], 2: [
function (require, module, exports) {
"use strict";

Object.defineProperty(exports, "__esModule", {
value: true
});
exports.name = void 0;
var name = 'world';
exports.name = name;
},
{},
],
})

最后

通过bundler学习webpack模块依赖分析

Webpack4打包机制原理简析

视频教程手写 Webpack 明白打包原理

理解webpack原理,手写一个100行的webpack

窥探原理:手写一个 JavaScript 打包器