2017年7月18日
JavaScript モジュールの現状
(2017-05-24)by Johannes Ewald
本記事は、原著者の許諾のもとに翻訳・掲載しております。
(注:2017/07/19、いただいたフィードバックを元に翻訳を修正いたしました。)
最近、 Twitter では、 ESモジュール の現状、特に、 *.mjs
をファイル拡張子として導入すると決めた Node.js の現状について大騒ぎになっています。この話題は複雑で、かなりの労力を費やしてそれに専念しないと議論について行けないので、 皆が恐れと不安を抱く のも無理はありません。
古き恐れ
フロントエンド開発者なら、 JavaScriptの依存関係の管理に悩まされた日々 を憶えている人も多いでしょう。あの頃は、ライブラリをベンダーフォルダにコピー&ペーストし、グローバル変数に依存し、あらゆる物を正しい順序でconcatしようとしてもネームスペースの問題に対処する必要がありました。
何年もかかって、私たちは共通モジュール形式と中央集権型モジュールレジストリの価値を認めることを学んできました。
今では、JavaScriptライブラリを公開するのも利用するのも簡単になりました。文字どおり、 npm publish
と npm install
だけです。だから、異なるモジュールシステム間の互換性の問題を耳にすると、みんな心配になるのです。 この快適さを失いたくないからです。
この記事では、実装の現状について、また、ESモジュール(ESM)への移行がNode.jsのエコシステムを傷つけないと私が考える理由について説明し、まとめます。最後に、この変化がwebpackユーザとモジュール作者にとってどんな意味があるのかについて概説します。
現在の実装
現在、実際に使われているESMの実装は次の3つです。
- 現行のブラウザ内
- webpackや、それに類似するビルドツールで
- Node.jsにおけるもの(未完成ですが、 年末にはβ版が出るもよう )
今回の議論を理解するには、 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
として構文解釈されるという点も重要です。 script
を import
する方法はありません。
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モードを暗示し、 this
を undefined
で初期化するからです。
しかし、webpackを使うと、次の結果が得られます。
(function(module, exports) {
console.log(this);
})
ブートストラップ時に、 this
は、Node.jsでCJSの挙動と一致する exports
を指します。その理由は、 script
と module
の文法がまぎらわしいからです。パーサーには、そのモジュールが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はエッジケースとトレードオフの評価に当たり、素晴らしい役割を果たしました。例えば、異なるファイル拡張子を使うことは、この問題のとても簡単な解決策です。
事実、ファイル拡張子はバイナリファイルがどのように解釈されるべきかの基本的なヒント なの です。 module
が script
でない場合は、異なるファイル拡張子を使う べき なのです。LinterやIDEのような他のツールでも同様の情報を得ることができます。
もちろん新しい拡張子の導入にはそれなりに犠牲が伴いますが、いったんサーバや他のアプリケーションによって *.mjs
がJavaScriptとして認識されれば、議論があったことさえ忘れてしまうでしょう。
*.mjsはNode.jsのPython 3になるのか?
全ての制約を考えると、この移行がどのようなダメージをエコシステムに与えることになるのか疑問に思えてくるでしょう。CTCはできる限り荒い部分もスムーズにしたが、コミュニティによってどのように適用されるか不確実な点はまだ多いのです。 著名なNPMモジュール作者らは 絶対に *.mjs
をモジュールに使用しないという主張によって、この不確実性は強調されています。
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の恩恵を受けることができるでしょう。
まとめ
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äinen と Karl Horky に感謝します。
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa