JavaScriptモジュールシステムのこれまでとこれから


JavaScriptは当初モジュールシステムを持っておらず、実装されるまでに長い時間がかかりました。
その過程で様々な手法が考案され、今なお統一されておらずプロジェクトによって異なる方式を採用しているのが現状です。
本稿ではJavaScriptがここに至った経緯と各種モジュールシステムの概要を紹介し、今後どのようにモジュールを設計していくべきかを考えます。

JavaScriptには複数の言語仕様が存在してとてもややこしいのですが、言語の仕様を策定する団体と各種プラットフォームで言語を実装する団体が異なる点に注意して読むと理解を助けると思います。

内容に誤りがある場合はご指摘いただけると幸いです。

年表

出来事
1995年 Brendan EichによってJavaScriptが開発される。
当初はLiveScriptと呼ばれていた。
1997年 EcmaインターナショナルがECMAScriptの初版をリリース。
JavaScriptの標準仕様。
ブラウザごとの非互換性の解消を目的とした。
2009年 Kevin DangoorがCommonJSプロジェクトを発足。
非ブラウザ環境におけるJavaScriptの標準仕様の策定を目的とした。
モジュールシステムが仕様化された。
2009年 Ryan DahlがNode.jsの初版をリリース。
CommonJSの仕様に基づくサーバーサイドJavaScript。
モジュールシステムが搭載された。
2010年 AMDの策定。
ブラウザで利用可能なモジュールシステム仕様。
2011年 RequireJSの登場。
AMDの仕様に基づくモジュールローダー。
ようやくモジュールシステムがフロントエンド開発にもたらされたが、やや冗長。
2011年 Browserifyの登場。
Node.jsに依存性解決させてフロントエンド用にバンドルする方式。
これによりCommonJSの記法で記述可能になる。
2012年 Webpackの登場。
2014年 Babelの登場。
ECMAScriptの最新機能を先取りするためのトランスパイラ。
2015年 EcmaインターナショナルがECMAScript Modulesをリリース。
2018年 Node.jsがECMAScript Modulesをサポート。

モジュールシステム

モジュールとはプログラムを構成する基本単位で、インターフェースが明確に定義されたひとまとまりの機能を持ちます。
1モジュールは1ファイルにまとめるのが一般的です。
モジュールを組み合わせることでアプリケーションやライブラリが構成されます。
モジュールシステムはモジュール間の依存関係を解決するための機構のことです。

また、JavaScriptにおいてモジュールは変数や関数をカプセル化するためにも用いられます。
次章でそのことを確認していきます。

原始的なモジュールシステム

JavaScriptにはNamespaceがなく、トップレベルで宣言された変数は全てグローバル変数になります。
不用意にグローバル変数を宣言してグローバルスコープを汚染することは保守性の観点から望ましくありません。

この問題を簡易的に解決するためにJavaScriptでは、公開モジュールパターン(Revealing Module Pattern)というアプローチが用いられてきました。
公開モジュールパターンはJavaScriptの関数がプライベートなスコープを形成するという性質を利用します。
具体的には即時関数を使って下記のように変数や関数を内包します。

Module2.js
var Module2 = (function() {
  function _privateFunc() {
    return Module1();
  }
  function publicFunc() {
    var tmp = _private();
    return tmp;
  }
  return publicFunc;
})();

ただし、Module2.jsの3行目を見るとModule1というモジュールに依存していることが分かります。
そのため、下記のようにModule1.jsを先に読み込む必要があります。

<script type="application/javascript" src="./Module1.js"></script>
<script type="application/javascript" src="./Module2.js"></script>
<script type="application/javascript" src="./main.js"></script>
main.js
Module2();

このアプローチには次の問題点があります。

  • モジュール自体の変数がグローバルスコープを汚染するのは避けられない
  • モジュールを読み込むためのHTTPリクエストが複数回生じるためパフォーマンスが悪い
  • モジュール同士に依存関係がある場合、読み込む順番を考慮する必要がある

CommonJS Modules

CommonJS(以下、CJSと記載)は非ブラウザ環境におけるJavaScriptの仕様策定を目的としたプロジェクトです。
その代表的な実装としてNode.jsがあります。

JavaScriptをサーバーで動かすために決定的に足りないのがモジュールシステムでした。
そこでCJSはJavaScriptの言語仕様を拡張し、独自のモジュールシステム仕様を策定しました。

前章のModule2はNode.jsを使って次のように書けます。

Module2.js
var Module1 = require('./Module1');

function _privateFunc() {
  return Module1();
}
function publicFunc() {
  const tmp = _privateFunc();
  return tmp;
}

module.exports = publicFunc;
main.js
var Module2 = require('./Module2');
Module2();

Node.jsの登場により、npmというパッケージマネージャーが生まれ、JavaScriptモジュールのエコシステムが発展していきました。

AMD

CJSは当初そのモジュールシステムをブラウザで利用することを想定していませんでした。
しかしながら、先述した通りブラウザ環境のJavaScriptでもモジュールシステムの不在による弊害を抱えていました。
そこで、ブラウザでも利用可能なモジュールシステムとしてAMD(Asynchronous Module Definition)という仕様が考案されました。某半導体メーカーのことではありません。
AMDの実装として、RequireJSなどが存在します。
これらのライブラリはモジュールローダーと呼ばれます。

RequireJSを使うとモジュールを次のように記述できます。

Module2.js
define(['./Module1'], function(Module1) {
  function _privateFunc() {
    return Module1();
  }
  function publicFunc() {
    const tmp = _privateFunc();
    return tmp;
  }

  return publicFunc;
});
main.js
require(['./Module2'], function(Module2) {
  Module2();
});
<script data-main="main.js" src="require.js"></script>

モジュールバンドラー

RequireJSなどのモジュールローダーの登場によってフロントエンド開発においてもモジュールシステムが利用可能となりました。
しかし、CJSに比べて記法がやや冗長でした。
また、依存性解決を非同期的なHTTPリクエストによって行うためパフォーマンス的なデメリットが生じます。
これらの問題を解決したのがBrowserifyなどのモジュールバンドラーです。

モジュールバンドラーはモジュールファイルを結合し、一つのJavaScriptファイルを静的に生成することで依存性解決を行います。
AMDと異なり依存性解決のためのHTTPリクエストが必要ないのでパフォーマンス的に優れています。

ファイル結合時にCJSからブラウザ互換のJavaScriptに変換するので、モジュールはCJSの記法で記述することができます。
モジュールバンドラーの登場によってブラウザでも間接的にCJSが利用できるようになり、ブラウザ用モジュールもnpmで管理が可能になりました。

現在はBrowserifyよりも、Webpackが主流となっています。

UMD

様々なモジュール規格が登場する中で開発者は次の3つの利用形式に対応する必要が出てきました。

  • グローバルオブジェクト
  • CJS Modules
  • AMD

これらの規格全てに対応したユニバーサルモジュールを作成するために、下記のようなモジュール定義が考案されました。

Module2.js
(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD
        define(['Module1'], factory);
    } else if (typeof module === 'object' && module.exports) {
        // CJS
        module.exports = factory(require('Module1'));
    } else {
        // Browser globals (root is window)
        root.Module2 = factory(root.Module1);
    }
}(typeof self !== 'undefined' ? self : this, function (Module1) {
    function _privateFunc() {
      return Module1();
    }
    function publicFunc() {
      const tmp = _privateFunc();
      return tmp;
    }

    return publicFunc;
}));

AMD、CJS、グローバルオブジェクトの順にフォールバックさせていくような書き方です。
このようなパターンをUMD(Universal Module Definition)と呼びます。
UMDは規格というよりもデザインパターンです。
用途によっていくつかのバリエーションがあります。

ECMAScript Modules

話はインターネット黎明期まで遡ります。
JavaScriptが生まれて間もない頃、ブラウザ毎にJavaScriptの仕様が異なる時代がありました。
それらを統一すべく、Ecmaインターナショナルという国際標準化団体がJavaScriptの標準仕様を策定しました。
ECMAScriptはEcmaインターナショナルのTC39という委員会が管理するJavaScriptの標準仕様です。
各種ブラウザの実装は基本的にECMAScriptの仕様に追随しています。

CJSやAMDに遅れて、ようやく2015年にECMAScript Modules(以下、ESMと記載)がリリースされました。
これで各種ブラウザが対応すれば、モジュールシステムが標準で動作する状況となりました。

ESMでは下記のような構文でモジュールを記述できます。

Module2.js
import Module1 from './Module1';

function _privateFunc() {
  return Module1();
}
function publicFunc() {
  const tmp = _privateFunc();
  return tmp;
}

export default publicFunc;
main.js
import Module2 from './Module2';
Module2();
<script type="module" src="./main.js"></script>

トランスパイラ

ESMがリリースされたものの、まだ各種ツールやブラウザの実装が追いついていない状況が生まれました。
これらの新機能をいち早く利用するために登場したのがBabelなどのトランスパイラです。

トランスパイラを利用することで、ESMで記述されたモジュールをブラウザ互換のJavaScriptに変換することができます。
Babelを単体で利用するというよりは、主にモジュールバンドラーのプラグインとして使います。

これからのモジュール設計

ここまでJavaScriptのモジュールシステムが歩んできた軌跡を振り返ってきました。
では、結局どのモジュールシステムを採用すればいいのか考察します。

理想的なモジュール

そもそも目指すべき理想のモジュールとは何でしょうか。

  • 後方互換性がある
  • 簡潔に記述できる
  • 読み込みが速い
  • どこでも実行できる

これらの要求をバランス良く満たせるのが理想的なモジュールではないでしょうか。

モジュール記法

ESMには次のようなメリットがあり、今後は徐々にESMに統一されていくことが予想されます。

  • ブラウザ互換
  • デフォルトでStrictモード
  • 非同期的モジュール読み込み
  • Tree-Shaking

Node.jsではバージョン10からCJSに加えて、ESMをサポートしています。
ファイルがESMで書かれているのか、CJSで書かれているのか下図のようなフローで判定が行われます。

ただし、ESMからCJSを呼び出すことは可能ですが、その逆は出来ません。
つまり、ESMでモジュールを公開したら、CJSのアプリケーションからは呼び出すことが出来ません。
ライブラリ開発など広範囲のサポートが必要な場合は注意が必要です。

モジュール記法の選択をチャートにまとめてみました。

※「Transpile(ESM) = UMD」はESMで記述したソースコードをUMDにトランスパイルすることを表しています。

ESM対応環境のみをサポートする場合はESMで書くのがよいでしょう。
もっと細かい条件を考えれば選択肢はこの限りではありませんが、考える目安にはなるかと思います。

依存性解決

ESMはブラウザ互換であり、主要なモダンブラウザは既にESMをサポートしています。
しかし、ブラウザ標準の依存性解決ではnpmパッケージのモジュール解決が出来ません。
また、依存性解決のために複数のHTTPリクエストが発行されるのは避けられません。

依存性解決には、引き続きWebpackなどのモジュールバンドラーを利用するのが良いでしょう。
モジュールバンドラーが担うのは依存性解決だけでなく、トランスパイルやミニファイも含みます。

Webpackの他に、RollupやParcelという選択肢もあります。
アプリケーション開発にはWebpack、ライブラリ開発にはRollupがオススメです。

一方、今後RequireJSなどのモジュールローダー系のライブラリは敢えて選択する必要はないでしょう。

結論

前方互換性を考えれば、なるべくESMで記述するのがよいでしょう。
結局、サポート環境に合わせてモジュールバンドラーでトランスパイルするというのが基本戦略となることは今までと変わらないと思います。
しかし、JavaScriptのエコシステムは着実に歩みを進めており、フロントエンドの変化はまだまだ続きそうです。

参考