ESモジュール以前、JavaScriptにはビルトインのモジュールシステムがありませんでした。正しい順序でscriptタグを並べ、IIFEパターンでカプセル化を擬似的に作り、そしてNode.jsのCommonJSが10年間続きました。ES2015で標準化され今では至る所でサポートされているESモジュールが本当の解決策です。このガイドでは、名前付きエクスポートとデフォルトエクスポートの基礎から、動的インポート、ツリーシェイキング、そして大規模なコードベースを維持可能に保つパターンまで全てを説明します。

名前付きエクスポートとデフォルトエクスポート

モジュールからエクスポートする方法は2つあります。違いとトレードオフを理解することが、他の全ての基礎になります:

js
// utils/currency.js

// Named exports — explicit names, multiple per file
export function formatPrice(amount, currency = 'USD') {
  return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
}

export function parseCents(cents) {
  return cents / 100;
}

export const DEFAULT_CURRENCY = 'USD';
js
// Importing named exports — names must match
import { formatPrice, parseCents } from './utils/currency.js';

// You can rename on import
import { formatPrice as fmt } from './utils/currency.js';

// Import the entire namespace as an object
import * as Currency from './utils/currency.js';
Currency.formatPrice(49.99); // works
js
// components/ProductCard.js

// Default export — one per file, no name required
export default function ProductCard({ product }) {
  return `<div class="card">${product.name}</div>`;
}
js
// Importing a default export — you choose the name
import ProductCard from './components/ProductCard.js';
import Card        from './components/ProductCard.js'; // same thing, different name
意見:名前付きエクスポートはユーティリティモジュールではほぼ常に優れています。明示的で、IDEとの相性が良く(オートコンプリートと「定義へ移動」が完璧に機能します)、リファクタリングに安全です。関数のリネームはテストではなくツールが検出します。デフォルトエクスポートは、React/Angularコンポーネントやルートハンドラーファイルのようにファイルが「一つのもの」をエクスポートする場合に意味があります。

バレルファイル — 便利で危険

バレルファイルとは、複数のモジュールから再エクスポートするindex.jsで、消費者にクリーンなインポートパスを提供します。大きなプロジェクトでは至る所にあります:

js
// utils/index.js — the barrel
export { formatPrice, parseCents, DEFAULT_CURRENCY } from './currency.js';
export { validateEmail, validatePhone }              from './validation.js';
export { debounce, throttle }                        from './timing.js';
export { fetchWithRetry, buildQueryString }          from './http.js';
js
// Consumer — clean import path, no need to know the file structure
import { formatPrice, debounce, fetchWithRetry } from './utils/index.js';
// or with bundlers that resolve index.js automatically:
import { formatPrice, debounce, fetchWithRetry } from './utils';

危険性:バレルファイルはツリーシェイキングを破壊する可能性があります。40モジュールを再エクスポートするバレルから1つの関数をインポートすると、一部のバンドラーは使っていないものも含めて40全部を引き込みます。修正方法はバレルに含めるものを選別し、バンドラーがpackage.jsonsideEffects: falseをサポートしていることを確認することです。

動的import() — オンデマンドのコード分割

静的インポート(import x from '...')はバンドル時に解決されます。動的import()はランタイムにモジュールを遅延ロードする関数で、Promiseを返します。これがルートベースのコード分割の実装方法です:

js
// Load a heavy chart library only when the user navigates to the analytics page
async function loadAnalyticsDashboard() {
  const { Chart }   = await import('chart.js');
  const { buildDashboardData } = await import('./analytics/data.js');

  const data   = await buildDashboardData();
  const canvas = document.getElementById('dashboard-chart');
  new Chart(canvas, { type: 'bar', data });
}

// Attach to a route change
router.on('/analytics', loadAnalyticsDashboard);
js
// Feature flags — only load a module if the feature is enabled
async function initFeature(featureName) {
  const flags = await getFeatureFlags();

  if (flags[featureName]) {
    const module = await import(`./features/${featureName}.js`);
    module.init();
  }
}

Webpack、Rollup、Viteはすべて動的import()を理解し、ロードされたモジュールを自動的に別のチャンクに分割します。結果:より小さい初期バンドル、より速い最初のロード。

import.meta — モジュールのメタデータ

import.metaは現在のモジュールに関するメタデータを提供するオブジェクトです。最も便利なプロパティはimport.meta.urlで、現在のモジュールファイルのURLです:

js
// Get the directory of the current module (Node.js)
import { fileURLToPath } from 'node:url';
import { dirname, join }  from 'node:path';

const __filename = fileURLToPath(import.meta.url);
const __dirname  = dirname(__filename);

// Now you can do path resolution the same way you would with CommonJS
const configPath = join(__dirname, '../config/app.json');
js
// In Vite projects, import.meta.env gives you environment variables
const apiBase = import.meta.env.VITE_API_BASE_URL;
const isProd  = import.meta.env.PROD; // boolean

// import.meta.glob — Vite-specific, import multiple files at once
const modules = import.meta.glob('./pages/*.js');
for (const path in modules) {
  const module = await modules[path]();
  console.log(path, module);
}

ESモジュール vs CommonJS

古いNode.jsコードベースやnpmパッケージではCommonJS(require())に出会うことがあります。よく混乱を招く違いのクイックリファレンスです:

js
// CommonJS (CJS) — still common in Node.js
const express = require('express');
const { join } = require('path');
module.exports = { myFunction };
module.exports.default = MyClass;

// ES Modules (ESM) — the standard
import express from 'express';
import { join } from 'path';
export { myFunction };
export default MyClass;

主な違い:ESモジュールは静的に解析可能(インポートはコード実行前に解決される)ですが、CommonJSのrequireはランタイムに実行されます。ESMはデフォルトでasyncロードを使用し、CJSは同期的です。Node.jsでは、.mjs拡張子のファイルまたはpackage.json"type": "module"があるファイルはESMとして扱われます。

相互運用の落とし穴:ESMファイルからCommonJSモジュールをimportすることはできます(Node.jsが処理します)。しかしCommonJSファイルからESMモジュールをrequire()することはできません。必要な場合は動的import()呼び出しを使ってください。両方のモジュールシステムで動作します。

ツリーシェイキングと名前付きエクスポート

ツリーシェイキングは、インポートしても使わないコードを除去するバンドラーのプロセスです。名前付きエクスポートと静的インポート文が必要です。動的インポートとCommonJSはツリーシェイキングがはるかに困難です。ツリーシェイキングがうまく機能するモジュールの書き方:

js
// ✅ Tree-shakeable — bundler can see exactly what's exported
export function formatDate(date) { /* ... */ }
export function parseDate(str)   { /* ... */ }
export function addDays(date, n) { /* ... */ }

// If you only import formatDate, parseDate and addDays are excluded from the bundle
import { formatDate } from './utils/dates.js';
js
// ❌ Harder to tree-shake — the whole object is one export
const dateUtils = {
  formatDate: (date) => { /* ... */ },
  parseDate:  (str)  => { /* ... */ },
  addDays:    (date, n) => { /* ... */ }
};
export default dateUtils;

// Even if you only use dateUtils.formatDate, the whole object may be bundled

npmに公開するライブラリでは、ツリーシェイキングのサポートが人々が推薦するライブラリと避けるライブラリの違いになります。MDNのモジュールガイドにはそれを可能にする完全な静的解析ルールが記載されています。

ブラウザのネイティブESモジュール

ESモジュールをブラウザで直接使用できます。バンドラーは不要です。scriptタグにtype="module"を追加してください:

js
<!-- index.html -->
<!-- type="module" enables ES Module semantics -->
<!-- deferred by default — runs after HTML is parsed -->
<script type="module" src="./app.js"></script>
js
// app.js — can use import/export directly
import { initRouter }    from './router.js';
import { setupAuth }     from './auth.js';
import { loadDashboard } from './dashboard.js';

async function main() {
  await setupAuth();
  initRouter();
  await loadDashboard();
}

main();

モジュールスクリプトは常に遅延実行され、デフォルトでstrict modeで動作します。各モジュールは複数の他のモジュールからインポートされても一度だけ実行されます。ブラウザはモジュールインスタンスをキャッシュします。モジュールに共有状態がある場合、これは知っておく価値があります。

実際のリファクタリング例

典型的なutilsファイルが肥大化し、適切にモジュールに分割される様子を見てみましょう:

js
// Before: one giant utils.js with everything
export function formatPrice(amount) { /* ... */ }
export function validateEmail(email) { /* ... */ }
export function debounce(fn, ms) { /* ... */ }
export function fetchWithRetry(url, opts) { /* ... */ }
export function parseCSV(text) { /* ... */ }
export function buildQueryString(params) { /* ... */ }

// After: split by concern
// utils/
//   currency.js     → formatPrice, parseCents
//   validation.js   → validateEmail, validatePhone
//   timing.js       → debounce, throttle
//   http.js         → fetchWithRetry, buildQueryString
//   csv.js          → parseCSV, formatCSV
//   index.js        → re-exports all of the above (optional barrel)

関心事で分割することで、各ファイルが単一の責任を持ち、テストが書きやすく見つけやすくなり、バンドラーがファイルレベルでツリーシェイクできます。バレルファイルは単一のインポートパスを好む消費者のためにインポートをクリーンに保ちます。

便利なツール

モジュラーなJavaScriptを構築する際、JSフォーマッターはインポートブロックをクリーンで一貫した状態に保ちます。最終バンドルの内容を理解するには、Webpack Bundle AnalyzerやRollupのビジュアライザーなどのツールがどのモジュールがスペースを取っているかを示します。TC39のECMAScript Modules仕様にはエッジケースを理解する必要がある場合の正確な解決セマンティクスが記載されています。

まとめ

ESモジュールは現代のJavaScriptアーキテクチャの基盤です。ユーティリティ関数には名前付きエクスポートを優先してください。明示的で、ツリーシェイク可能で、IDEと相性が良いです。重いコードを初期バンドルから切り離すには動的import()を使ってください。大きなコードベースではバレルファイルに注意してください。そして新しいNode.jsプロジェクトでまだCommonJSを使っているなら、移行する時です。エコシステムは完全にESMにシフトしています。