本文主要內容來自於 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
在代碼中我們可以大致這樣表示:
{
"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 查找其他的檔案進行相同的操作,最後將所有的檔案以字串的形式合併到一個檔案中,這樣就完成了打包。
接下來從一個實際的場景出發:
# 專案檔案結構
- `mini-bundle`
- `index.js`
- `example/`
- `entry.js`
- `hello.js`
- `name.js`
entry.js
import { sayHello } from "./hello.js";
console.log(sayHello());
hello.js
import { name } from "./name.js";
export function sayHello() {
return `hello ${name}`;
}
name.js
export const name = "mini-bundle";
首先,我們需要為每個 js 生成 asset,asset 可以理解為每個 js 檔案對應的用於打包的資料結構
id 是檔案的唯一標識符,用於查詢編譯後的模組;
filename 是檔案的相對路徑(也可以是絕對路徑或者別名),用於找到讀取並編譯檔案內容;
dependencies 是檔案的依賴項;
code 是編譯後的代碼;
mapping 是 filename 跟 id 的映射,跟 dependencies 配合可以找到依賴項的 id;
所以,我們需要先實現一個 createAsset 函數,代碼中 parse 和 traverse 是 babel 的函數,用於解析並生成 AST 和遍歷查詢依賴。
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,
};
}
接著我們需要從入口檔案開始構建出整個依賴圖(所有檔案的依賴關係)
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
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 的規範)中,這樣就完成了腳本的打包。
以下是完整代碼:
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 打包後的代碼如下:
(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 的概念和如何查找依賴。其他諸如快取、循環引用等問題沒有進行涉及。