ファイルが増えると、関数や定数の共有が必要になります。ES モジュール(ESM) は import / export で依存を明示します。ここでは 文法とスコープ に絞ります。Node の "type": "module" やブラウザの <script type="module"> は、それぞれ実行環境側の話です。
参考(MDN)
目次
なぜモジュールを使うか
モジュールを使うと次のようなメリットがあります。
- 関心の分離 — 1 ファイル 1 責務にしやすい
- 名前の衝突回避 — ファイルスコープで変数が閉じる
- 依存の明示 — 先頭の
importで必要なものが読める - 再利用 — ユーティリティをプロジェクト横断で共有
従来の「HTML に <script> を並べる」方式では、グローバル変数が増えて衝突しやすくなります。ESM では 各ファイルが独立したモジュールスコープ を持ちます。
// utils/math.jsexport function add(a, b) { return a + b;}// main.jsimport { add } from "./utils/math.js";console.log(add(2, 3)); // 5名前付き export / import
名前付き export は、複数の識別子を公開するときに使います。import 側は 同じ名前(または as でリネーム)が必要です。
宣言と同時に export
// strings.jsexport const APP_NAME = "MyApp";export function capitalize(str) { return str.charAt(0).toUpperCase() + str.slice(1);}export class Formatter { static upper(s) { return s.toUpperCase(); }}あとからまとめて export
const PI = 3.14159;function area(r) { return PI * r * r;}export { PI, area };import
import { APP_NAME, capitalize } from "./strings.js";import { capitalize as cap } from "./strings.js";console.log(cap("hello")); // "Hello"import { APP_NAME, capitalize, Formatter } from "./strings.js";デフォルト export / import
デフォルト export は、モジュールあたり 1 つ(実務上は 1 メインの値)を公開するときに使います。import 側は 任意の名前 を付けられます。
// logger.jsexport default function createLogger(prefix) { return { info(msg) { console.log(`[${prefix}] ${msg}`); }, };}import createLogger from "./logger.js";const log = createLogger("APP");log.info("起動");名前付きとデフォルトの併用
// http.jsexport const BASE_URL = "/api";export default async function fetchJSON(path) { const res = await fetch(`${BASE_URL}${path}`); return res.json();}import fetchJSON, { BASE_URL } from "./http.js";使い分けの目安
| 名前付き | デフォルト |
|---|---|
| 複数の関数・定数を公開 | モジュールの「主役」が 1 つ |
| 名前で検索・リファクタしやすい | import 側で任意の名前を付けられる |
| ツリーシェイクと相性がよい | import 名がファイルごとにバラけやすい |
ユーティリティでは 名前付きを基本 にし、デフォルトは「このファイルの主役が 1 つだけ」のときに使います。
再エクスポートと import まとめ
再エクスポート(barrel)
// utils/index.jsexport { add, subtract } from "./math.js";export { capitalize } from "./strings.js";import { add, capitalize } from "./utils/index.js";小規模プロジェクトでは便利ですが、巨大な barrel は循環依存の原因になりやすいので、公開 API だけに絞るのが安全です。
import * as 名前空間
import * as math from "./math.js";console.log(math.add(1, 2));動的 import()
async function loadHeavy() { const module = await import("./heavy.js"); module.run();}必要になったタイミングでだけモジュールを読み込む コード分割 に使います。静的 import はファイル先頭で依存を固定するのに対し、動的 import は条件分岐の中に書けます。
モジュールのスコープ
- モジュール内の
const/let/functionは モジュールスコープ(グローバルに漏れない) - モジュールは strict モード相当 で実行される
- トップレベルの
awaitが使える(モジュールが async コンテキスト) importは ホイスティング されるが、実行前に依存グラフが解決される(循環依存は注意)
// counter.jslet count = 0;export function increment() { count += 1; return count;}export function getCount() { return count;}モジュール内の count は外部から直接触れず、export した関数だけが窓口になります。
うまくいかないとき
デフォルトと名前付きの取り違え
// logger.jsexport default function log() {}// NG// import { log } from "./logger.js";// OKimport log from "./logger.js";逆に名前付きをデフォルト import しようとしても undefined になります。
循環依存で undefined が入る
a.js が b.js を import し、b.js が a.js を import すると、初期化順の関係で まだ export されていない値 を読むことがあります。
// a.jsimport { b } from "./b.js";export const a = "A";// b.jsimport { a } from "./a.js";export const b = a + "B"; // タイミングによっては a が undefined解決策は依存の向きを整理する、共通部分を第 3 ファイルに切り出す、関数内で遅延参照する、などです。