JavaScript モジュールの現状

(注:2017/07/19、いただいたフィードバックを元に翻訳を修正いたしました。)

1-OyugeWjSxQ4a7DanyRUQig
ESM、CJS、UMD、AMD  — どれを使うべき?

最近、Twitterでは、ESモジュールの現状、特に、*.mjsをファイル拡張子として導入すると決めたNode.jsの現状について大騒ぎになっています。この話題は複雑で、かなりの労力を費やしてそれに専念しないと議論について行けないので、皆が恐れと不安を抱くのも無理はありません。

古き恐れ

フロントエンド開発者なら、JavaScriptの依存関係の管理に悩まされた日々を憶えている人も多いでしょう。あの頃は、ライブラリをベンダーフォルダにコピー&ペーストし、グローバル変数に依存し、あらゆる物を正しい順序でconcatしようとしてもネームスペースの問題に対処する必要がありました。

何年もかかって、私たちは共通モジュール形式と中央集権型モジュールレジストリの価値を認めることを学んできました。

今では、JavaScriptライブラリを公開するのも利用するのも簡単になりました。文字どおり、npm publishnpm installだけです。だから、異なるモジュールシステム間の互換性の問題を耳にすると、みんな心配になるのです。この快適さを失いたくないからです。

この記事では、実装の現状について、また、ESモジュール(ESM)への移行がNode.jsのエコシステムを傷つけないと私が考える理由について説明し、まとめます。最後に、この変化がwebpackユーザとモジュール作者にとってどんな意味があるのかについて概説します。

現在の実装

現在、実際に使われているESMの実装は次の3つです。

今回の議論を理解するには、ES2015で、次の2つの異なるモードが導入されたことを知っておくことが重要です。

  • script:グローバル名前空間を必要とする標準的なスクリプトのためのもの
  • module:明示的なインポートとエクスポートを必要とする、モジュール化されたコードのためのもの

scriptモードでimport文またはexport文を使おうとすると、SyntaxErrorが起こります。これらの文は、グローバルなコンテキストでは無意味です。一方で、moduleモードはstrictモードを必ず必要とし、このモードではwith文などの言語機能が禁じられます。よって、モードの定義は、スクリプトの構文解析と実行の前に、行う必要があります。

ブラウザ内のESM

2017年5月の時点で、全ての主要ブラウザでESMの動作可能な実装がリリースされています。ほとんどはまだβ版ですが。これについてはJake Archibaldが素晴らしい記事を書いているので、詳細には触れません。

多少の困難はあっても、ブラウザにはそれ以前にモジュールシステムがなかったので、実装はかなり簡単でした。moduleモードを指定するには、次のように、scriptタグにtype="module"属性を追加する必要があります。

<script type="module" src="main.js"></script>

現在、モジュール内では、モジュール指定子として使用できるのは有効なURLのみです。モジュール指定子とは、他のモジュールを要求またはインポートするために使用する文字列です。CJSモジュール指定子との将来の互換性を確保するために、import "lodash"のような、 “裸の” インポート指定子は、まだサポートされていません。モジュール指定子は、絶対URLであるか、または/./、もしくは../で始まらなければなりません。

// Supported:
import {foo} from 'https://jakearchibald.com/utils/bar.js';
import {foo} from '/utils/bar.js';
import {foo} from './bar.js';
import {foo} from '../bar.js';

// Not supported:
import {foo} from 'bar.js';
import {foo} from 'utils/bar.js';
// Example from https://jakearchibald.com/2017/es-modules-in-browsers/

また、モジュールの中ではインポートはその都度moduleとして構文解釈されるという点も重要です。scriptimportする方法はありません。

webpackでのESM

webpackのようなビルドツールは、通常、moduleモードでコードを構文解析しようとします。不具合が起こると、scriptに戻ります。こういったツールの結果はscriptとモジュールランタイムで、このランタイムは通常、CJSとESMの両方の挙動を一定の程度シミュレートします。

例として、2つの簡単なESMを見てみましょう。


webpackは関数ラッパーを使ってモジュールスコープとオブジェクト参照をカプセル化し、ESMライブバインディングをシミュレートします。また、コンパイルごとに、モジュールのブートストラップとキャッシングを担当するモジュールランタイムも含まれています。さらに、モジュール指定子を数値モジュールIDに変換します。これにより、バンドルサイズとブートストラップまでの時間が短縮されます。

それにはどんな意味があるのでしょうか?コンパイル出力を見てみましょう。


ESモジュールの挙動をシミュレートする、簡略化されたwebpack出力

この例に関係ないコードをいくつか簡略化して削除しました。これを見ると、webpackはexport文を全てexportオブジェクトのObject.definePropertyに置き換えます。また、インポートされた値への参照を全てプロパティアクセサに置き換えます。さらに、各ESMの最初には、"use strict"ディレクティブがあります。これは、ESMのstrictモードを説明するためにwebpackが追加したものです。

この実装はシミュレーションです。ESMとCJSの挙動の模倣を試みますが、複製するのではありません。例えば、一定のエッジケースには対応できません。次のモジュールを考えてみましょう。

console.log(this);

これを、babel-preset-es2015を使ってBabelで実行すれば、次の結果が得られます。

“use strict”;
console.log(undefined);

この出力から判断すると、BabelはデフォルトでESMを仮定すると思われます。その理由は、moduleモードはstrictモードを暗示し、thisundefinedで初期化するからです。

しかし、webpackを使うと、次の結果が得られます。

(function(module, exports) {
console.log(this);
})

ブートストラップ時に、thisは、Node.jsでCJSの挙動と一致するexportsを指します。その理由は、scriptmoduleの文法がまぎらわしいからです。パーサーには、そのモジュールがESMなのかCJSなのか分かりません。最もよく使われるモジュールスタイルは今でもCJSなので、はっきりしない場合には、webpackはCJSをシミュレートします。

モジュールの作者は通常、この種のコードを避けるので、多くの場合、このシミュレーションは上手くいきます。しかし、 “多くの場合” というだけでは、全ての有効なJavaScriptコードの実行がサポートされている、Node.jsのようなプラットフォームには不十分です。

Node.jsでのESM

Node.jsは、まだCJSをサポートする必要があるので、ESMの実装には苦労が伴います。構文は似ているように見えますが、ランタイムの挙動は全く異なります。Node.js Core Technical Committee(CTC)のメンバーであるJames M Snellが、CJSとESMの違いを説明する素晴らしい記事を書いています。

詰まるところ、CJSは動的なモジュールシステムで、ESMは静的だということです。

CJS

  • 動的な同期require()が許可される
  • モジュールを評価した後にはじめてエクスポートが分かる
  • モジュールが初期化された後でもエクスポートの追加、置換、削除が可能

ESM

  • 静的な同期importのみが許可される
  • インポートとエクスポートはモジュールを評価する前にリンクされる
  • インポートとエクスポートは不変

CJSはES2015より古いので、必ずscriptモードで構文解析されてきました。カプセル化を実現するには関数ラッパーを使用します。Node.jsでCJSをロードすると、実際には次のコードに似たものを実行します。


Node.jsでCommonJSモジュールを包む簡略な関数ラッパー

両方のモジュールシステムを同じランタイムに統合しようとした時に問題は発生します。例えば、ESMとCJSの間の循環依存の関係はすぐにデッドロックに似た状況になってしまいます。

しかしながら、既存のCJSモジュールの数が多いため、CJSサポートを無くしてしまうことも選択肢とはしませんでした。Node.jsエコシステムの崩壊を免れるためには次のことは明確です。

  • 既存のCJSコードが同様に継続して機能できる必要がある。
  • 両方のモジュールシステムが同時に、できるだけスムーズに機能できる必要がある。

現在のトレードオフ

2017年3月に数カ月にも及んだ議論の末、やっとCTCは実現方法を見つけました。ES仕様やエンジンへの変更なしにはスムーズな統合ができないため、CTCはいくつかのトレードオフを含んだ実装を始めることを決断したのです。

1. ESMには*.mjsファイルの拡張子が必要である

これは上でも説明したとおり曖昧な文法に起因します。構文解析しただけでは、目の前のJavaScriptコードがどのような種類のものか必ずしも分かる訳ではありません。後方互換性がNode.jsの主要目的となっているため、作者は新規モードに頭を切り替える必要があります。他の選択肢についてのあらゆる議論もされましたが、最もトレードオフの小さい解決策が異なるファイル拡張子でした。

2. CJSは非同期import()を介してのみESMをインポートできる

ブラウザの挙動にできるだけ近づけるようにNode.jsによってESMは非同期でロードされます。そのため、ESMの非同期require()は不可能となります。その結果、ESMに依存する全ての関数も非同期である必要があります。

3. CJSはESMへの単一で不変なデフォルトエクスポートを公開する

Babelやwebpackを使えば、下記のように大抵の場合CJSをESMにリファクタリングできます。

// CJS
const { a, b } = require("c");
// ESM
import { a, b } from "c";

ここでも構文は似ていますが、CJSには名前付きエクスポートがないということは無視されています。defaultと呼ばれるエクスポートが1つあるだけで、これはCJSモジュールの評価後に行うmodule.exportsの不変スナップショットと同じことになります。技術的には、module.exportsの構造分解し名前付きインポートにすることは可能ですが、より大規模な仕様変更が必要となります。このため、今はこの道を進むことをCTCは決断したのです。

4. moduleやrequire、_filenameなどのモジュールスコープの引数はESMには存在しない

Node.jsとブラウザはこれらに対応するもののいくつかをESMに実装する予定ですが、標準化に関してはまだ作業中です。

エンジニアリングの意味での課題はCJSとESMを1つのランタイムに統合することです。CTCはエッジケースとトレードオフの評価に当たり、素晴らしい役割を果たしました。例えば、異なるファイル拡張子を使うことは、この問題のとても簡単な解決策です。

事実、ファイル拡張子はバイナリファイルがどのように解釈されるべきかの基本的なヒントなのです。modulescriptでない場合は、異なるファイル拡張子を使うべきなのです。LinterやIDEのような他のツールでも同様の情報を得ることができます。

もちろん新しい拡張子の導入にはそれなりに犠牲が伴いますが、いったんサーバや他のアプリケーションによって*.mjsがJavaScriptとして認識されれば、議論があったことさえ忘れてしまうでしょう。

*.mjsはNode.jsのPython 3になるのか?

全ての制約を考えると、この移行がどのようなダメージをエコシステムに与えることになるのか疑問に思えてくるでしょう。CTCはできる限り荒い部分もスムーズにしたが、コミュニティによってどのように適用されるか不確実な点はまだ多いのです。著名なNPMモジュール作者らは絶対に*.mjsをモジュールに使用しないという主張によって、この不確実性は強調されています。

1-kZcrQrQ-1R0y6CIVrabxiw
Python 3によってPythonは死に追い込まれています

どのような反応をコミュニティがするのか予測するのは難しいのですが、エコシステムの大崩壊にはならないと思います。さらに、CJSからESMへのスムーズな移行が見られると思います。その理由は2つあります。

1. CJSとの厳密な後方互換性

ESMに抵抗があるモジュール作者は、CJSをそのまま使い続けることができますし、排除されることもありません。ESMを取り入れてもコードへの影響はありませんし、他のランタイムへの移行の可能性を低くします。NPM規模のエコシステムで時間のかかるコード移行を簡単にもしてくれます。CJSからESMへのリファクタリングはパッケージ保持に負荷を掛けますし、掛けるだけの時間もないと思います。

2. CJSのESMへのスムーズな統合

CJSモジュールをESMからインポートするのは特段難しい訳ではありません。CJSエクスポートが扱うのは1つのデフォルト値だけだということを覚えておけばいいのです。ESM内部にさえ入れば依存関係にどのモジュールタイプを使っていることを気にすることもありません。CJSのawait import()と比較してください。

上述した利点やtree shaking、変更不要でブラウザ互換性が得られるというような利点がESMにはあるため、これからの数年間はゆっくりと安定的にESMへ移行していく傾向が見られると思います。動的require()やモンキーパッチエクスポートなどのCJSだけの機能については、今までもNode.jsコミュニティで物議を醸してきまし、ESMの強みに勝るということでもありません。

つまりどういうこと?

近年出来事を考えてみると、あらゆる選択肢や制約によって簡単に混乱してしまいます。次のセクションでは、開発者が直面する特有の疑問に対して解答を述べています。

既存のコードをリファクタリングする必要は?

ありません。Node.jsはESMの実装を始めたばかりですので、完成までには時間がかかります。James M Snell曰く最低あと1年は掛る見込みで、変更点はさらに出てくる可能性があるため、現時点でリファクタリングするのは危険でしょう。

新しいコードでESMは使用するべき?

  • Webpackビルドのようなビルドステップがすでにある場合、あるいはビルドステップに抵抗がない場合はESMを使用するべきでしょう。コードベースの移行を簡単にしてくれ、tree shakingを可能にしてくれます。しかし、注意すべき点は、Node.jsにネイティブESMサポートが導入されたら、恐らくリファクタリングが必要なところがでてきます。
  • ライブラリを書いているのであれば、使用するべきです。モジュールユーザにとってtree shakingは役立つでしょう。
  • ビルドステップがいらない、あるいは、Node.jsアプリを書いているわけでないのであれば、CJSを使用するべきです。

今はESMに.mjsを使用するべき?

いいえ。使う利点はないし、ツールのサポートがまだ弱いです。Node.jsでネイティブESMサポートの提供が開始されてから移行することをお勧めします。ブラウザがきにするのはMIMEタイプのみで拡張子ではないということを忘れないでください。

ネイティブブラウザの互換性は考慮するべき?

ある程度は考慮すべきでしょう。ブラウザは完全なURLを必要とするため、これからはインポート文書の中から.js拡張子を省くべきではありません。ブラウザはNode.jsが行うようなパスルックアップを行うことはできません。同じように、index.jsファイルも避けるべきでしょう。しかし、そのままのインポートはまだ不可能であるため、近い将来において、NPMパッケージをブラウザに使い始める人はいないと思います。

ライブラリ作者として何を提供するべき?

ESMを書いて、Rollupやwebpackを使って単一のCJSモジュールにトランスパイルします。package.json内のメインフィールドをこのCSJバンドルにポイントするように設定します。さらにmoduleフィールドを使って元のESMにポイントするようにします。ESM以外の新しい言語機能を使用している場合は、ES5にまずコンパイルしてCJSとESM両方のバンドルを提供するといいでしょう。このようにすれば、作成しライブラリのユーザは、あなたのコードをトランスパイルせずにtree shakingの恩恵を受けることができるでしょう。

1-oIpcKRUPKPCHCIBvCUdBcQ
Tree shakingされたモジュールがたくさん

まとめ

ESモジュールに関しては不明なことが多々あります。現在のNode.jsの実装によって生じるトレードオフがNode.jsのエコシステムを崩壊させてしまうのではないかと開発者は危惧しています。

2つの理由からそうならない可能性が高いでしょう。CJSとの厳密な後方互換性とESM内でのCJSのスムーズな統合です。

Node.jsによってネイティブESMサポートが提供されるまでは、Rollupやwebpackのようなツールを使った方がいいでしょう。ESM環境をある程度シミュレーションすることができます。しかし、仕様に完全に準拠しているわけではないことに注意してください。その上、NPMパッケージをブラウザで使用できるようになってもバンドル機能を使い続ける正当な理由があります。

我々webpackチームでは、移行をよりスムーズに実行できるよう頑張っています。これを実現するため、Node.jsのネイティブESMサポートが成熟したらNode.jsのCJSインポート方法をシミュレーションする計画を立てています。

Juho VepsäläinenKarl Horkyに感謝します。