1. 前言
不知道有没有人和我一样,不管看了几遍文档,还是不会自己写 webpack,只能在别人写的配置上修修补补,更别提什么优化了。
于是我痛定思痛,决定从源头上解决这个问题!为了更好地应用 webpack,我们应该了解它背后的工作原理。
因此,我阅读了 miniwebpack 这个仓库。这个仓库实现了一个最简单的打包工具。接下来我会按照我的理解来解释一下怎么实现一个简单的打包工具
2. 主要思路
- 代码处理。我们平常写代码的时候,用的可能是ES6、ES7等高版本的语法,我们需要将它们转换成浏览器能运行的语法
- 打包。需要根据一个 entry 来输出一个 output,我们通过维护一个依赖关系图来解决这个问题
graph LR
A[entry] --> |解析代码| B[AST]
B --> |生成| C[浏览器可用代码]
B --> |检查import声明| D[依赖]
C --> E[模块信息]
D --> E
E --> F[依赖关系图]
F --> |生成最终代码| G[output]
3. 代码处理
- 解析(parse)。将源代码变成AST。
- 转换(transform)。操作AST,这也是我们可以操作的部分,去改变代码。
- 生成(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 第一步,提取某文件的依赖
最开始我们提到,需要构建一个依赖关系图。那么我们先从第一步开始,实现根据某个文件(输入绝对路径)提取依赖。大致可以分成以下几步:
- 读取文件内容
- 生成 AST
- 遍历 AST 来理解这个模块依赖哪些模块
- 为该模块分配唯一标识符
- 使代码支持所有浏览器
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 = [];
traverse(ast, {
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');
const {code} = transformFromAst(ast, null, { presets: ['env'], });
|
那么 code 到底长什么样呢
- 首先,babel 能将 es6 等更新的代码转成浏览器能执行的低版本代码,这个之前一直在强调的
- 其次,对于模块的转换。Babel 对 ES6 模块转码就是转换成 CommonJS 规范
Babel 对于模块输出的转换,就是把所有输出都赋值到 exports 对象的属性上,并加上 ESModule: true 的标识。表示这个模块是由 ESModule 转换来的 CommonJS 输出
输入就是 require
例如,对于以下文件
1 2 3 4 5 6 7 8 9 10
| import message from './message.js';
console.log(message); ``` ```javascript
import {name} from './name.js';
export default `hello ${name}!`;
|
按照上面的规范,转换后的代码大概是这样大概是这样:
1 2 3 4 5 6 7 8
| "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
| "use strict";
Object.defineProperty(exports, "__esModule", { value: true }); var _name = require("./name.js");
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');
function createGraph(entry) {
const mainAsset = createAsset(entry);
const queue = [mainAsset];
for (const asset of queue) { asset.mapping = {};
const dirname = path.dirname(asset.filename);
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 3 4
| import message from './message.js';
console.log(message);
|
1 2 3 4
| import {name} from './name.js';
export default `hello ${name}!`;
|
1 2
| 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 filename: string dependencies: Module[] code: string mapping: Record<string, number> } ``` 既然已经到了这一步了,就说明我们得处理一下 `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
|
modules: Record<number, [(require, module, exports) => any, Record<string, number>]>
|
接下来我们写主程序,我们主程序要做的工作有
- 实现
require, module, exports
- 默认调用入口文件
- 自执行
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) { 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)
|