JSエコシステムの進化を語るにはNode.jsを避けて通ることはできません。Node.jsと、それ自身の持つモジュール機能について歴史的な背景を踏まえつつ説明します。
Node.js
Node.jsは非同期I/Oを備えたサーバーサイドJavaScriptのための実行環境として2009年に登場しました。1 現在はサーバーサイドJavaScriptだけではなく、JavaScriptのビルド環境として無くてはならないものになっています。
要するにNode.jsは、PerlスクリプトやRubyスクリプトと同じようにJavaScriptのコードを実行するための環境です。
console.log("Hello, world!");
$ node main.js
Hello, world!
CommonJS modules (CJS)
CommonJS modulesはNode.jsで主に使われているモジュール形式です。CommonJSという規格群の一部として2009年に登場し、生まれて間もないNode.jsのモジュールシステムもすぐにCommonJSに適合しました。 2
後述するESM (ES Modules) で置き換えられつつあり、CJSのほうが伝統的な形式といえます。
var private_value = 42;
// exports.square = ... でもよい
module.exports.square = function(x) {
return x * x;
};
var util = require('./util');
console.log(util.square(2)); // => 4
console.log(util.private_value); // => undefined
また、 module.exports
に別の値を代入することもできます。この場合は exports
にだけ代入しても有効ではないので注意する必要があります。
// module.exportsへの代入は必須。exportsにも代入しておくと後々便利
module.exports = exports = function(x) {
return x * x;
};
// JavaScriptの関数はそれ自体オブジェクトなので、プロパティーに代入できる
exports.version = "0.1.0";
上の例では、 require
は関数を返します。
var square = require('./square');
console.log(square(2)); // => 4
console.log(square.version); // => "0.1.0"
Node.jsにおいては最初に呼ばれるJavaScriptファイルもモジュールです。
var x = 42;
// global is Node.js equivalent of windows
// (There's also globalThis usable in both environments)
console.log(global.x); // => undefined
Node.jsのCJSの仕組み
CJSのrequireはNode.js (など、それぞれの処理系) が提供するプリミティブ関数ですが、その動作は比較的シンプルに理解できます。つまり、ファイルを発見して読み取り、関数に包んで eval
する処理と考えることができます。3
実際にevalされるときは以下のような関数の本体として扱われます。(つまり、 exports
, require
, module
, __filename
, __dirname
という5つのローカル変数があらかじめ存在するものとして扱われます。)
(function(exports, require, module, __filename, __dirname) {
// ファイルの中身
});
module
と exports
はあらかじめ以下のように初期化されたものと考えることができます。
var module = {}, exports = {};
module.exports = exports;
// その他、moduleの様々なプロパティーを設定
require
は module
オブジェクトをキャッシュしておき、eval終了後に module.exports
の値を戻り値として返すと考えることができます。これにより module.exports
と exports
の関係についても説明がつきます。
モジュールの副作用とオブジェクトの同一性
以下のようなモジュールを考えます。
console.log("Hello, world!");
var counter = 1;
exports.fresh = function() {
return counter++;
};
このモジュールには以下の特徴があります。
- モジュールのトップレベル処理に副作用がある。
- モジュールが状態を持っている。
このような場合、モジュールの同一性を気にする必要が出てきます。全く同じ内容の module2.js
をコピーとして作成し、以下のように main.js
から呼び出してみます。
var module1 = require('./module1'); // => Hello, world!
var module2 = require('./module2'); // => Hello, world!
console.log(module1.fresh()); // => 1
console.log(module1.fresh()); // => 2
console.log(module2.fresh()); // => 1
console.log(module2.fresh()); // => 2
console.log(module1.fresh === module2.fresh); // => false
両方の console.log
が実行され、 fresh
は別々にカウントされ、 fresh
のオブジェクトとしての同一性も false
になりました。
Node.jsでは、パスが同じものは同一モジュールになります4。先ほどの main.js
を書き換えて ./module1
を2回インポートするようにしてみます。
var module1a = require('./module1'); // => Hello, world!
var module1b = require('./module1'); // (no output)
console.log(module1a.fresh()); // => 1
console.log(module1a.fresh()); // => 2
console.log(module1b.fresh()); // => 3
console.log(module1b.fresh()); // => 4
console.log(module1a.fresh === module1b.fresh); // => true
console.log
は1回しか実行されず、 fresh
は同じカウンタを使うようになり、2つの fresh
関数はオブジェクトとしても同一になりました。
冠頭形モジュール
CJSのインポートは単なる require
という関数であり、どこでも呼び出すことができます。これは一見すると便利で妥当な設計に見えますが、実際はNode.js以外の環境にモジュールシステムを移植するにあたってこの「どこでも呼び出せる」という性質が邪魔になってきます。
そこで、以降で解説するモジュールシステムの理解を助けるために、本稿独自の用語として「(CJSの)冠頭形モジュール」という概念を導入します5。
定義. あるCommonJSモジュールが冠頭形である (is a prenex-form module) とは、以下を満たすことである。
- そのモジュールファイルはヘッダ部と本体に分けられる。 (ヘッダ部に続いて本体が来るものとする)
- ヘッダ部の各文は以下のいずれかの形式である。
var <変数名> = require(<文字列リテラル>);
let <変数名> = require(<文字列リテラル>);
const <変数名> = require(<文字列リテラル>);
require(<文字列リテラル>);
- 本体では
require
関数は使われていない。
冠頭形であれば、モジュールファイルの中身を実際にevalしなくても、あらかじめ依存先モジュールを決定することができます。
冠頭形ではないものの例としては以下のようなものがあります6。
- 条件つきインポート
-
require
の引数が動的に決まるようなインポート - 当該モジュール読み込み時ではなく、あとで必要になってから行うインポート
まとめ
特に重要なのが以下の点です。
- Node.jsによって「ブラウザー以外のJavaScript実行環境」が大きな地位を獲得した。
- Node.jsによって、JavaScriptに優れたモジュールシステムがもたらされた。
このことがJavaScriptに2つの大きな課題をもたらしました:
- Webブラウザーもモジュールシステムの恩恵を受けられるようにすること。
- Node.jsとWebブラウザーの間のコードの相互運用性を高めること。
これらの課題がJavaScriptバンドラーの誕生、そして各種の新しいモジュールシステムの提案へとつながっていくと考えられます。が、次回はその前に、Node.jsのパッケージシステムについて扱います。
-
Wikipediaの記述によると、それ以前にもサーバーサイドJavaScriptの技術自体は存在していたようです。 ↩
-
根拠を探す余裕がなかったのでこのように書きましたが、実際のところNode.jsの初期のモジュールシステムをベースにしてCommonJSが生まれた可能性が高いと思います。 ↩
-
他に、ファイルの読み取りが同期的に行われる点、複数回requireしたときにキャッシュする仕組み、巡回参照の処理などを考える必要がある ↩
-
シンボリックリンクについては次回言及予定 ↩
-
これについて、より広く使われている名称があれば教えていただけるとありがたいです。 ↩
-
バンドラーによっては、ここに挙げたような例をうまく処理できてしまう場合もありますが、それでも一般的な場合を全てカバーするのは困難です。 ↩