JSエコシステムぶらり探訪(5): CommonJSモジュールバンドラー

[←前](https://qiita.com/qnighy/items/faddc72389f6c94421e1) [目次](https://qiita.com/qnighy/items/16fdd8e58309a1f706a0) [次→](https://qiita.com/qnighy/items/0c3fd208e0356fa19cda)

## モジュールバンドラーとは

前3回までで説明したNode.jsのモジュールシステムとnpmのパッケージシステムは、再利用可能なJavaScriptプログラムを作り、配布し、利用する仕組みとして非常によくできたものでした。そのため、この**Node.jsのモジュールシステムをブラウザでも利用したい**という需要が生じることになりました。正確には以下の2つの需要がありました。

- Node.js用に書いたプログラムをブラウザでも再利用したい。
- 純粋にブラウザ向けのJavaScriptコードでも、モジュールシステムやパッケージシステムを使いたい。

これらの願いを同時に叶えるべく、Browserifyをはじめとした**モジュールバンドラー**というツールが開発されました。モジュールバンドラーは複数のJavaScriptモジュールを結合し、ひとつのJavaScriptファイルを生成します。

モジュールバンドラーの機能を持つツールは一つではなく、比較的有名なものでも[Browserify](http://browserify.org/), [Webpack](https://webpack.js.org/), [rollup.js](https://rollupjs.org/), [Parcel](https://parceljs.org/), [FuseBox](https://fuse-box.org/), [esbuild](https://esbuild.github.io/) など多くの実装がありますが、本稿では現在最もメジャーと思われる多機能バンドラー **Webpack** を代表例として扱います。

## Webpackでバンドルしてみる

[CommonJS modulesの説明](https://qiita.com/qnighy/items/067dd47c48b96e772a4e#commonjs-modules-cjs)に使った例を再掲します。

```javascript:src/main.js
var util = require('./util');
console.log(util.square(2)); // => 4
console.log(util.private_value); // => undefined
```

```javascript:src/util.js
var private_value = 42;
// exports.square = ... でもよい
module.exports.square = function(x) {
  return x * x;
};
```

これをWebpackでバンドリングしてみます。

```json:package.json
{
  "scripts": {
    "build": "webpack --mode none --entry ./src/main.js"
  },
  "devDependencies": {
    "webpack": "^5.4.0",
    "webpack-cli": "^4.2.0"
  }
}
```

```console
npm install
npm run build
```

これで `dist/main.js` が生成されます。多くの場合はこれを `<script>` から呼び出しますが、今回はブラウザの機能を使っていないので、そのままNode.jsで動かすことができます。

```console
node dist/main.js
```

### CommonJSモジュールバンドラーの制約

CommonJSモジュールバンドラーは、あらゆるCommonJSモジュールに対応しているわけではありません。原則として、以下のように**定数インポート**のみを行っているモジュールのみがバンドルできます。

- `require` は必ず関数呼び出しの形で出現する。 `require(...)` はOKだが `f(require)` や `let x = require` のような形は駄目。
- `require(...)` の引数は必ず文字列リテラルである。`require("./hoge")` はOKだが `require(addExtension("./hoge"))` は駄目。

ただし、モジュールバンドラーによっては上記を満たさないコードも何とかしてバンドルできる場合があります (例: [Webpackのrequire context](https://webpack.js.org/guides/dependency-management/#require-with-expression))。

また、モジュールバンドルという機能自体はNode.jsとブラウザの環境差異を吸収しない[^node-shim]ため、環境差異については別途考慮する必要があります。本稿ではモジュールバンドル自体の性質のみを扱います。

[^node-shim]: 実際にはWebpackのnode shimのように、そういった機能が同梱されているものも多い

### 条件つきrequire

この**定数インポート**条件は[冠頭形モジュール](https://qiita.com/qnighy/items/067dd47c48b96e772a4e#%E5%86%A0%E9%A0%AD%E5%BD%A2%E3%83%A2%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB)よりも制約が緩やかで、**条件つき `require`**を行うことができます。たとえば[Reactのエントリポイント](https://github.com/facebook/react/blob/v17.0.1/packages/react/npm/index.js)は以下のように記述されています。

```javascript:index.js
'use strict';

if (process.env.NODE_ENV === 'production') {
  module.exports = require('./cjs/react.production.min.js');
} else {
  module.exports = require('./cjs/react.development.js');
}
```

これは冠頭形モジュールの条件は満たしていませんが、定数インポートの条件は満たしています。

## CommonJSバンドリングの実現方法

上記のように、CommonJSバンドリングでは条件つきrequireを扱う必要があります。また、Node.jsには「同一モジュールは1度しか評価されない」という挙動があり、一部のライブラリはこの性質に依存しているため、この挙動の再現もほぼ必須です (パフォーマンス上も重要)。

そのため、CommonJSのバンドルは原則として「**Node.jsの `require` の挙動を実行時に再現する**」というアプローチになります。 [^module-concatenation]

[^module-concatenation]: [モジュール結合](https://webpack.js.org/plugins/module-concatenation-plugin/)という形で、より積極的な変換が可能な場合もある。

1. **バンドル時**には以下を行います。
   - 全ての `require` 呼び出しを再帰的に収集し、収録するべきモジュールの一覧を確定する。 (このために**定数インポート**である必要がある)
   - モジュールにIDを振る (絶対パス[^semi-absolute-paths]や番号などが使われる)
   - 全ての `require` 呼び出しを相対パスからIDに置き換える
   - モジュールのソースコードを、関数のリスト (オブジェクト) として1つのJavaScriptにまとめる。
2. **実行時**には以下を行います。
   - 読み込み済みモジュールを管理するためのオブジェクトを用意する。
   - バンドル済みのモジュール (関数) を実行する `require` 関数を作る。
   - `require` を使って、最初のモジュールを読み込む。

[^semi-absolute-paths]: 正確には絶対パスよりもCWDからの相対パスなどが使われることが多いと思われます。重要なのは、インポート元のモジュールからの相対ではないということです。

例として、以下のようなプログラムを考えます。

```javascript:src/index.js
console.log("Loading: index.js");
require("./utils/module2");
require("./utils/module3");
console.log("Loaded: index.js");
```

```javascript:src/utils/module1.js
console.log("Loading: module1.js");
console.log("Loaded: module1.js");
```

```javascript:src/utils/module2.js
console.log("Loading: module2.js");
require("./module1");
console.log("Loaded: module2.js");
```

```javascript:src/utils/module3.js
console.log("Loading: module3.js");
require("./module1");
console.log("Loaded: module3.js");
```

これをバンドルすると、以下のようなJavaScriptになります。 (Webpackの出力を参考に手書きし、説明を付与したものです)

```javascript:bundled.js
// IIFEでスコープを作って実行
(function () {
  // 必要なモジュールのソースがそのまま入ったオブジェクト。
  // この例ではCWDからの相対パスをそのままモジュールIDとして使っている
  var moduleDefs = {
    // 渡されるexports, require, moduleはNode.jsで使われているものと互換
    "src/index.js": function(exports, require, module) {
      console.log("Loading: index.js");
      // この部分がモジュール相対ではなく、CWD相対に変換されていることがポイント！
      require("src/utils/module2.js");
      require("src/utils/module3.js");
      console.log("Loaded: index.js");
    },
    "src/utils/module1.js": function(exports, require, module) {
      console.log("Loading: module1.js");
      console.log("Loaded: module1.js");
    },
    "src/utils/module2.js": function(exports, require, module) {
      console.log("Loading: module2.js");
      require("src/utils/module1.js");
      console.log("Loaded: module2.js");
    },
    "src/utils/module3.js": function(exports, require, module) {
      console.log("Loading: module3.js");
      require("src/utils/module1.js");
      console.log("Loaded: module3.js");
    },
  };

  // ロード済みモジュールの一覧を入れるオブジェクト
  var modules = {};
  // Node.jsのrequireのかわりに使う関数。
  // ここではわかりやすさのためにpathという変数名にしているが、
  // Node.jsのrequireと異なり、モジュールIDなら何でもよい。
  // (あらかじめバンドラーによってrequire()の呼び出しが変換されているため)
  function require(path) {
    // 読み込み済み、または読み込み中 (循環参照) のときはキャッシュされたモジュールを返す
    if (modules[path]) return modules[path].exports;

    // Node.js互換のCommonJS呼び出しロジック
    var module = modules[path] = { exports: {} };
    moduleDefs[path](module.exports, require, module);

    return module.exports;
  }

  // エントリポイントの読み込みを開始
  require("src/index.js");
})();
```

## まとめ

- Node.jsのモジュールシステムをブラウザでも使うための仕組みとして、モジュールバンドラーが生まれた。
- CommonJSモジュールバンドラーは、ビルド時にはファイルの収集と最低限の変換だけ行い、実行時にNode.jsのインポート処理を模倣するという方法で実装されている。

本稿で説明したものはCommonJSモジュールバンドラーの中核となる機能にすぎません。本シリーズの後半で、高機能アセットパイプラインとしてのWebpackを詳しく見ていきますが、その前にCommonJS以外のモジュールシステムに触れる予定です。

[←前](https://qiita.com/qnighy/items/faddc72389f6c94421e1) [目次](https://qiita.com/qnighy/items/16fdd8e58309a1f706a0) [次→](https://qiita.com/qnighy/items/0c3fd208e0356fa19cda)