本文主要内容来自于 mini-pack。示例代码跟原项目的代码有部分不同。
开发 web app 时一般采用模块化的开发方式,即不同的业务需求、组件会编写到不同的文件中,最后再根据需要进行将不同的文件合并成一个或者多个文件,这样的过程就是打包。
上图展示了一个常见的文件结构,入口文件(entry.js)依赖 a.js
和 c.js
,a.js
依赖 b.js
,而 c.js
依赖 d.js
和 e.js
。当然,实际的结构会比示例更复杂。如果说我们需要对这样的代码组织结构进行打包,那么所有的文件都应该打包的一个文件中。
首先,我们需要分析出每个文件的依赖关系:
entry.js
依赖a.js
和c.js
a.js
依赖b.js
c.js
依赖d.js
和e.js
在代码中我们可以大致这样表示:
json
{
"entry.js": {
"id": 0,
"code": "xxx",
"deps": ["a.js", "c.js"]
},
"a.js": {
"id": 1,
"code": "xxx",
"deps": ["b.js"]
},
"c.js": {
"id": 2,
"code": "xxx",
"deps": ["d.js", "f.js"]
},
"b.js": {
"id": 3,
"code": "xxx",
"deps": []
},
"d.js": {
"id": 4,
"code": "xxx",
"deps": []
},
"e.js": {
"id": 0,
"code": "xxx",
"deps": []
}
}
每个文件都有自身的 id,code 和 deps,在进行打包时我们就可以从入口文件出发对 code 进行转译(基于兼容性考虑),然后使用 id 和 deps 查找其他的文件进行相同的操作,最后将所有的文件以字符串的形式合并到一个文件中,这样就完成了打包。
接下来从一个实际的场景出发:
markdown
# 项目文件结构
- `mini-bundle`
- `index.js`
- `example/`
- `entry.js`
- `hello.js`
- `name.js`
entry.js
js
import { sayHello } from "./hello.js";
console.log(sayHello());
hello.js
js
import { name } from "./name.js";
export function sayHello() {
return `hello ${name}`;
}
name.js
js
export const name = "mini-bundle";
首先,我们需要为每个 js 生成 asset,asset 可以理解为每个 js 文件对应的用于打包的数据结构
id 是文件的唯一标识符,用于查询编译后的模块;
filename 是文件的相对路径(也可以是绝对路径或者别名),用于找到读取并编译文件内容;
dependencies 是文件的依赖项;
code 是编译后的代码;
mapping 是 filename 跟 id 的映射,跟 dependencies 配合可以找到依赖项的 id;
所以,我们需要先实现一个 createAsset 函数,代码中 parse 和 traverse 是 babel 的函数,用于解析并生成 AST 和遍历查询依赖。
js
async function createAsset(filename) {
// 读取文件内容
const content = await readFile(filename, "utf-8");
// 构建 ast
const ast = parse(content, {
sourceType: "module",
});
// 收集依赖
const dependencies = [];
traverse.default(ast, {
ImportDeclaration: ({ node }) => {
dependencies.push(node.source.value);
},
});
const id = ID++;
// 转译代码
const { code } = transformFromAst(ast, null, {
presets: ["@babel/preset-env"],
});
// 目前返回的对象中没有 mapping 属性
// 该属性会在构建依赖图时创建
return {
id,
filename,
dependencies,
code,
};
}
接着我们需要从入口文件开始构建出整个依赖图(所有文件的依赖关系)
js
async function createGraph(entry) {
const mainAsset = await createAsset(entry);
// 记录所有的模块资产
const queue = [mainAsset];
// 广度优先遍历
for (const asset of queue) {
// 记录模块的依赖关系
// 映射模块 id 和 模块路径
asset.mapping = {};
// 获取模块的目录
const dirname = path.dirname(asset.filename);
for (const relativePath of asset.dependencies) {
// 获取绝对路径
const absolutePath = path.join(dirname, relativePath);
// 创建子模块
const child = await createAsset(absolutePath);
// 记录模块的依赖关系
asset.mapping[relativePath] = child.id;
// 将子模块添加到队列中
queue.push(child);
}
}
return queue;
}
// const graph = await createGraph("./example/entry.js");
构建依赖图时会从入口文件(entry.js)开始生成 mainAsset,然后将 mainAsset 加入 queue,遍历 queue 找到 dependencies 所对应的文件,接着依次生成依赖项的 child asset 并更新 mapping,然后再将 child 加入 queue,直到所有的文件都生成完成。
最后通过 bundle 函数拼接所有的 asset
js
function bundle(graph) {
let modules = "";
graph.forEach(mod => {
modules += `${mod.id}: [
function(require, module, exports) {
${mod.code}
},
${JSON.stringify(mod.mapping)}
],
`;
});
return `
(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}})
`;
}
在 bundle 函数中首先将 graph 转化为 { id: [fn, mapping] }
结构的 modules 对象,然后将其传入立即执行函数(按照 commonjs 的规范)中,这样就完成了脚本的打包。
以下是完整代码:
js
import { readFile } from "node:fs/promises";
import { parse } from "@babel/parser";
import traverse from "@babel/traverse";
import { transformFromAst } from "@babel/core";
import path from "node:path";
import { mkdirSync, writeFileSync } from "node:fs";
let ID = 0;
async function createAsset(filename) {
// 读取文件内容
const content = await readFile(filename, "utf-8");
// 构建 ast
const ast = parse(content, {
sourceType: "module",
});
// 收集依赖
const dependencies = [];
traverse.default(ast, {
ImportDeclaration: ({ node }) => {
dependencies.push(node.source.value);
},
});
const id = ID++;
// 转译代码
const { code } = transformFromAst(ast, null, {
presets: ["@babel/preset-env"],
});
return {
id,
filename,
dependencies,
code,
};
}
async function createGraph(entry) {
const mainAsset = await createAsset(entry);
// 记录所有的模块资产
const queue = [mainAsset];
// 广度优先遍历
for (const asset of queue) {
// 记录模块的依赖关系
// 映射模块 id 和 模块路径
asset.mapping = {};
// 获取模块的目录
const dirname = path.dirname(asset.filename);
for (const relativePath of asset.dependencies) {
// 获取绝对路径
const absolutePath = path.join(dirname, relativePath);
// 创建子模块
const child = await createAsset(absolutePath);
// 记录模块的依赖关系
asset.mapping[relativePath] = child.id;
// 将子模块添加到队列中
queue.push(child);
}
}
return queue;
}
function bundle(graph) {
let modules = "";
graph.forEach(mod => {
modules += `${mod.id}: [
function(require, module, exports) {
${mod.code}
},
${JSON.stringify(mod.mapping)}
],
`;
});
return `
(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}})
`;
}
(async () => {
const graph = await createGraph("./example/entry.js");
const bundleCode = bundle(graph);
mkdirSync("./dist", { recursive: true });
writeFileSync("./dist/bundle.js", bundleCode);
})();
将 entry.js 、hello.js 和 name.js 打包后的代码如下:
js
(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);
})({
0: [
function (require, module, exports) {
"use strict";
var _hello = require("./hello.js");
console.log((0, _hello.sayHello)());
},
{ "./hello.js": 1 }
],
1: [
function (require, module, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.sayHello = sayHello;
var _name = require("./name.js");
function sayHello() {
return "hello ".concat(_name.name);
}
},
{ "./name.js": 2 }
],
2: [
function (require, module, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.name = void 0;
var name = exports.name = "mini-bundle";
},
{}
],
})
这篇文章的内容是最基本的打包原理,主要是了解 asset、graph 的概念和如何查找依赖。其他诸如缓存、循环引用等问题没有进行涉及。