Yukiniro

Yukiniro

Change the world.

mini-pack から js のバンドル原理を理解する

本文主要内容来自于 mini-pack。示例代码跟原项目的代码有部分不同。

開発 web アプリケーションでは一般的にモジュール化された開発方式が採用されます。つまり、異なるビジネスニーズやコンポーネントは異なるファイルに記述され、最終的に必要に応じて異なるファイルを 1 つまたは複数のファイルに統合するプロセスがパッケージングです。

image

上の図は一般的なファイル構造を示しています。エントリファイル(entry.js)は a.jsc.js に依存し、a.jsb.js に依存し、c.jsd.jse.js に依存しています。もちろん、実際の構造は例よりも複雑です。このようなコードの組織構造をパッケージングする必要がある場合、すべてのファイルは 1 つのファイルにパッケージングされるべきです。

まず、各ファイルの依存関係を分析する必要があります:

  • entry.jsa.jsc.js に依存
  • a.jsb.js に依存
  • c.jsd.jse.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 ファイルに対応するパッケージング用のデータ構造と理解できます。

image 1

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,
  };
}

次に、エントリファイルから依存グラフ全体を構築する必要があります(すべてのファイルの依存関係)。

image 2

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.jshello.jsname.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 の概念と依存関係の検索方法を理解することを目的としています。キャッシュや循環参照などの問題には触れていません。

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。