はじめに

JavaScriptのコードが大規模になると、1つのファイルにすべてを書くのは管理が難しくなります。そこで登場するのが「ES Modules(ESモジュール)」です。ES Modulesを使うと、コードを複数のファイルに分割し、必要な機能だけをimport/exportで読み込んで再利用できます。

本記事では、以下の内容を初心者向けにわかりやすく解説します。

  • ES Modulesの概要とメリット
  • exportの種類(名前付きエクスポート・デフォルトエクスポート)
  • importの基本構文と応用
  • ファイル分割の実践例
  • 循環参照(Cyclic Import)の注意点
  • HTMLでのモジュール読み込み方法

ES Modulesとは

ES Modules(ECMAScript Modules)は、ES2015(ES6)で導入されたJavaScriptの公式モジュールシステムです。それ以前はCommonJS(Node.js向け)やAMD(RequireJS)などの非公式な仕組みが使われていましたが、ES Modulesはブラウザとサーバーサイドの両方でネイティブにサポートされています。

ES Modulesのメリット

ES Modulesを使うことで、以下のメリットが得られます。

メリット 説明
コードの分割と整理 機能ごとにファイルを分けることで、見通しがよくなる
再利用性の向上 共通の関数やクラスを複数のファイルから呼び出せる
名前空間の分離 モジュールごとにスコープが分かれ、グローバル汚染を防げる
依存関係の明確化 どのモジュールが何に依存しているかが一目でわかる
ツールとの連携 バンドラー(webpack、Vite)やトランスパイラ(Babel)との相性が良い

export(エクスポート)の基本

モジュールから外部に公開したい関数・変数・クラスは、exportキーワードを使ってエクスポートします。エクスポートには「名前付きエクスポート」と「デフォルトエクスポート」の2種類があります。

名前付きエクスポート(Named Export)

名前付きエクスポートは、複数の値を個別の名前で公開する方法です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// mathUtils.js

// 方法1: 個別にexportを付ける
export const PI = 3.14159;

export function add(a, b) {
  return a + b;
}

export function subtract(a, b) {
  return a - b;
}

または、ファイル末尾でまとめてエクスポートすることもできます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// mathUtils.js

const PI = 3.14159;

function add(a, b) {
  return a + b;
}

function subtract(a, b) {
  return a - b;
}

// 方法2: まとめてエクスポート
export { PI, add, subtract };

デフォルトエクスポート(Default Export)

デフォルトエクスポートは、モジュールのメイン機能を1つだけ公開する方法です。1つのモジュールにつき、デフォルトエクスポートは1つだけ定義できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Calculator.js

class Calculator {
  add(a, b) {
    return a + b;
  }

  subtract(a, b) {
    return a - b;
  }

  multiply(a, b) {
    return a * b;
  }

  divide(a, b) {
    if (b === 0) {
      throw new Error("0で割ることはできません");
    }
    return a / b;
  }
}

export default Calculator;

匿名関数をデフォルトエクスポートすることも可能です。

1
2
3
4
5
// greet.js

export default function (name) {
  return `こんにちは、${name}さん!`;
}

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

1つのモジュールで両方を同時に使うこともできます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// userUtils.js

// デフォルトエクスポート
export default class User {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }
}

// 名前付きエクスポート
export function validateEmail(email) {
  const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return regex.test(email);
}

export const MAX_USERNAME_LENGTH = 50;

import(インポート)の基本

importを使って、他のモジュールからエクスポートされた機能を読み込みます。

名前付きインポート

名前付きエクスポートを読み込む場合は、中括弧{}でエクスポート名を指定します。

1
2
3
4
5
6
7
// main.js

import { PI, add, subtract } from "./mathUtils.js";

console.log(PI);           // 3.14159
console.log(add(5, 3));    // 8
console.log(subtract(5, 3)); // 2

デフォルトインポート

デフォルトエクスポートを読み込む場合は、中括弧なしで任意の名前を付けられます。

1
2
3
4
5
6
7
// main.js

import Calculator from "./Calculator.js";

const calc = new Calculator();
console.log(calc.add(10, 5));      // 15
console.log(calc.multiply(4, 3)); // 12

両方を同時にインポート

デフォルトエクスポートと名前付きエクスポートを同時に読み込むこともできます。

1
2
3
4
5
6
7
8
9
// main.js

import User, { validateEmail, MAX_USERNAME_LENGTH } from "./userUtils.js";

const user = new User("太郎", "taro@example.com");
console.log(user.name); // 太郎

console.log(validateEmail("taro@example.com")); // true
console.log(MAX_USERNAME_LENGTH); // 50

エイリアス(別名)を使ったインポート

名前の衝突を避けたい場合や、より分かりやすい名前を付けたい場合はasキーワードを使います。

1
2
3
4
5
6
// main.js

import { add as addition, subtract as subtraction } from "./mathUtils.js";

console.log(addition(10, 5));     // 15
console.log(subtraction(10, 5)); // 5

モジュール全体をオブジェクトとしてインポート

* as構文を使うと、モジュールのすべてのエクスポートを1つのオブジェクトにまとめて読み込めます。

1
2
3
4
5
6
7
// main.js

import * as MathUtils from "./mathUtils.js";

console.log(MathUtils.PI);           // 3.14159
console.log(MathUtils.add(5, 3));    // 8
console.log(MathUtils.subtract(5, 3)); // 2

この方法は、エクスポートが多いモジュールを使う際に便利です。

ファイル分割の実践例

実際のプロジェクトでのファイル分割例を見てみましょう。ECサイトの商品管理機能を想定します。

ディレクトリ構造

1
2
3
4
5
6
7
project/
├── index.html
├── main.js
└── modules/
    ├── Product.js
    ├── Cart.js
    └── utils.js

modules/Product.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 商品クラス
export class Product {
  constructor(id, name, price) {
    this.id = id;
    this.name = name;
    this.price = price;
  }

  getInfo() {
    return `${this.name}: ¥${this.price.toLocaleString()}`;
  }
}

// 税込み価格を計算するユーティリティ
export function calculateTax(price, taxRate = 0.10) {
  return Math.floor(price * (1 + taxRate));
}

modules/Cart.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { calculateTax } from "./utils.js";

export class Cart {
  constructor() {
    this.items = [];
  }

  addItem(product, quantity = 1) {
    const existingItem = this.items.find((item) => item.product.id === product.id);

    if (existingItem) {
      existingItem.quantity += quantity;
    } else {
      this.items.push({ product, quantity });
    }
  }

  removeItem(productId) {
    this.items = this.items.filter((item) => item.product.id !== productId);
  }

  getTotal() {
    const subtotal = this.items.reduce((sum, item) => {
      return sum + item.product.price * item.quantity;
    }, 0);

    return calculateTax(subtotal);
  }

  getItemCount() {
    return this.items.reduce((count, item) => count + item.quantity, 0);
  }
}

modules/utils.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 共通ユーティリティ関数

export function calculateTax(price, taxRate = 0.10) {
  return Math.floor(price * (1 + taxRate));
}

export function formatCurrency(amount) {
  return ${amount.toLocaleString()}`;
}

export function generateId() {
  return Math.random().toString(36).substring(2, 9);
}

main.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { Product } from "./modules/Product.js";
import { Cart } from "./modules/Cart.js";
import { formatCurrency } from "./modules/utils.js";

// 商品を作成
const apple = new Product(1, "りんご", 150);
const banana = new Product(2, "バナナ", 100);
const orange = new Product(3, "みかん", 80);

// カートに追加
const cart = new Cart();
cart.addItem(apple, 3);
cart.addItem(banana, 2);
cart.addItem(orange, 5);

// 結果を表示
console.log("=== カートの中身 ===");
cart.items.forEach((item) => {
  console.log(`${item.product.name} x ${item.quantity}`);
});

console.log(`合計点数: ${cart.getItemCount()}点`);
console.log(`合計金額(税込): ${formatCurrency(cart.getTotal())}`);

動的インポート(Dynamic Import)

通常のimport文は静的にモジュールを読み込みますが、import()関数を使うと実行時に動的にモジュールを読み込めます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 条件に応じてモジュールを読み込む
async function loadModule(moduleName) {
  if (moduleName === "math") {
    const module = await import("./modules/mathUtils.js");
    console.log(module.add(5, 3)); // 8
  } else if (moduleName === "cart") {
    const { Cart } = await import("./modules/Cart.js");
    const cart = new Cart();
    console.log(cart);
  }
}

loadModule("math");

動的インポートは、以下のようなケースで特に有効です。

  • 初期読み込みを軽くしたいとき(遅延読み込み)
  • ユーザーの操作に応じて機能を読み込むとき
  • 条件分岐でモジュールを切り替えるとき

循環参照(Cyclic Import)の注意点

循環参照とは、モジュールAがモジュールBをインポートし、モジュールBがモジュールAをインポートする状態のことです。

graph LR
    A[moduleA.js] -->|import| B[moduleB.js]
    B -->|import| A

循環参照の問題例

1
2
3
4
5
// moduleA.js
import { b } from "./moduleB.js";

export const a = "A";
console.log(b); // この時点でbはまだ初期化されていない可能性がある
1
2
3
4
5
// moduleB.js
import { a } from "./moduleA.js";

export const b = "B";
console.log(a); // この時点でaはまだ初期化されていない可能性がある

循環参照を回避する方法

循環参照を避けるための一般的なアプローチは以下のとおりです。

  1. 共通モジュールへの分離: 共有するコードを第三のモジュールに移動する
  2. 依存関係の見直し: 設計を見直し、一方向の依存関係に修正する
  3. 遅延アクセス: 変数に直接アクセスせず、関数を通じてアクセスする
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 解決策: 共通モジュールに分離する
// shared.js
export const sharedValue = "共有データ";

// moduleA.js
import { sharedValue } from "./shared.js";
export const a = `A: ${sharedValue}`;

// moduleB.js
import { sharedValue } from "./shared.js";
export const b = `B: ${sharedValue}`;

HTMLでモジュールを読み込む

ブラウザでES Modulesを使う場合は、<script>タグにtype="module"属性を付けます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>ES Modules デモ</title>
</head>
<body>
  <h1>ES Modules テスト</h1>
  <div id="output"></div>

  <!-- type="module" でモジュールとして読み込む -->
  <script type="module" src="./main.js"></script>

  <!-- インラインモジュールも可能 -->
  <script type="module">
    import { formatCurrency } from "./modules/utils.js";
    console.log(formatCurrency(1000)); // ¥1,000
  </script>
</body>
</html>

モジュールスクリプトの特徴

type="module"を指定したスクリプトには、通常のスクリプトとは異なる特徴があります。

特徴 説明
自動的にdefer モジュールはHTMLパース後に実行される
厳格モード 自動的にstrict modeが適用される
スコープの分離 トップレベル変数はグローバルに漏れない
一度だけ実行 同じモジュールは複数回読み込んでも1回だけ実行
CORS制約 file://プロトコルでは動作しない(ローカルサーバーが必要)

ローカルでのテスト方法

モジュールをローカルでテストする場合は、ローカルサーバーが必要です。以下のいずれかの方法を使います。

1
2
3
4
5
6
7
# Node.jsのhttp-serverを使う場合
npx http-server .

# Pythonを使う場合
python -m http.server 8000

# VS Codeの拡張機能「Live Server」を使う

モジュールの再エクスポート(Re-export)

複数のモジュールを1つのエントリポイントにまとめることで、インポートを簡潔にできます。

1
2
3
4
5
6
// modules/index.js(バレルファイル)

// 他のモジュールをまとめて再エクスポート
export { Product, calculateTax } from "./Product.js";
export { Cart } from "./Cart.js";
export { formatCurrency, generateId } from "./utils.js";
1
2
3
4
// main.js

// 1つのファイルからすべてインポートできる
import { Product, Cart, formatCurrency } from "./modules/index.js";

まとめ

ES Modulesを使うことで、JavaScriptのコードを効率的に分割・管理できます。本記事で解説した内容を振り返ります。

  • exportでモジュールから機能を公開(名前付き/デフォルト)
  • importで他モジュールの機能を読み込む
  • ファイル分割で機能ごとにコードを整理
  • 循環参照には注意し、共通モジュールへの分離で回避
  • HTMLでの読み込みtype="module"を指定
  • 動的インポートで必要なときだけモジュールを読み込む

モダンなJavaScript開発では、ES Modulesは必須の知識です。React、Vue、Angularなどのフレームワークも、すべてES Modulesをベースに構築されています。ぜひ実際のプロジェクトで活用してみてください。

参考リンク