本文主要内容来自于 mini-pack。示例代码跟原项目的代码有部分不同。
開発 web アプリケーションでは一般的にモジュール化された開発方式が採用されます。つまり、異なるビジネスニーズやコンポーネントは異なるファイルに記述され、最終的に必要に応じて異なるファイルを 1 つまたは複数のファイルに統合するプロセスがパッケージングです。
上の図は一般的なファイル構造を示しています。エントリファイル(entry.js)は a.js
と c.js
に依存し、a.js
は b.js
に依存し、c.js
は d.js
と e.js
に依存しています。もちろん、実際の構造は例よりも複雑です。このようなコードの組織構造をパッケージングする必要がある場合、すべてのファイルは 1 つのファイルにパッケージングされるべきです。
まず、各ファイルの依存関係を分析する必要があります:
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 を使用して他のファイルを探し、同様の操作を行い、最後にすべてのファイルを文字列の形式で 1 つのファイルに統合することで、パッケージングが完了します。
次に、実際のシーンから始めます:
# プロジェクトファイル構造
- `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 の概念と依存関係の検索方法を理解することを目的としています。キャッシュや循環参照などの問題には触れていません。