LoginSignup
38
26

More than 3 years have passed since last update.

JSエコシステムぶらり探訪(2): Node.jsとCommonJS modules

Last updated at Posted at 2020-09-08

JSエコシステムの進化を語るにはNode.jsを避けて通ることはできません。Node.jsと、それ自身の持つモジュール機能について歴史的な背景を踏まえつつ説明します。

←前 目次 次→

Node.js

Node.jsは非同期I/Oを備えたサーバーサイドJavaScriptのための実行環境として2009年に登場しました。1 現在はサーバーサイドJavaScriptだけではなく、JavaScriptのビルド環境として無くてはならないものになっています。

要するにNode.jsは、PerlスクリプトやRubyスクリプトと同じようにJavaScriptのコードを実行するための環境です。

main.js
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のほうが伝統的な形式といえます。

util.js
var private_value = 42;
// exports.square = ... でもよい
module.exports.square = function(x) {
  return x * x;
};
main.js
var util = require('./util');
console.log(util.square(2)); // => 4
console.log(util.private_value); // => undefined

また、 module.exports に別の値を代入することもできます。この場合は exports にだけ代入しても有効ではないので注意する必要があります。

square.js
// module.exportsへの代入は必須。exportsにも代入しておくと後々便利
module.exports = exports = function(x) {
  return x * x;
};
// JavaScriptの関数はそれ自体オブジェクトなので、プロパティーに代入できる
exports.version = "0.1.0";

上の例では、 require は関数を返します。

main.js
var square = require('./square');
console.log(square(2)); // => 4
console.log(square.version); // => "0.1.0"

Node.jsにおいては最初に呼ばれるJavaScriptファイルもモジュールです。

main.js
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) {
  // ファイルの中身
});

moduleexports はあらかじめ以下のように初期化されたものと考えることができます。

var module = {}, exports = {};
module.exports = exports;
// その他、moduleの様々なプロパティーを設定

requiremodule オブジェクトをキャッシュしておき、eval終了後に module.exports の値を戻り値として返すと考えることができます。これにより module.exportsexports の関係についても説明がつきます。

モジュールの副作用とオブジェクトの同一性

以下のようなモジュールを考えます。

module1.js
console.log("Hello, world!");

var counter = 1;
exports.fresh = function() {
  return counter++;
};

このモジュールには以下の特徴があります。

  • モジュールのトップレベル処理に副作用がある。
  • モジュールが状態を持っている。

このような場合、モジュールの同一性を気にする必要が出てきます。全く同じ内容の module2.js をコピーとして作成し、以下のように main.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回インポートするようにしてみます。

main.js
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のパッケージシステムについて扱います。

←前 目次 次→


  1. Wikipediaの記述によると、それ以前にもサーバーサイドJavaScriptの技術自体は存在していたようです。 

  2. 根拠を探す余裕がなかったのでこのように書きましたが、実際のところNode.jsの初期のモジュールシステムをベースにしてCommonJSが生まれた可能性が高いと思います。 

  3. 他に、ファイルの読み取りが同期的に行われる点、複数回requireしたときにキャッシュする仕組み、巡回参照の処理などを考える必要がある 

  4. シンボリックリンクについては次回言及予定 

  5. これについて、より広く使われている名称があれば教えていただけるとありがたいです。 

  6. バンドラーによっては、ここに挙げたような例をうまく処理できてしまう場合もありますが、それでも一般的な場合を全てカバーするのは困難です。 

38
26
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
38
26