やしログ

JavaScript のモジュールについて完全に理解する

created: 2022/05/08
js module の成り立ち含め、種々のモジュール仕様について調べました。

ES Modules やら CommonJS やらいろいろあってよく分かってなかったため、この機に完全に理解しようと思います。

モジュールシステムの歴史

まず、そもそも(JavaScript における)モジュールって何?なぜ必要なの?というところから立ち返ってみようと思い、歴史を紐解くこととしました。

昔々あるところに、JavaScript というものありけり

元々 js は、HTML ページに対してちょっとした動きをつけるためのものでしかありませんでした。そのため、 HTML に <script> タグを書き、その中に js を記述することで賄っていました。

しかしこの方法には以下のような課題があります。

  • コードの再利用ができない
    • 特定の HTML 専用コードになる
  • 機能(関数)の定義順に気をつける必要がある
    • ある関数を利用するコードは、関数定義より後ろに書かなければならない
  • 全ての関数と変数がグローバルスコープになる

そこで、 <script> タグに直接 js を書くのではなく、別ファイルとして切り出すようにしてみます。つまり <script src="hoge.js"></script> のようなかたちです。こうすることでコードの再利用はできるようになりましたが、依然として以下のような課題があります。

  • <script> タグの記述順に気をつける必要がある
    • 例えば hoge.js 内の関数を利用するコードが fuga.js にある場合、 fuga.js を読み込む <script> タグは hoge.js の読み込みより後ろに書かなければならない
  • 全ての関数と変数がグローバルスコープになる

※別ファイルへの切り出しに関しては、クライアント PC のスペックが高まったことにより、 frontend でよりたくさん処理ができるようになったという歴史的経緯から、 ブラウザアプリケーション(JavaScript)のコード量が増えていったという背景もありそうです。

ところで JavaScript には、即時関数実行式 と呼ばれる記法があります。英語では IIFE(Immediately-invoked function expression)と呼ばれます。これを使うと、先のグローバルスコープ汚染問題が軽減できます。なぜなら、必要な全ての関数と変数を含むオブジェクトを一つだけ作り公開することになるため、関数と変数は即時関数内のスコープに収まるためです。文で書くとややこしいですが、かの jQuery はまさにこのアプローチを取っています。全てが $ オブジェクトに入っていますね。

しかしこの場合でも、 <script> タグの記述順には気をつける必要があります。例えば jQuery 本体とそれを利用する hoge.js があったら、前者を先に読み込まなければなりません。

CommonJS

2009 年、 js をサーバーサイドで使うことについて議論がなされ、結果 ServerJS というものが生まれました。これは後に CommonJS という名前に変更されることになります。CommonJS は js ライブラリではなくて、標準化団体(プロジェクト)の名称です。 ECMA や W3C と同様ですね。

CommonJS は Web サーバー、デスクトップ、コマンドラインのアプリケーションに共通の API を定義することを目標とし、その中の一つとして、モジュールの仕様(API)も定義されています。backend においては HTML も <script> タグも無いため、モジュール API が用意されるのは半ば必然的とも言えそうです。

syntax は以下のようなかたちです。

// add.js
module.exports = function add(a, b) {
  return a + b;
};
// 利用側
var add = require(‘./add’);

Node.js には、この CommonJS スタイルのモジュール API が実装されています。

AMD と RequireJS

CommonJS スタイルのモジュール読み込みは、全て同期的であるという課題があります。つまり var add = require(‘./add’); のような箇所で、読み込む対象のモジュールが利用可能になるまで待つことになってしまいます。 backend においてはこれでも良かったのですが、 frontend においては利用可能になるまでブラウザが止まることになってしまうため都合が悪いです。

そこで CommonJS ではいくつかのモジュールスタイルが提案されました。そのうちの一つが後に AMD(Asynchronous Module Definition) と呼ばれるようになります。syntax は以下。

define([‘add’, ‘reduce’], function(add, reduce){
  return function(){...};
});

第二引数のコールバック関数の引数は、第一引数と同じ順番になります。コールバック関数の返り値が(公開される)モジュールになります。

この AMD スタイルのモジュールを読み込むためには、 RequireJS が必要になります。 RequireJS は JavaScript module loader です。名前とは裏腹に、 CommonJS の require syntax をサポートするためのものではありません(ややこしや...)。RequireJS を使うと、 HTML には以下のような <script> タグを書くだけでよくなります。

<script data-main="main" src="require.js"></script>

data-main 属性は RequireJS にアプリケーションのエントリファイルを教えるためのもので、デフォルトで .js ファイルだと仮定するようです。RequireJS はエントリファイルを読み込んだ後、その依存モジュール、依存モジュールのさらに依存...と、順々に読み込んでくれます。

CommonJS、AMD、RequireJS により、過去の方法にあったグローバルスコープ汚染問題や <script> タグ記述順問題は解決しました。スコープは基本各モジュール内に閉じ、各モジュールにおける依存関係のみを意識すればよくなりました。また、HTML から読み込むのは RequireJS のみでよくなりました。

しかし以下のような新たな課題が発生します...。

  • AMD syntax において、第一引数(依存関係リスト)の順番と第二引数(コールバック関数)の引数の順番が一致しなければならず、依存関係が多い場合に順序を維持するのが難しいことがある
  • HTTP 1.1 では、小さなファイルをたくさん読み込むとパフォーマンスが低下する恐れがある

Browserify

AMD の syntax に課題感を感じる人は少なくなかったようで、代わりに CommonJS を使いたい派がそこそこ居たようです。そこで、CommonJS スタイルのモジュールをブラウザアプリケーションでも使えるようにすれば良いのでは?という発想が生まれます。これを叶えたのが Browserify です。

Browserify は CommonJS スタイルモジュールの依存関係を解決し、単一のファイルにバンドルするモジュールバンドラです。HTML からはバンドル結果のみを読み込めば良いことになります。

UMD

ここまでいろいろなスタイルのモジュールが出てきましたが、結局どのスタイルで書けばよいのか?という疑問が生まれます。CommonJS、AMD、...と各種用意するのも手ですが、管理数が増えるしモジュール利用側はダウンロードするモジュールの種類を意識する必要が出てきます。

そこでこれらの差分吸収として、UMD(Universal Module Definition)というものが生まれました。これは、現在の環境がサポートするモジュールスタイルを識別するための if/else 文です。例としては以下のような感じ。

// sum.umd.js
(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD
    define(['add', 'reduce'], factory);
  } else if (typeof exports === 'object') {
    // Node, CommonJS-like
    module.exports = factory(require('add'), require('reduce'));
  } else {
    // Browser globals (root is window)
    root.sum = factory(root.add, root.reduce);
  }
})(this, function (add, reduce) {
  // private methods

  // exposed public methods
  return function (arr) {
    return reduce(arr, add);
  };
});

ES6 module syntax

JavaScript は言語仕様としてモジュールシステムを持っていませんでした。そのため、これまで挙げてきたようなたくさんの方法が生まれてきました。しかし ES6 からはモジュールシステムが提供されるようになりました。これが ES Modules です。従ってこれからは基本的に ES Modules を使えばよいことになります。ES Modules では、以下のように import export キーワードによりモジュールを import または export します。

// main.js
import sum from './sum';

var values = [1, 2, 4, 5, 6, 7, 8, 9];
var answer = sum(values);

document.getElementById('answer').innerHTML = answer;
// sum.js
import add from './add';
import reduce from './reduce';

export default function sum(arr) {
  return reduce(arr, add);
}

しかし古いブラウザではサポートされていない syntax のため、これらをサポートする場合には課題があります。この後 Webpack 等の各種モジュールバンドラが発展し、このような下位互換性の問題解決や、異なるスタイルのモジュールのバンドルがサポートされていくことになります。

Node.js におけるモジュールシステムについて

Node.js では元々 CommonJS スタイルのモジュールシステムが確立され、その後 ES Modules のサポートがなされたという経緯から、現状複数のモジュールスタイルを扱うことができるようになっています。この判別方法は以下のようになっています。

  1. まず、拡張子による判断を行う

    .cjs なら CommonJS、.mjs なら ES Modules

    .js の場合、 2. へ

  2. package.jsontype フィールドの値による判断を行う

    commonjs または type フィールド無指定なら CommonJS

    module なら ES Modules

package.json は、対象ファイルから一番近い場所にあるものの値が採用されます。対象ファイルと同ディレクトリに package.json が無い場合は上位ディレクトリの package.json を順次探します。また、拡張子が .cjs または .mjs のファイルは、 type フィールドの影響を受けません。

Node.js のドキュメントでは、 type フィールドを明示することを推奨しているようです。

次に、両モジュールシステムの相互互換性について見ていきます。

TypeScript の ES Modules 対応について

TypeScript で ES Modules を作り、パッケージとして提供したい場合については以下のような状況でした。

  • TypeScript 及び Babel による ES Modules → CommonJS 変換結果が、 Node.js における CommonJS Interop の仕様と異なっている
    • このため、 default export された値を default import できない
    • moduleResolution: node が Node.js の ES Modules 仕様と異なる
      • TypeScript が ES Modules に変換したパッケージに同梱された型定義が、Node.js の ES Modules 仕様と異なるため、 Node.js から default import しようとすると型エラー or ランタイムエラーのどちらかになってしまう
  • TypeScript で ES Modules を出力できない
    • .mjs を入出力できない

これら課題の一部が、 TypeScript v4.7 で解決される運びとなりました。一度 v4.5 でリリースされかけたのですが、 nightly release での検証を重ねることとなり取りやめられ、v4.7 で改めて正式リリースとなったようです。

リリース予定の仕様は以下のようになっています。

  • tsconfig の compilerOptions で module: node12 を指定すると、 TypeScript がモジュールを Node.js v12 相当として扱うようになる
    • v12 は、 Node.js で ES Modules が使えるようになったバージョンらしいです
  • top level await を使いたい場合は、 module: nodenext を指定する
  • これらを指定すると、 package.jsontypeexports imports フィールドの値に従うようになります。例えば、 .ts ファイルは typemodule にすれば ES Modules として出力できるようになるわけです。
    • また、 .mts .cts がサポートされるようになり、これらはそれぞれ .mjs .cjs に出力されます。
    • また、 import 文の path に関する仕様が Node.js の ES Modules 仕様に準拠するようになります。つまり、拡張子を省略できなくなり、ディレクトリを指定して index.ts を読み込むこともできなくなります。注意点として、指定する拡張子は ES Modules 出力後 のファイルのものを指定しなければなりません。 .ts.mts ではなく、 .js.mjs を指定する必要があります。

ES Modules と CommonJS の相互互換性については以下の通りです。

  • ES Modules から CommonJS を読み込む場合
    • import を(拡張子付きで)書けば 🙆
  • CommonJS から ES Modules を読み込む場合
    • dynamic import を使えば 🙆
      • 従来は module: commonjs のとき dynamic import が require() に変換されてしまい読み込めないという問題があったらしいですが、 module: node12 では変換されずにそのまま残るため問題なくなったようです。

なお、 default export の Node.js との互換性については未解決となっています。従って現状では、パッケージを提供する場合には default export を避けるのが無難なのかもしれません... 🤔

参考にした記事

ありがたや...。