モジュールとはスコープを持つ1つのファイルのこと
ESモジュールが登場する前は、<script>タグを書くたびに変数がグローバル空間にばらまかれ、読み込み順によって何が見えるかが決まっていました。JavaScriptのESモジュールはこの問題をスッキリ解決してくれます。各ファイルが独自のスコープを持ち、exportで明示的に公開しない限り外には漏れません。逆に、importで明示的に取り込まない限り、外から何かが入ってくることもありません。
2つのファイルで、片方が公開し、もう片方が取り込む例を見てみましょう。
add と multiply は math.js の中で定義されていますが、main.js から見えるようになるのは import があるからです。math.js 内のそれ以外のもの(ヘルパー関数や定数など)は、外部からは一切アクセスできません。
ここから導かれる2つのルールは、早めに身につけておくと便利です。
- モジュールは自動的に strict モードで実行されます。
'use strict'を書く必要はありません。 - トップレベルの
thisはグローバルオブジェクトではなくundefinedになります。
名前付きexport:その場で書き出す
もっともよく使われる形です。function、class、const、let の前に export を付けるだけで、そのモジュールの公開APIになります。
波括弧の中に書く名前は、export した名前と完全に一致させる必要があります。たとえば import { circlearea } のように書くとエラーになります。すでに同名のものがあって衝突してしまう場合は、as を使って import 時にリネームしましょう。
ファイルの末尾でまとめて export する書き方もできます。「このファイルが外部に公開する API はこれだよ」と一目で分かるので、この書き方を好む人も多いですね。
どちらの書き方でも結果は同じです。プロジェクト内ではどちらかに統一しましょう。
デフォルトエクスポート(export default):1ファイルに1つ
モジュールには、ひとつだけ デフォルト エクスポートを設けることもできます。export default は、そのファイルの主役が明確に1つだけのとき——コンポーネント、クラス、設定オブジェクトなど——に向いています。
注目すべきポイントは3つあります。
- import 側で
logを波括弧で囲んでいない。 - インポート時の名前は自由に決められる。
import shout from './logger.js'と書いても、まったく同じように動く。 - 1つのファイルにつき default export は1つだけ。2つ目を書こうとするとパースエラーになります。
名前付きexportとexport defaultは、同じファイルに共存できます。
デフォルトを先に書き、その後に名前付きを波カッコで囲みます。この順番は決まっています。
どちらを使うべき? 名前付きexportの方がリファクタリングしやすいです。import側も必ず同じ名前を使うので、コードベース全体でのリネームはfind-and-replace一発で済みます。一方、export defaultは柔軟ですが、呼び出し側で好きな名前を付けられてしまうため、grepしにくくなります。最近のスタイルガイドの多くは名前付きexportを推奨していて、defaultは本当に単一目的のモジュールに限定する方針です。
まとめてimport、再export、副作用import
他にもよく見かけるimportの書き方をいくつか紹介します。
名前付きexportをまるごと名前空間オブジェクトとして取り込むには:
現在のスコープにインポートせず、別のモジュールからそのまま再エクスポートすることもできます。
このパターンは、ライブラリのエントリーポイントで内部ファイルから各パーツを集めて、ひとつの公開インターフェースにまとめるときによく使われます。
最後に、バインディングをまったく指定しないimportもあります。副作用だけが目的のモジュール(polyfill、CSS-in-JS、ハンドラーの登録など)で使う書き方です:
ファイルは一度だけ実行され、名前付きで何かをインポートすることはありません。
importは静的で、しかもライブバインディング
importには、知っておくと役立つちょっと意外な性質が2つあります。
静的であること。 import宣言は、コードが動き出す前の段階で解決されます。なのでifの中や関数の中、tryブロックの中に書くことはできません。パスも変数ではなく文字列リテラルで指定する必要があります。この制約があるおかげで、コードを実行せずに依存関係を解析できるわけです。バンドラー、型チェッカー、Tree Shakingといったツールはすべてこの仕組みに支えられています。
// 許可されていません — SyntaxError。
if (userWantsFancy) {
import { fancy } from './fancy.js';
}
条件付きで読み込みたい場合は、import() を使います(次のセクションで解説)。
ライブバインディング。 import で取り込んだ値は、スナップショット(値のコピー)ではなく、export 元への読み取り専用の参照です。そのため、export 側で値を再代入すると、import 側にも新しい値がそのまま反映されます。
インポートした側で再代入することもできません。main.js で count = 5 と書けばエラーになります。import はあくまで読み取り専用のビューだからです。
動的import()で必要なときだけ読み込む
モジュールを読み込むかどうかを 実行時 に判断したい場面——重めの機能、ルート単位のコード分割、条件付きのポリフィルなど——では、import() を関数として呼び出します。戻り値はモジュールのエクスポートに解決される Promise です:
通常の関数呼び出しなので、次のようなことができます。
async関数の中でawaitして使う。- パスを変数で渡す。
ifやtry/catchの中で呼び出す。
解決された(resolve された)オブジェクトを分割代入で受け取る書き方は、静的な import と同じです。
default は、デフォルトエクスポートを分割代入で取り出すときのキーです。好きな名前に付け替えて使えます。
実際のユースケースとしては、コード分割(ユーザーが「チャートを表示」をクリックしたときだけチャートライブラリを読み込む)、機能検出ベースのポリフィル、実行時に読み込まれるプラグインなどがあります。
ブラウザとNodeでESモジュールを動かす
構文はどの環境でも同じで、違うのはランタイムがファイルをどうやって見つけて読み込むか、という部分だけです。
ブラウザで動かす場合は、エントリーとなるスクリプトをモジュールとして指定します。
<script type="module" src="./main.js"></script>
type="module" を付けると、ブラウザは import / export を認識し、コードを strict モードで実行したうえで、HTML のパースが終わるまで実行を遅延してくれます。パスは相対パス(./、../)か絶対 URL を指定する必要があり、import 'lodash' のような裸の指定子(bare specifier)は、import map やバンドラーがないと動きません。
Node.js の場合、モジュールとして扱う方法は2つあります。
- ファイル名を
.mjsにする - 一番近い
package.jsonに"type": "module"を指定する(こうするとすべての.jsファイルがモジュール扱いになります)
また、Node.js では拡張子まで含めたフルパスの指定が必須です。import './utils' ではなく import './utils.js' と書く必要があります。
// package.json
{
"type": "module",
"main": "./index.js"
}
どちらの環境でも、ネイティブのESMでは拡張子を明示的に書く必要があります。開発中はバンドラー(Vite、webpack、esbuild)が拡張子なしのパスを解決してくれるので便利ですが、これに頼りきると、ビルドを通さない限りソースがそのままでは動かなくなってしまいます。
よくあるハマりどころ
ESモジュールでつまずきやすいポイントをいくつか挙げておきます。
- ブラウザで
type="module"を書き忘れる。 これがないと<script>はクラシックスクリプトとして実行されるので、importは構文エラーになります。 - Nodeでファイル拡張子を省略する。
import './utils'はエラー、import './utils.js'ならOKです。バンドラーはこれを吸収してくれますが、ネイティブのランタイムは容赦しません。 - ESモジュール内で
__dirnameやrequireを使おうとする。 これらはCommonJS専用です。ESMではimport.meta.urlを使い、パスが必要なら変換してください。 - 値が準備できる前に参照してしまう循環インポート。 2つのモジュールが互いをインポートすること自体は合法ですが、まだ代入されていないexportを読むと
undefinedになります。初期化時にその循環を踏まないようコードを構成するか、そもそも分割してしまいましょう。 importを条件付きで書こうとする。 静的なimport文では不可能です。実行時に決まるものは動的import()を使ってください。
次回: CommonJS と ESM の比較
ESモジュールは標準仕様ですが、現場のNodeコードには依然としてCommonJSを使っているものがたくさんあります。require と module.exports の組み合わせで、コードの実行タイミングに関するルールもESMとは異なります。両者の違いと相互運用の方法を押さえておくと実戦で役立つので、次のページで詳しく見ていきましょう。
よくある質問
JavaScriptでESモジュールを使うにはどうすればいい?
まず、値を公開したいファイル側で export または export default を書き、別のファイルから import で読み込みます。ブラウザで使う場合はエントリーポイントを <script type="module" src="main.js"></script> のように読み込めばOKです。Node.jsの場合は拡張子を .mjs にするか、package.json に "type": "module" を追加してください。
default exportと名前付きexportの違いは?
名前付きexport(export function foo() {} など)は1ファイルにいくつでも書けますが、default export(export default ...)は1ファイルにつき1つだけです。名前付きは import { foo } from './x.js' のように同じ名前を波括弧で囲んで取り込む必要があります。一方、default exportは import whatever from './x.js' のように好きな名前を付けてインポートできます。
動的import()とは?
関数のように呼び出す import() は、そのモジュールのexportを値に持つPromiseを返します。静的な import 文と違って実行時に評価されるため、条件分岐で読み込んだり、必要になったタイミングで初めて読み込んだりできます。コード分割(code splitting)や遅延読み込みを実現するための仕組み、と考えるとわかりやすいです。
importのパスに拡張子は必要?
ブラウザやNode.jsのESMローダーなどネイティブのESモジュール環境では必須です。./utils ではなく ./utils.js と書く必要があります。Viteやwebpackのようなバンドラはゆるめなので拡張子なしでも解決してくれますが、それに依存するとバンドラ以外で動かなくなるので、素直に拡張子まで書く方が無難です。