📘

Build Your Own Webpack!

2021/01/03に公開

はじめに

2018年6月にウクライナのキエフで開催されたカンファレンス、You Gotta Love FrontendからRonen Amiel氏のライブコーディングBuild Your Own Webpackに沿ってモジュールバンドラー基本的な内部動作を勉強してみようという記事(兼備忘録)です。
コードの中で疑問に思ったことをすべて調べて補足しています。
筆者はプログラミングを初めて1年にも満たない未熟者ですので、本文中に訂正すべき点や誤りがあればご指摘いただければ幸いです。

一度本編を通して見て頂いて、その振り返りがてらこの記事を読むことをおすすめします。
英語での講演ですが十分内容はわかりやすいですし、コードで行っていることの雰囲気は伝わると思います。
ライブコーディング本編↓

目標物の確認

ライブコーディングを通じて制作するモジュールバンドラーは当たり前ですが、プロダクション環境に導入できるような代物ではありません。

昨今、モジュールバンドラーと一口に言ってもTree shaking,code splitting,Dead Code Elimination,プラグインシステムといった様々な機能が搭載されています。それをスクラッチで作るのは非常に困難なので、今回は本当に必要最低限の2つの機能を実装しま。

  1. entry pointを起点とした各モジュールの依存関係グラフ[1]の構築
  2. モジュールをBrowserで実行可能な単一(もしくは複数)のJavaScriptファイルへアセンブルする。

Let's coding!!

まずサンプルのためのファイルを作成します。

サンプルの作成

example
├── entry.js
├── message.js
└── name.js
//entry.js
import message from "./message.js";
console.log(message);

//message.js
import { name } from "./name.js";
export default `hello ${name}!`;

//name.js
export const name = "world";

エントリーポイントはentry.jsです。

entry.js : message.jsをインポート、
message.js : name.jsをインポート
しているのがわかると思います。
イメージを掴むために依存関係グラフをイラストにします。entry pointが起点向きのあるグラフです。

モジュールをコードで表そう

最初はイラスト内でいう丸(モジュール)をコードで表そうという工程です。

bundle.js
const fs = require('fs')
const { parse } = require("@babel/parser");
const traverse = require("@babel/traverse").default;

let ID = 0;

function createAsset(filename){
 const content = fs.readFileSync(filename, "utf-8");
 const ast = parse(content, {
		sourceType: "module",
	   });

  const dependencies = [];

  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      dependencies.push(node.source.value);
    },
  });
  const id = ID++;
  return {
    id,
    filename,
    dependencies,
  };
}

const mainAsset = createAsset('./example/entry.js')
console.log(mainAsset)

~13:30あたりまでのコードです。

モジュールをコードで表現するために次の4つの要素が必要になります。

  1. ファイルの識別子
  2. ファイルの出どころ
  3. 依存関係
  4. ソースコード//後で出てきます

それぞれcreateAssetの返り値に対応しています。

  1. ファイルの識別子:----> id
  2. ファイルの出どころ:----> filename
  3. 依存関係:----> dependencies
  4. ソースコード:----> code //後で出てきます

console.logの結果を確認してみましょう。

{
  id: 0,
  filename: './example/entry.js',
  dependencies: [ './message.js' ]
}

依存関係グラフを構築しよう

次の工程はモジュール同士の依存関係・つながりをコードで表し、依存関係グラフを構築することです。

bundle.js
+ const path = require("path");
~~~~~~~

function createGraph(entry) {
  const mainAsset = createAsset(entry);
  const queue = [mainAsset];
  for (const asset of queue) {
    const dirname = path.dirname(asset.filename);
    asset.mapping = {};
    asset.dependencies.forEach((relativePath) => {
      const absolutePath = path.join(dirname, relativePath);
      const child = createAsset(absolutePath);
      asset.mapping[relativePath] = child.id;
      queue.push(child);
    });
  }
  return queue;
}

~20:00あたりまでのコードです。

ここで新しくモジュールマップを作成する必要があります。

asset.mapping = {};

モジュールマップには必要なもの(依存しているモジュール)の名前がキー、一意に定まったidが値として格納されています。


  {
    id: 0,
    filename: './example/entry.js',
    dependencies: [ './message.js' ],
    mapping: { './message.js': 1 }
  },
  {
    id: 1,
    filename: 'example/message.js',
    dependencies: [ './name.js' ],
    mapping: { './name.js': 2 }
  },
  { id: 2, filename: 'example/name.js', dependencies: [], mapping: {} }
]

dependenciesに格納されているpathに対して一意の番号が対応しているのがわかると思います。
このモジュールマップこそがモジュール間の依存関係を表すものであり、バンドルする工程で非常に大事な役割を果たします。
これは、実際の工程で実感できると思うので、ひとまず次に進みます。

その他

なぜ絶対パスに修正するの?

コード中のrelativePathはdependenciesから取り出されたものです。
そしてdependenciesにはその依存元からみてどこにあるのかという情報がしまわれています。
一方createAsset関数の引数には**ファイルシステム(fs)から見てどこにあるのか(絶対パス)**を教えて上げる必要があります。

ex) entry.jsとmessage.jsの場合
// createAssetにentry.jsのpathを入れたときの返り値
{
 dependencies:['./message.js'] // entry.jsから見てどこにあるのかを示している。
}

// createAsset関数が引数としてほしいもの
'example/message.js' // fsから見たmessage.jsの場所

内部の処理

一見コードが複雑に見えますが、幅優先探索を勉強したことがある方なら、非常に形が似ていることに気づくと思います。初歩の初歩だけでも知っていれば、処理がそんなに難しくないことがわかると思うので、ぜひ触れてみてください。

モジュールをバンドルしよう

ここまでの工程で生成した依存関係グラフを元に、一つのファイルにバンドルしましょう。

codeの追加

一点createAsset関数に修正を加える部分があります。
モジュールをコードで表すために、必要な要素の4つ目codeを追加しましょう。
モジュールバンドラーの機能として、次のものがありました。

Browserで実行可能な単一(もしくは複数)のJavaScriptファイルへアセンブルする。

そのため、babelを使って、トランスパイルした上で、加えるようにしましょう。


+ const babel = require("@babel/core");

function createAsset(filename) {
  const content = fs.readFileSync(filename, "utf-8");
  const ast = parse(content, {
    sourceType: "module",
  });

  const dependencies = [];

  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      dependencies.push(node.source.value);
    },
  });
  const id = ID++;
+   const { code } = babel.transformFromAst(ast, null, {
+     presets: ["@babel/preset-env"],
+   });
  return {
    id,
    filename,
    dependencies,
+   code,
  };
}

bundle関数を実装しよう

bundle.js
function bundle() {
  let modules = "";

  graph.forEach((mod) => {
    modules += `${mod.id}:[
      function(require,module,exports){
        ${mod.code}
      },
      ${JSON.stringify(mod.mapping)},
    ],`;
  });

  const result = `
  (function(modules){
    function require(id){
      const [fn,mapping] = modules[id]
      function localRequire(relativePath){
        return require(mapping[relativePath])
      }
      const module = {exports:{}};
      fn(localRequire,module,module.exports);
      return module.exports;
    }
    require(0)
  })({${modules}})
  `;
  return result;
}

一番初見だと?になる点が多いと思います。

パラメーターの作成

let modules = "";

  graph.forEach((mod) => {
    modules += `${mod.id}:[
      function(require,module,exports){
        ${mod.code}
      },
      ${JSON.stringify(mod.mapping)},
    ],`;
  });

modulesに対して、graphから一つずつ要素を取り出して文字列として連結させています。

0: [
        function(require, module, exports) {
            (codeが入る)
        },
        {
            "./message.js": 1
        },
    ], 1: [
        function(require, module, exports) {
            (codeが入る)
        },
        {
            "./name.js": 2
        },
    ], 2: [
        function(require, module, exports) {
            (codeが入る)
        },
        {},
    ],

ここの時点では本当にただの文字列であることに注意してください。
定数resultをよーく見ると最後にmodulesが'{}'に囲まれています。

({${modules}})

先程の文字列を代入すると、

{
   0: [
        function(require, module, exports) {
            (codeが入る)
        },
        {
            "./message.js": 1
        },
    ], 
    1: [
        function(require, module, exports) {
            (codeが入る)
        },
        {
            "./name.js": 2
        },
    ], 
    2: [
        function(require, module, exports) {
            (codeが入る)
        },
        {},
    ],
}

これは、jsのオブジェクトとして認識されます。
こうすることで、module[一意の番号]とすれば、モジュールの本体と依存しているモジュールを示すマップを持った配列にアクセスできるようになります。

なぜfunction(require, module, exports){}で囲むのか?

function(){}で囲むことで変数が他のモジュールやグローバルスコープの汚染を防ぐためにを形成させるためです。
また、コードをcoommonjsにトランスパイルしているのでモジュールのシステムもcommonjsに合わせる必要があります。そのため、引数にrequire,module,exportsに設定しています。(この3つが必須)
詳しくはこちら

result

最後です。

const result = `
  (function(modules){
    function require(id){
      const [fn,mapping] = modules[id]
      function localRequire(relativePath){
        return require(mapping[relativePath])
      }
      const module = {exports:{}};
      fn(localRequire,module,module.exports);
      return module.exports;
    }
    require(0)
  })({${modules}})
  `;

Browserで実行できるようにするため、定義と実行を同時に行う即時関数でラップします。
この即時関数は

require(0)

を実行します。require関数の定義を見てみましょう。

function require(id){
      const [fn,mapping] = modules[id]
      function localRequire(relativePath){
        return require(mapping[relativePath])
      }
      const module = {exports:{}};
      fn(localRequire,module,module.exports);
      return module.exports;
    }

パラメータを作成したときにmodulesは登場しました。
一意の番号を入れると、モジュールの本体依存しているモジュールを示すマップを持った配列が返ってきますね。よって
fn : モジュール本体
mapping: 依存しているモジュールを示すマップ
がそれぞれ入ります。
また、モジュール本体は(require,module,exports)を引数に持った関数でラップされているのでした。
moduleとexportsについては適当に実装しましょう。

 const module = {exports:{}};
function localRequire(relativePath){
        return require(mapping[relativePath])
      }

モジュールマップ(mapping)は依存モジュールへのpathが一意なidに対応しています。

{
   "relativePath(./message.js など)": id
}

そのidをrequire関数にいれて...と依存モジュールをすべて読み込むまで繰り返します。

実行

bundle関数を実行してみましょう。

結果
(function (modules) {
  function require(id) {
    const [fn, mapping] = modules[id];

    function localRequire(relativePath) {
      return require(mapping[relativePath]);
    }
    const module = {
      exports: {},
    };
    fn(localRequire, module, module.exports);
    return module.exports;
  }
  require(0);
})({
  0: [
    function (require, module, exports) {
      "use strict";

      var _message = _interopRequireDefault(require("./message.js"));

      function _interopRequireDefault(obj) {
        return obj && obj.__esModule
          ? obj
          : {
              default: obj,
            };
      }

      console.log(_message["default"]);
    },
    {
      "./message.js": 1,
    },
  ],
  1: [
    function (require, module, exports) {
      "use strict";

      Object.defineProperty(exports, "__esModule", {
        value: true,
      });
      exports["default"] = void 0;

      var _name = require("./name.js");

      var _default = "hello ".concat(_name.name, "!");

      exports["default"] = _default;
    },
    {
      "./name.js": 2,
    },
  ],
  2: [
    function (require, module, exports) {
      "use strict";

      Object.defineProperty(exports, "__esModule", {
        value: true,
      });
      exports.name = void 0;
      var name = "world";
      exports.name = name;
    },
    {},
  ],
});

実行した結果のコードをコンソールにはっつけて

hello,worldと表示されれば成功です! お疲れさまでした!

最後に

拡張の余地はまだまだたくさんあります。
・class構文で書いてみる。
・node_modulesからのimportにも対応する。
・自動でファイルを作成し、出力する。
・cssをバンドルできるようにしてみる。
・専用CLIツールを作る。
.....
などなど。

この記事が皆さんの理解の一助となれば幸いです!
ライブコーディングのソースコードこちら

脚注
  1. 背後にはグラフというデータ構造が関わっています。とてもおもしろいのでおすすめ。 ↩︎

Discussion