徐漂漂

☝️ 一个工作 2 年并无建树的前端搬砖师

0%

写一个简易的打包工具

1. 前言

不知道有没有人和我一样,不管看了几遍文档,还是不会自己写 webpack,只能在别人写的配置上修修补补,更别提什么优化了。
于是我痛定思痛,决定从源头上解决这个问题!为了更好地应用 webpack,我们应该了解它背后的工作原理。
因此,我阅读了 miniwebpack 这个仓库。这个仓库实现了一个最简单的打包工具。接下来我会按照我的理解来解释一下怎么实现一个简单的打包工具

2. 主要思路

  1. 代码处理。我们平常写代码的时候,用的可能是ES6、ES7等高版本的语法,我们需要将它们转换成浏览器能运行的语法
  2. 打包。需要根据一个 entry 来输出一个 output,我们通过维护一个依赖关系图来解决这个问题
graph LR
A[entry] --> |解析代码| B[AST]
B --> |生成| C[浏览器可用代码]
B --> |检查import声明| D[依赖]
C --> E[模块信息]
D --> E
E --> F[依赖关系图]
F --> |生成最终代码| G[output]

3. 代码处理

  1. 解析(parse)。将源代码变成AST。
  2. 转换(transform)。操作AST,这也是我们可以操作的部分,去改变代码。
  3. 生成(generate)。将更改后的AST,再变回代码。

    参考:Babel用户手册

下面我将介绍一些这个过程中需要用到的工具。

3.1 解析器 babylon

用来将源代码转换为 AST。
(不了解 AST 的,可以先看看在线AST转换器。)

3.1.1 安装

1
npm install --save babylon

3.1.2 使用

1
2
3
import * as babylon from "babylon";

babylon.parse(code, [options])

3.2 转换器 babel-traverse

用来操作 AST

3.2.1 安装

1
npm install --save babel-traverse

3.2.2 使用

该模块仅暴露出一个 traverse 方法。traverse 方法是一个遍历方法, path 封装了每一个节点,并且还提供容器 container ,作用域 scope 这样的字段。提供个更多关于节点的相关的信息,让我们更好的操作节点。
示例:

1
2
3
4
5
6
7
8
9
10
11
// 
import traverse from "babel-traverse";

traverse(ast, {
enter(path) {
if (path.node.type === "Identifier"
&& path.node.name === 'text') {
path.node.name = 'alteredText';
}
}
})

3.3 生成器 babel-generator

可以根据 AST 生成代码

3.3.1 安装

1
npm install --save babel-generator

3.3.2 使用

1
2
3
import generate from "babel-generator";

const genCode = generate(ast, {}, code);

4. 实现细节

4.1 第一步,提取某文件的依赖

最开始我们提到,需要构建一个依赖关系图。那么我们先从第一步开始,实现根据某个文件(输入绝对路径)提取依赖。大致可以分成以下几步:

  1. 读取文件内容
  2. 生成 AST
  3. 遍历 AST 来理解这个模块依赖哪些模块
  4. 为该模块分配唯一标识符
  5. 使代码支持所有浏览器

4.1.1 读取文件内容

我们用 node.js 的 fs 模块就可以

1
2
const fs = require('fs');
const content = fs.readFileSync(filename, 'utf-8');

4.1.2 生成 AST

用到我们之前提到的 babylon

1
2
3
const ast = babylon.parse(content, {
sourceType: 'module',
});

4.1.3 遍历 AST 来试着理解这个模块依赖哪些模块

这里我们需要操作 AST,所以用到 babel-traverse

1
2
3
4
5
6
7
8
9
10
const dependencies = [];
// 要做到这一点,我们检查`ast`中的每个 `import` 声明.
traverse(ast, {
// `Ecmascript`模块相当简单,因为它们是静态的. 这意味着你不能`import`一个变量,
// 或者有条件地`import`另一个模块.
// 每次我们看到`import`声明时,我们都可以将其数值视为`依赖性`.
ImportDeclaration: ({node}) => {
dependencies.push(node.source.value);
},
});

4.1.4 为模块分配唯一标识符

我们简单地用 id 表示

1
2
// 递增简单计数器
const id = ID++;

4.1.5 使代码支持所有浏览器

使用 babel

1
2
3
4
5
6
7
const {transformFromAst} = require('babel-core');

// 该`presets`选项是一组规则,告诉`babel`如何传输我们的代码.
// 我们用`babel-preset-env``将我们的代码转换为浏览器可以运行的东西.
const {code} = transformFromAst(ast, null, {
presets: ['env'],
});

那么 code 到底长什么样呢

  1. 首先,babel 能将 es6 等更新的代码转成浏览器能执行的低版本代码,这个之前一直在强调的
  2. 其次,对于模块的转换。Babel 对 ES6 模块转码就是转换成 CommonJS 规范
    Babel 对于模块输出的转换,就是把所有输出都赋值到 exports 对象的属性上,并加上 ESModule: true 的标识。表示这个模块是由 ESModule 转换来的 CommonJS 输出
    输入就是 require

例如,对于以下文件

1
2
3
4
5
6
7
8
9
10
// entry.js
import message from './message.js';

console.log(message);
```
```javascript
// message.js
import {name} from './name.js';

export default `hello ${name}!`;

按照上面的规范,转换后的代码大概是这样大概是这样:

1
2
3
4
5
6
7
8
// entry.js
"use strict";
var _message = require("./message.js");
var _message2 = _interopRequireDefault(_message);
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : { default: obj };
}
console.log(_message2.default);
1
2
3
4
5
6
7
8
9
10
11
// message.js
"use strict";

// 加上 ESModule: true 的标识
Object.defineProperty(exports, "__esModule", {
value: true
});
var _name = require("./name.js");

// 把所有输出都赋值到 exports 对象的属性上
exports.default = "hello " + _name.name + "!";

4.1.6 返回模块信息

1
2
3
4
5
6
return {
id,
filename,
dependencies,
code,
};

以上,我们就处理好了一个模块。包含着以下 4 项信息

  • 模块 id
  • 文件的绝对路径
  • 该模块的依赖。保存着的是依赖们的相对路径
  • 该模块内部代码(浏览器可运行)

4.2 第二步,生成依赖图

通过第一步,我们已经能生成某个模块的依赖了。接下来,我们就可以顺藤摸瓜,从入口文件开始,生成入口文件的依赖,再生成入口文件的依赖的依赖,再生成入口文件的依赖的依赖依…(禁止套娃),直到所有模块处理完毕

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
const path = require('path');

// entry 为入口文件的路径
function createGraph(entry) {

// createAsset 是我们在【第一步,提取某文件的依赖】中实现的函数
// mainAsset 就是入口模块的信息了
const mainAsset = createAsset(entry);

// 使用一个队列,刚开始只有入口模块
const queue = [mainAsset];

for (const asset of queue) {

// mapping 用来将【依赖的相对路径】映射到【该依赖的模块 id】
asset.mapping = {};

// 这个模块所在的目录.
const dirname = path.dirname(asset.filename);

// 遍历每一个依赖。
asset.dependencies.forEach(relativePath => {

// 得到依赖的绝对路径
const absolutePath = path.join(dirname, relativePath);

// 得到 child 的模块信息
const child = createAsset(absolutePath);

// 将【依赖的相对路径】映射到【该依赖的模块 id】
// 因为如果不做映射。最终打包到一个文件后,编码时的相对路径就不管用了。我们就没法知道像 require('./child') 这种代码到底应该加载哪一个模块
asset.mapping[relativePath] = child.id;

// 把这个子模块也放进队列里面
queue.push(child);
});
}

// 到这一步,队列 就是一个包含目标应用中 每个模块 的数组
// 实际上这个就是我们最终的依赖关系图了
return queue;
}

对于以下文件

1
2
3
4
// ./example/entry.js
import message from './message.js';

console.log(message);
1
2
3
4
// ./example/message.js
import {name} from './name.js';

export default `hello ${name}!`;
1
2
// ./example/name.js
export const name = 'world';

我们处理后的依赖关系图应该是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[{
id: 0,
filename: './example/entry.js',
dependencies: ['./message.js'],
code: ,// 略
mapping: {
'./message.js': 1
}
}, {
id: 1,
filename: './example/message.js',
dependencies: ['./name.js'],
code: ,// 略
mapping: {
'./name.js': 2
}
}, {
id: 2,
filename: './example/name.js',
dependencies: [],
code: ,// 略
mapping: {}
}]

4.3 第三步,根据依赖图生成代码

目前,我们已经有了依赖图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
graph: Module[]

interface Module {
id: number // 模块id;在【提取某文件的依赖】这一步中我们使用的是一个递增的 id
filename: string
dependencies: Module[]
code: string // 该模块的代码(经过转换的,能在浏览器中运行)
mapping: Record<string, number> // 将依赖的相对路径转换成id。是我们在【生成依赖图】这一步所做的工作
}
```
既然已经到了这一步了,就说明我们得处理一下 `code` 了。在【使代码支持所有浏览器】这一步中,我们已经知道了,`code` 是符合 CommonJS 规范的。但CommonJS 中有以下几个东西,是浏览器中没有的:
- require
- module
- exports

那么接下来就是我们自己实现这3个东西!

首先把咱目前的模块信息整合一下:
- mapping 是肯定要的。因为我们模块的被转换后会通过相对路径来调用 require() ,而我们需要知道对应去加载哪个模块
- code 需要稍微改一下。每个模块的作用域应该是独立的。所以我们改成这样:
```javascript
function (require, module, exports) {
{code}
}

最终把所有这样的模块放在 modules 中,大概是这样:

1
2
3
4
5
6
7
8
9
10
11
/*
{0: [
function (require, module, exports) {
{code}
},
mapping: {
'./message.js': 1
}
]}
*/
modules: Record<number, [(require, module, exports) => any, Record<string, number>]>

接下来我们写主程序,我们主程序要做的工作有

  1. 实现 require, module, exports
  2. 默认调用入口文件
  3. 自执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
(function(modules) {

function require(id) {
// 从 modules 拿到 【执行函数】和【mapping】
const [fn, mapping] = modules[id];

// 自己实现的 require,可以根据相对路径加载依赖
function localRequire(name) {
return require(mapping[name]);
}

// // 自己实现的 module 和 exports
const module = { exports : {} };

fn(localRequire, module, module.exports);

return module.exports;
}

// 调用入口文件
require(0);

})(modules)