モジュールバンドラーとは
前3回までで説明したNode.jsのモジュールシステムとnpmのパッケージシステムは、再利用可能なJavaScriptプログラムを作り、配布し、利用する仕組みとして非常によくできたものでした。そのため、このNode.jsのモジュールシステムをブラウザでも利用したいという需要が生じることになりました。正確には以下の2つの需要がありました。
- Node.js用に書いたプログラムをブラウザでも再利用したい。
- 純粋にブラウザ向けのJavaScriptコードでも、モジュールシステムやパッケージシステムを使いたい。
これらの願いを同時に叶えるべく、Browserifyをはじめとしたモジュールバンドラーというツールが開発されました。モジュールバンドラーは複数のJavaScriptモジュールを結合し、ひとつのJavaScriptファイルを生成します。
モジュールバンドラーの機能を持つツールは一つではなく、比較的有名なものでもBrowserify, Webpack, rollup.js, Parcel, FuseBox, esbuild など多くの実装がありますが、本稿では現在最もメジャーと思われる多機能バンドラー Webpack を代表例として扱います。
Webpackでバンドルしてみる
CommonJS modulesの説明に使った例を再掲します。
var util = require('./util');
console.log(util.square(2)); // => 4
console.log(util.private_value); // => undefined
var private_value = 42;
// exports.square = ... でもよい
module.exports.square = function(x) {
return x * x;
};
これをWebpackでバンドリングしてみます。
{
"scripts": {
"build": "webpack --mode none --entry ./src/main.js"
},
"devDependencies": {
"webpack": "^5.4.0",
"webpack-cli": "^4.2.0"
}
}
npm install
npm run build
これで dist/main.js
が生成されます。多くの場合はこれを <script>
から呼び出しますが、今回はブラウザの機能を使っていないので、そのままNode.jsで動かすことができます。
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)。
また、モジュールバンドルという機能自体はNode.jsとブラウザの環境差異を吸収しない1ため、環境差異については別途考慮する必要があります。本稿ではモジュールバンドル自体の性質のみを扱います。
条件つきrequire
この定数インポート条件は冠頭形モジュールよりも制約が緩やかで、**条件つき require
**を行うことができます。たとえばReactのエントリポイントは以下のように記述されています。
'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
の挙動を実行時に再現する」というアプローチになります。 2
-
バンドル時には以下を行います。
- 全ての
require
呼び出しを再帰的に収集し、収録するべきモジュールの一覧を確定する。 (このために定数インポートである必要がある) - モジュールにIDを振る (絶対パス3や番号などが使われる)
- 全ての
require
呼び出しを相対パスからIDに置き換える - モジュールのソースコードを、関数のリスト (オブジェクト) として1つのJavaScriptにまとめる。
- 全ての
-
実行時には以下を行います。
- 読み込み済みモジュールを管理するためのオブジェクトを用意する。
- バンドル済みのモジュール (関数) を実行する
require
関数を作る。 -
require
を使って、最初のモジュールを読み込む。
例として、以下のようなプログラムを考えます。
console.log("Loading: index.js");
require("./utils/module2");
require("./utils/module3");
console.log("Loaded: index.js");
console.log("Loading: module1.js");
console.log("Loaded: module1.js");
console.log("Loading: module2.js");
require("./module1");
console.log("Loaded: module2.js");
console.log("Loading: module3.js");
require("./module1");
console.log("Loaded: module3.js");
これをバンドルすると、以下のようなJavaScriptになります。 (Webpackの出力を参考に手書きし、説明を付与したものです)
// 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以外のモジュールシステムに触れる予定です。