ES Modules完全実践ガイド〜import/export・dynamic import・CJS共存・Tree Shaking【2026年版】〜

importrequireって何が違うの?」「Node.jsでSyntaxError: Cannot use import statement outside a moduleが出る」「Tree Shakingが効かない」――ES Modules(ESM)はモダンJavaScriptの土台ですが、CommonJSとの共存・Node.js/ブラウザ・バンドラの挙動が絡み、入門書では拾いきれない落とし穴が大量にあります。

本記事では、ESMの基本構文から、dynamic import、top-level await、Node.jsのexports field、Tree Shaking、デュアルパッケージハザード、Import Mapsまで、現役エンジニアが本当に踏む地雷とその回避策を40個以上のコピペで動くES2025準拠コードとともに徹底解説します。import/exportを「なんとなく書ける」段階から、パッケージを公開できるレベルに引き上げる、実務直結の総まとめです。

この記事で身につくこと

  • named export / default export / 名前空間importの完全な使い分け
  • dynamic import()とtop-level awaitによる遅延読み込み戦略
  • CommonJSとESMの相互運用とデュアルパッケージハザード回避
  • package.jsonのtype / exports / importsフィールド設計
  • Tree Shakingが効く書き方とsideEffects: falseの正しい指定
  • ブラウザでの<script type="module">とImport Maps運用
  1. 1. ES Modulesの基本 — import / exportの全構文
    1. 1-1. named export — 名前付きエクスポート
    2. 1-2. まとめて export する書き方
    3. 1-3. default export — 単一の値を公開
    4. 1-4. default と named を同時にエクスポート
    5. 1-5. default export のアンチパターン
  2. 2. import の応用構文 — リネーム・名前空間・副作用
    1. 2-1. as でリネームしてインポート
    2. 2-2. export 側で as リネーム
    3. 2-3. import * as ns — 名前空間として取り込む
    4. 2-4. side-effect import — 値を取り出さない
    5. 2-5. import type — TypeScriptで型のみ取り込む
    6. 2-6. import type と値importの混在
  3. 3. dynamic import() — 遅延読み込みの切り札
    1. 3-1. dynamic importの基本
    2. 3-2. 条件付き読み込み
    3. 3-3. ルート別コード分割(SPA典型例)
    4. 3-4. プラグインシステムの実装
    5. 3-5. importのwithアトリビュート(ES2025)
  4. 4. top-level await — モジュール直下のawait
    1. 4-1. 基本的な使い方
    2. 4-2. データベース接続をモジュールで管理
    3. 4-3. 動的選択でモジュール本体を切り替え
  5. 5. import.meta — モジュールのメタ情報
    1. 5-1. import.meta.url で自分のURLを知る
    2. 5-2. Node.jsで __dirname / __filename を再現
    3. 5-3. import.meta.env(Vite)
    4. 5-4. import.meta.resolve(Node.js 20+)
  6. 6. Node.jsでESMを使う — package.jsonと拡張子
    1. 6-1. package.json の “type” フィールド
    2. 6-2. .mjs と .cjs の拡張子で個別指定
    3. 6-3. Node.jsでのESM拡張子は明示必須
    4. 6-4. TypeScript で ESM を書く時の注意
  7. 7. CommonJS と ESM の相互運用
    1. 7-1. CommonJS構文の復習
    2. 7-2. ESMからCJSをimport(基本パターン)
    3. 7-3. CJSからESMをrequire(Node 22+)
    4. 7-4. createRequire でCJSのrequireを呼ぶ
    5. 7-5. デュアルパッケージハザード
  8. 8. package.json の exports フィールド — 公開APIの正式宣言
    1. 8-1. 最小限の exports
    2. 8-2. サブパスエクスポート
    3. 8-3. Conditional Exports — 環境別に出し分け
    4. 8-4. ブラウザ/Node別エクスポート
    5. 8-5. 開発/本番別エクスポート
  9. 9. Node.js Subpath Imports — 内部エイリアス
    1. 9-1. 基本構文
    2. 9-2. 環境別の内部alias
  10. 10. Tree Shaking — 不要コード自動削除の仕組み
    1. 10-1. Tree Shakingが効く書き方
    2. 10-2. Tree Shakingが効かないアンチパターン
    3. 10-3. sideEffects: false の宣言
    4. 10-4. /*#__PURE__*/ アノテーション
    5. 10-5. lodash / lodash-es の罠
  11. 11. 循環参照(Circular Dependency)
    1. 11-1. 動く循環参照
    2. 11-2. 壊れる循環参照
    3. 11-3. 循環参照の検出ツール
    4. 11-4. 解消パターン:共通モジュールに分離
  12. 12. ブラウザでESMを使う
    1. 12-1. <script type=”module”>
    2. 12-2. type=”module”の特徴
    3. 12-3. ブラウザの import 構文制約
    4. 12-4. Import Maps — ブラウザでパッケージ名を使う
    5. 12-5. modulepreload で先読み
  13. 13. Bundlerの挙動 — Vite / Webpack
    1. 13-1. Vite — 開発はESMネイティブ
    2. 13-2. Webpack — ESMもCJSも食える
    3. 13-3. esbuild / Rollup でのライブラリビルド
  14. 14. パッケージ公開 — npm publishまで
    1. 14-1. デュアルESM/CJSパッケージのpackage.json
    2. 14-2. 公開コマンド
    3. 14-3. ローカル動作確認
    4. 14-4. Workspace / pnpm でモノレポ管理
  15. 15. デバッグ&アンチパターン総まとめ
    1. 15-1. モジュール解決のデバッグ
    2. 15-2. “Cannot use import statement outside a module” エラー
    3. 15-3. “ERR_MODULE_NOT_FOUND” エラー
    4. 15-4. アンチパターン:バレル(barrel)ファイルの乱用
    5. 15-5. アンチパターン:default export の混在
    6. 15-6. アンチパターン:re-export地獄
    7. 15-7. パフォーマンス計測
    8. 15-8. 最終チェックリスト
  16. まとめ

1. ES Modulesの基本 — import / exportの全構文

ES Modules(ESM)はES2015で標準化されたJavaScriptの公式モジュールシステムです。import/exportキーワードでファイル間の依存関係を宣言し、静的解析が可能なため、Tree Shakingや型推論など現代的なツールチェーンの基盤になっています。

1-1. named export — 名前付きエクスポート

もっとも基本的かつ推奨されるパターンがnamed exportです。1ファイルから複数の値を名前付きで公開できます。

// math.js
export const PI = 3.14159;

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

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

export class Calculator {
  constructor(initial = 0) {
    this.value = initial;
  }
  add(n) {
    this.value += n;
    return this;
  }
}

インポート側は、エクスポート名と完全に一致する名前を{}で囲んで取り出します。

// app.js
import { PI, add, multiply, Calculator } from "./math.js";

console.log(add(2, 3));        // 5
console.log(multiply(4, 5));   // 20
console.log(PI);               // 3.14159

const calc = new Calculator(10).add(5).add(3);
console.log(calc.value);       // 18

1-2. まとめて export する書き方

宣言と分離してまとめてエクスポートする方が、何を公開しているか一目でわかります。中〜大規模ファイルではこちらが推奨です。

// utils.js
function isEmail(value) {
  return /^[^s@]+@[^s@]+.[^s@]+$/.test(value);
}

function isUrl(value) {
  try {
    new URL(value);
    return true;
  } catch {
    return false;
  }
}

const MAX_LENGTH = 255;

// ファイル末尾でまとめて公開
export { isEmail, isUrl, MAX_LENGTH };

1-3. default export — 単一の値を公開

defaultは1ファイルに1つだけ指定できる「主役」のエクスポートです。インポート側は{}を使わず任意の名前を付けられます。

// logger.js
export default class Logger {
  constructor(prefix = "[app]") {
    this.prefix = prefix;
  }
  info(msg) {
    console.log(`${this.prefix} ${msg}`);
  }
  error(msg) {
    console.error(`${this.prefix} ${msg}`);
  }
}
// app.js — 名前は自由に付けられる
import MyLogger from "./logger.js";
import Log from "./logger.js"; // 別名でもOK

const logger = new MyLogger("[api]");
logger.info("server started");

1-4. default と named を同時にエクスポート

// api.js
export const BASE_URL = "https://api.example.com";

export function get(path) {
  return fetch(BASE_URL + path);
}

export default {
  get,
  post: (path, body) => fetch(BASE_URL + path, {
    method: "POST",
    body: JSON.stringify(body),
  }),
};
// 両方を一度にimport
import api, { BASE_URL, get } from "./api.js";

console.log(BASE_URL);
api.get("/users");
get("/posts");

1-5. default export のアンチパターン

default exportは便利ですが、リファクタリング耐性が低く、IDE補完が弱いという重大な欠点があります。実務ではnamed exportを優先するのが現代の主流です。

// ❌ default export — リネームが効きにくい
// logger.js
export default function log(msg) { console.log(msg); }

// 利用側でタイポしてもエラーが出ない
import lgo from "./logger.js"; // ← 気付かない
lgo("hello");
// ✅ named export — エクスポート名と一致が必須
// logger.js
export function log(msg) { console.log(msg); }

import { log } from "./logger.js"; // 名前一致で安全
log("hello");

2. import の応用構文 — リネーム・名前空間・副作用

2-1. as でリネームしてインポート

名前の衝突や、もっと意味のある名前を付けたい時はasでリネームできます。

// dateUtils.js
export function format(date) { return date.toISOString(); }
export function parse(str) { return new Date(str); }
// app.js — 別ライブラリのformatと衝突を避ける
import { format as formatDate, parse as parseDate } from "./dateUtils.js";
import { format as formatCurrency } from "./currency.js";

console.log(formatDate(new Date()));
console.log(formatCurrency(1980));

2-2. export 側で as リネーム

// internal.js
function _privateImpl(x) { return x * 2; }

// 公開時に綺麗な名前に変える
export { _privateImpl as doubleValue };

2-3. import * as ns — 名前空間として取り込む

ファイルの全エクスポートを1つのオブジェクトとしてまとめて取り込めます。「これはこのモジュールのもの」と明示したい時に有効です。

// math.js
export function add(a, b) { return a + b; }
export function sub(a, b) { return a - b; }
export const PI = 3.14159;

// app.js
import * as math from "./math.js";

console.log(math.add(1, 2));   // 3
console.log(math.sub(5, 3));   // 2
console.log(math.PI);          // 3.14159

2-4. side-effect import — 値を取り出さない

cssや初期化処理など、読み込むだけで副作用を発生させたい場合は、変数を取り出さない形でimportします。

// polyfills.js — 実行されることに意味がある
import "core-js/stable";
import "regenerator-runtime/runtime";
import "./styles.css";

// 利用側
import "./polyfills.js"; // 値は受け取らない

2-5. import type — TypeScriptで型のみ取り込む

TypeScriptではimport typeを使うと、コンパイル後に該当のimport文がきれいに消えます。Tree Shakingやサイクル防止に必須のテクニックです。

// types.ts
export type User = { id: number; name: string };
export type Role = "admin" | "user" | "guest";

// app.ts
import type { User, Role } from "./types";
// ↑ コンパイル後、このimport文は完全に消える

function greet(user: User, role: Role): string {
  return `${role}: ${user.name}`;
}

2-6. import type と値importの混在

// TypeScript 4.5+ のinline構文
import { createUser, type User, type Role } from "./userModule";

const u: User = createUser("Taro");
const r: Role = "admin";

3. dynamic import() — 遅延読み込みの切り札

import()(関数として呼ぶ)は非同期にモジュールを読み込む構文です。コード分割・条件付き読み込み・プラグインシステムなど、現代のフロントエンドでは欠かせません。

3-1. dynamic importの基本

// 通常のimportは静的(ファイル先頭)
import { heavyFunction } from "./heavy.js";

// dynamic importは非同期(任意の場所で呼べる)
async function loadHeavy() {
  const module = await import("./heavy.js");
  module.heavyFunction();
}

// Promise として扱うこともできる
import("./heavy.js").then((m) => m.heavyFunction());

3-2. 条件付き読み込み

async function loadPolyfillIfNeeded() {
  if (!("ResizeObserver" in window)) {
    const { ResizeObserver } = await import("@juggle/resize-observer");
    window.ResizeObserver = ResizeObserver;
  }
}

await loadPolyfillIfNeeded();

3-3. ルート別コード分割(SPA典型例)

const routes = {
  "/": () => import("./pages/Home.js"),
  "/about": () => import("./pages/About.js"),
  "/dashboard": () => import("./pages/Dashboard.js"),
};

async function navigate(path) {
  const loader = routes[path];
  if (!loader) return;
  const page = await loader();
  document.body.innerHTML = page.render();
}

navigate("/dashboard");

3-4. プラグインシステムの実装

async function loadPlugin(name) {
  try {
    const plugin = await import(`./plugins/${name}.js`);
    if (typeof plugin.init !== "function") {
      throw new Error(`plugin ${name} must export init()`);
    }
    await plugin.init();
    return plugin;
  } catch (err) {
    console.error(`Failed to load plugin ${name}:`, err);
    return null;
  }
}

await loadPlugin("analytics");
await loadPlugin("notifications");

3-5. importのwithアトリビュート(ES2025)

JSONやCSSモジュールを取り込む際の標準構文として、Import Attributes(旧 Import Assertions)がES2025で正式化されました。

// JSONを直接import
import config from "./config.json" with { type: "json" };
console.log(config.apiUrl);

// dynamic importでも同様
const data = await import("./data.json", {
  with: { type: "json" },
});
console.log(data.default);

4. top-level await — モジュール直下のawait

ES2022で標準化されたtop-level awaitは、関数で囲まずモジュールのトップレベルでawaitを書ける機能です。初期化処理が大幅にシンプルになります。

4-1. 基本的な使い方

// config.js — 設定を非同期で取得してからexport
const response = await fetch("/api/config");
const config = await response.json();

export default config;
export const { apiUrl, version } = config;
// app.js — importする側は普通にimportするだけ
import config, { apiUrl } from "./config.js";

console.log(config); // 既にロード済み
console.log(apiUrl);

4-2. データベース接続をモジュールで管理

// db.js
import { createPool } from "mysql2/promise";

const pool = createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASS,
  database: process.env.DB_NAME,
});

// 接続テスト(失敗したらモジュール読み込みが失敗する)
await pool.query("SELECT 1");

export default pool;

4-3. 動的選択でモジュール本体を切り替え

// strings.js — ロケールに応じて辞書を選択
const locale = navigator.language.startsWith("ja") ? "ja" : "en";
const messages = await import(`./locales/${locale}.js`);

export default messages.default;

5. import.meta — モジュールのメタ情報

import.metaは実行中のモジュール自身に関する情報を提供する特別なオブジェクトです。

5-1. import.meta.url で自分のURLを知る

// このモジュールのURLが取れる
console.log(import.meta.url);
// "file:///app/src/utils.js" (Node) または "https://example.com/utils.js" (Browser)

// 相対パスで他ファイルを参照する
const dataUrl = new URL("./data.json", import.meta.url);
console.log(dataUrl.pathname);

5-2. Node.jsで __dirname / __filename を再現

ESMでは__dirname / __filenameが使えませんが、import.metaから復元できます。

import { fileURLToPath } from "node:url";
import { dirname } from "node:path";

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

console.log(__filename); // /app/src/utils.js
console.log(__dirname);  // /app/src

5-3. import.meta.env(Vite)

Vite/Astroなどの一部バンドラは独自にimport.meta.envを提供します(Node.js標準ではありません)。

// Vite
console.log(import.meta.env.MODE);          // "development" or "production"
console.log(import.meta.env.DEV);           // boolean
console.log(import.meta.env.VITE_API_URL);  // .env で定義した値

5-4. import.meta.resolve(Node.js 20+)

// パッケージ名から実ファイルパスを解決
const resolved = import.meta.resolve("lodash");
console.log(resolved);
// "file:///app/node_modules/lodash/lodash.js"

6. Node.jsでESMを使う — package.jsonと拡張子

Node.jsはもともとCommonJS前提で設計されたランタイムなので、ESMを使うには明示的な設定が必要です。

6-1. package.json の “type” フィールド

{
  "name": "my-app",
  "version": "1.0.0",
  "type": "module",
  "main": "index.js"
}

"type": "module"を指定すると、そのパッケージ配下の.jsファイルはすべてESMとして扱われます。指定しない場合(または"commonjs")はCJS扱いです。

6-2. .mjs と .cjs の拡張子で個別指定

# どんなpackage.jsonでも、拡張子で強制できる
src/
├── server.mjs    # 必ずESM
├── legacy.cjs    # 必ずCJS
└── shared.js     # package.jsonの"type"に従う

6-3. Node.jsでのESM拡張子は明示必須

// ❌ Node.jsのESMでは拡張子省略は不可
import { foo } from "./utils";        // Error!

// ✅ 拡張子を必ず付ける
import { foo } from "./utils.js";

// ✅ ディレクトリの場合は index.js まで書く
import { foo } from "./utils/index.js";

6-4. TypeScript で ESM を書く時の注意

// TypeScript上では .ts と書きたいが、コンパイル後は .js になる
// → Node.js ESM 互換性のため、import 側は ".js" と書く(!)

// user.ts
export function getUser() { /* ... */ }

// app.ts
import { getUser } from "./user.js"; // ← .js と書く(.ts ではない)

tsconfig.jsonでは"module": "NodeNext" + "moduleResolution": "NodeNext"がESM Node向け推奨設定です。詳細はB10 tsconfigの記事を参照してください。

7. CommonJS と ESM の相互運用

7-1. CommonJS構文の復習

// CJS — 動的・同期・実行時解決
const fs = require("fs");
const { readFile } = require("fs/promises");

module.exports = function () { /* ... */ };
module.exports.helper = function () { /* ... */ };
exports.utility = function () { /* ... */ };

7-2. ESMからCJSをimport(基本パターン)

NodeはCJSをESMから読み込めますが、default importでしか確実に動きません

// CJSモジュール: legacy-lib.cjs
module.exports = {
  hello: () => "world",
  PI: 3.14,
};
// ESMから読み込む
import legacy from "./legacy-lib.cjs";

console.log(legacy.hello()); // "world"
console.log(legacy.PI);      // 3.14

// named importは動く時と動かない時がある(static analysis依存)
import { hello } from "./legacy-lib.cjs"; // 失敗しうる

7-3. CJSからESMをrequire(Node 22+)

長年禁止されていた「CJSからESMをrequire」が、Node.js 22.12+で同期require対応(--experimental-require-moduleは不要)になりました。

// ESM: tool.mjs
export const greet = (name) => `Hello, ${name}!`;

// CJS: app.cjs
const { greet } = require("./tool.mjs"); // Node 22.12+ で動く
console.log(greet("Taro"));

7-4. createRequire でCJSのrequireを呼ぶ

// ESMでもrequireが必要になる場面(古いCJS専用パッケージ等)
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);

const cjsOnly = require("some-cjs-only-package");
cjsOnly.doSomething();

7-5. デュアルパッケージハザード

同じパッケージのESM版とCJS版を両方読み込んでしまい、「同じクラスなのにinstanceofが効かない」などの不可解な事故が起きるアンチパターンです。

// 危険な例:
// 経路1: import { User } from "user-lib"        → ESM版
// 経路2: const { User } = require("user-lib")    → CJS版
// → 実体が別々のクラスになる

import { User as ESMUser } from "user-lib";
const { User: CJSUser } = require("user-lib");

const u = new ESMUser();
console.log(u instanceof ESMUser); // true
console.log(u instanceof CJSUser); // false ← 別物!

対策はパッケージ提供側で、ESMの中からCJS実装をrequireする「ESM is the source of truth」パターンを採用することです(下記exports field節で解説)。

8. package.json の exports フィールド — 公開APIの正式宣言

exportsフィールドは、パッケージが何を公開するかを厳密に制御する現代的なメカニズムです。Node.js 12.7+/全主要バンドラがサポートしています。

8-1. 最小限の exports

{
  "name": "my-lib",
  "type": "module",
  "exports": "./dist/index.js"
}

これだけで、import x from "my-lib"./dist/index.jsに解決されます。これ以外のファイルは外部からimportできなくなる(プライベート化される)のがポイントです。

8-2. サブパスエクスポート

{
  "name": "my-lib",
  "type": "module",
  "exports": {
    ".": "./dist/index.js",
    "./utils": "./dist/utils.js",
    "./components/*": "./dist/components/*.js"
  }
}
// 利用側
import lib from "my-lib";
import utils from "my-lib/utils";
import Button from "my-lib/components/Button";

8-3. Conditional Exports — 環境別に出し分け

これがexports fieldの真骨頂です。ESM/CJS/型定義/ブラウザ/Node等の環境に応じて別ファイルを返せます

{
  "name": "my-lib",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "default": "./dist/index.js"
    }
  }
}

キーの順序が重要です:typesを最初、defaultを最後に置くのが鉄則です。

8-4. ブラウザ/Node別エクスポート

{
  "exports": {
    ".": {
      "browser": "./dist/browser.js",
      "node": "./dist/node.js",
      "default": "./dist/index.js"
    }
  }
}

8-5. 開発/本番別エクスポート

{
  "exports": {
    ".": {
      "development": "./dist/index.dev.js",
      "production": "./dist/index.prod.js",
      "default": "./dist/index.js"
    }
  }
}

9. Node.js Subpath Imports — 内部エイリアス

package.jsonのimportsフィールドは、パッケージ「内部」のエイリアスを定義する仕組みです。#で始まる名前を使います。

9-1. 基本構文

{
  "name": "my-app",
  "type": "module",
  "imports": {
    "#config": "./src/config.js",
    "#utils/*": "./src/utils/*.js"
  }
}
// どの階層からも同じパスでimportできる
import config from "#config";
import { format } from "#utils/format";

9-2. 環境別の内部alias

{
  "imports": {
    "#db": {
      "test": "./src/db/mock.js",
      "default": "./src/db/real.js"
    }
  }
}
# NODE_OPTIONS=--conditions=test で起動するとモックが返る
node --conditions=test ./src/index.js

10. Tree Shaking — 不要コード自動削除の仕組み

Tree Shakingは使われていないexportをビルド結果から削除する最適化です。ESMの「静的解析可能」という性質があってこそ成立する技術です。

10-1. Tree Shakingが効く書き方

// ✅ named export — 個別に取り込める
// utils.js
export function a() { /* */ }
export function b() { /* */ }
export function c() { /* */ }

// app.js
import { a } from "./utils.js";
// → ビルド結果には a だけ含まれ、b / c は削除される

10-2. Tree Shakingが効かないアンチパターン

// ❌ 名前空間import + 全部読み込み
import * as utils from "./utils.js";
utils.a();
// → bundlerが何が使われるか判定しにくく、全部残ることが多い

// ❌ default export でobjectを返す
// utils.js
export default { a, b, c };

// app.js
import utils from "./utils.js";
utils.a();
// → b / c も削除されない(objectのプロパティアクセスは静的解析不能)

10-3. sideEffects: false の宣言

package.jsonに"sideEffects": falseを書くと、「このパッケージのモジュールは副作用がない」とバンドラに伝えられ、より積極的にTree Shakingされます

{
  "name": "my-lib",
  "sideEffects": false
}
{
  "name": "my-lib",
  "sideEffects": [
    "./src/polyfills.js",
    "*.css"
  ]
}

10-4. /*#__PURE__*/ アノテーション

関数呼び出しに/*#__PURE__*/を付けると、戻り値が使われない場合はバンドラがその呼び出しごと削除します。

// 大きな辞書を初期化する処理
export const dict = /*#__PURE__*/ buildLargeDictionary();

function buildLargeDictionary() {
  // 重い処理...
  return { /* 数千エントリ */ };
}

// dict を使わない側ではバンドルから完全に消える

10-5. lodash / lodash-es の罠

// ❌ CJS版 lodash — 全部読み込まれる
import _ from "lodash";
_.debounce(fn, 100);
// → lodash全体(数百KB)がバンドル

// ⚠ サブパスimport — 安全だが冗長
import debounce from "lodash/debounce";

// ✅ lodash-es — ESM版なのでTree Shaking可
import { debounce } from "lodash-es";

11. 循環参照(Circular Dependency)

A.js が B.js を import し、B.js が A.js を import するような循環構造はESMでも検出されます。基本的には動きますが、「読み込み時点ではexportが空」という罠があります。

11-1. 動く循環参照

// a.js
import { b } from "./b.js";

export const a = "A";
export function callB() { return b; }
// b.js
import { a } from "./a.js";

export const b = "B";
export function callA() { return a; }

これはOKです。関数の中身が「呼ばれる時」にだけ相手の値を参照するので、初期化の順序問題が起きません。

11-2. 壊れる循環参照

// a.js
import { b } from "./b.js";
export const a = `from B: ${b}`; // ← 初期化中にbを参照
// b.js
import { a } from "./a.js";
export const b = `from A: ${a}`; // ← 初期化中にaを参照
// → "from A: undefined" など壊れた値になる

11-3. 循環参照の検出ツール

# ESLint
npm i -D eslint-plugin-import
# .eslintrc に import/no-cycle ルールを有効化

# madge
npm i -D madge
npx madge --circular ./src

11-4. 解消パターン:共通モジュールに分離

// types.js — 共通の依存を別ファイルに切り出す
export const TYPES = { A: "A", B: "B" };

// a.js
import { TYPES } from "./types.js";
export const a = TYPES.A;

// b.js
import { TYPES } from "./types.js";
export const b = TYPES.B;

12. ブラウザでESMを使う

12-1. <script type=”module”>

<!-- 通常のscript -->
<script src="./app.js"></script>

<!-- ESMとして読み込む -->
<script type="module" src="./app.js"></script>

<!-- インラインESM -->
<script type="module">
  import { greet } from "./greet.js";
  greet("World");
</script>

12-2. type=”module”の特徴

<!--
  type="module" は通常scriptと以下の点で異なる:

  1. defer相当(HTMLパース完了後に実行)
  2. strict modeが強制される
  3. 各モジュールが独自スコープを持つ(globalを汚さない)
  4. crossorigin属性のデフォルトが変わる
  5. importの拡張子・パスは厳密(./ や ../ や 絶対URL必須)
-->

<script nomodule src="./legacy.js"></script>
<script type="module" src="./modern.js"></script>
<!--
  古いブラウザはmoduleを無視+nomoduleを実行
  モダンブラウザはmoduleを実行+nomoduleを無視
-->

12-3. ブラウザの import 構文制約

// ✅ 相対パス + 拡張子必須
import { foo } from "./utils.js";

// ✅ 絶対URL
import { foo } from "https://cdn.example.com/lib.js";

// ❌ パッケージ名はそのままでは使えない
import { debounce } from "lodash-es"; // ブラウザ単体ではエラー!
//   ↑ Import Maps か Bundler が必要

12-4. Import Maps — ブラウザでパッケージ名を使う

<script type="importmap">
{
  "imports": {
    "react": "https://esm.sh/react@18",
    "react-dom/client": "https://esm.sh/react-dom@18/client",
    "lodash-es": "https://esm.sh/lodash-es@4",
    "#utils/": "./src/utils/"
  },
  "scopes": {
    "/admin/": {
      "react": "https://esm.sh/react@19"
    }
  }
}
</script>

<script type="module">
  import React from "react";
  import { createRoot } from "react-dom/client";
  import { debounce } from "lodash-es";
  // ↑ Bundler無しで動く
</script>

12-5. modulepreload で先読み

<!-- ESMモジュールの先読み(のmodule版) -->
<link rel="modulepreload" href="./pages/Dashboard.js">
<link rel="modulepreload" href="./components/Chart.js">

13. Bundlerの挙動 — Vite / Webpack

13-1. Vite — 開発はESMネイティブ

// vite.config.js
import { defineConfig } from "vite";

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ["react", "react-dom"],
          utils: ["lodash-es", "date-fns"],
        },
      },
    },
  },
  optimizeDeps: {
    include: ["lodash-es"], // 事前バンドル対象
  },
});

13-2. Webpack — ESMもCJSも食える

// webpack.config.js
module.exports = {
  experiments: {
    outputModule: true, // ESM形式で出力
  },
  output: {
    library: { type: "module" },
    chunkFormat: "module",
  },
  optimization: {
    usedExports: true,    // Tree Shaking有効
    sideEffects: true,    // package.jsonのsideEffectsを尊重
    minimize: true,
  },
};

13-3. esbuild / Rollup でのライブラリビルド

// build.js — 1つのソースから ESM + CJS + d.ts を出す
import { build } from "esbuild";

await build({
  entryPoints: ["src/index.ts"],
  outdir: "dist",
  format: "esm",
  outExtension: { ".js": ".js" },
  bundle: true,
  platform: "neutral",
});

await build({
  entryPoints: ["src/index.ts"],
  outdir: "dist",
  format: "cjs",
  outExtension: { ".js": ".cjs" },
  bundle: true,
  platform: "node",
});

14. パッケージ公開 — npm publishまで

14-1. デュアルESM/CJSパッケージのpackage.json

{
  "name": "@your-scope/awesome-lib",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "files": ["dist", "README.md"],
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "default": "./dist/index.js"
    },
    "./package.json": "./package.json"
  },
  "sideEffects": false,
  "engines": { "node": ">=18" },
  "publishConfig": { "access": "public" }
}

14-2. 公開コマンド

npm login
npm run build
npm publish --access public

# バージョン更新
npm version patch  # 1.0.0 → 1.0.1
npm version minor  # 1.0.1 → 1.1.0
npm version major  # 1.1.0 → 2.0.0
npm publish

14-3. ローカル動作確認

# packする → tgzが生成される
npm pack

# 別プロジェクトで試す
cd ../other-project
npm install ../awesome-lib/your-scope-awesome-lib-1.0.0.tgz

14-4. Workspace / pnpm でモノレポ管理

# pnpm-workspace.yaml
packages:
  - "packages/*"
  - "apps/*"
// apps/web/package.json
{
  "dependencies": {
    "@my-org/ui": "workspace:*",
    "@my-org/utils": "workspace:*"
  }
}
pnpm install                  # ワークスペース全体
pnpm --filter web build       # 特定パッケージだけ
pnpm -r build                 # 全パッケージ再帰実行

15. デバッグ&アンチパターン総まとめ

15-1. モジュール解決のデバッグ

# Node.jsで解決パスを見る
node --trace-warnings --experimental-resolve-debug ./app.js

# どのファイルが読まれているか追う
NODE_OPTIONS="--inspect" node ./app.js
# → chrome://inspect でモジュールツリーを確認

15-2. “Cannot use import statement outside a module” エラー

# 原因: package.json に "type": "module" がない
# 解決1: 追加する
{ "type": "module" }

# 解決2: ファイル拡張子を .mjs にする
mv app.js app.mjs

# 解決3: TypeScript を使い、tsconfigで "module": "NodeNext"

15-3. “ERR_MODULE_NOT_FOUND” エラー

// 多くの場合、拡張子忘れが原因
import { foo } from "./utils";    // ❌
import { foo } from "./utils.js"; // ✅

// import "./components" → import "./components/index.js"

15-4. アンチパターン:バレル(barrel)ファイルの乱用

// ❌ 巨大なバレルファイル(index.js)
// components/index.js
export * from "./Button.js";
export * from "./Modal.js";
export * from "./Chart.js"; // ← 重い
export * from "./Editor.js"; // ← 重い
// → 利用側で1つしか使わなくても全部読み込まれることがある

// ✅ 直接参照を推奨
import Button from "./components/Button.js";
import Modal from "./components/Modal.js";

15-5. アンチパターン:default export の混在

// ❌ default と named を混在させすぎ
export default function api() { /* */ }
export const VERSION = "1.0";
export class APIError extends Error { /* */ }
// → 利用側の書き方がブレる(import api, { VERSION, APIError })

// ✅ named のみで統一(再エクスポートでdefaultも作る場合は別ファイルに)
export function api() { /* */ }
export const VERSION = "1.0";
export class APIError extends Error { /* */ }

15-6. アンチパターン:re-export地獄

// ❌ 5階層の再エクスポート
// src/index.js → features/index.js → user/index.js → user/api/index.js → user/api/get.js
// → 型推論が遅くなる + Tree Shakingが効きにくい

15-7. パフォーマンス計測

# Viteの場合
npm run build -- --debug

# webpack-bundle-analyzer
npm i -D webpack-bundle-analyzer
# webpack.config.js に追加
# new BundleAnalyzerPlugin()

# rollup-plugin-visualizer
import { visualizer } from "rollup-plugin-visualizer";
// build.rollupOptions.plugins に追加

15-8. 最終チェックリスト

## ESMパッケージ公開前チェックリスト

- [ ] package.json に "type": "module" を指定
- [ ] exports フィールドで公開APIを限定
- [ ] types エントリで .d.ts を提供
- [ ] sideEffects: false を宣言(該当する場合)
- [ ] CJSも提供するなら types → import → require の順で書く
- [ ] 内部alias は #foo の imports フィールドで管理
- [ ] importの拡張子は .js を明記
- [ ] 循環参照を madge で検査
- [ ] npm pack → 別プロジェクトで実装動作確認
- [ ] Tree Shakingの結果を bundle analyzer で確認

まとめ

ES Modulesは「importと書けば動く」という単純さの裏に、CommonJSとの共存、Node.js特有の拡張子規約、exportsフィールド、Tree Shaking、デュアルパッケージハザードといった現代的な落とし穴が大量に潜んでいます。本記事の40個以上のコード例は、そのほとんどが実際の開発で踏む地雷とその回避法に対応しています。

この記事の重要ポイント

  • named exportを優先 — Tree Shaking・IDE補完・リネーム安全性で有利
  • dynamic importでルート単位のコード分割を実現
  • top-level awaitで初期化処理がシンプルに(Node 14.8+/全モダンブラウザ)
  • package.jsonのexportsで公開APIを厳密制御
  • CJS/ESM相互運用はdefault import経由が安全(named importは保証されない)
  • Tree ShakingにはsideEffects: false/*#__PURE__*/を活用
  • ブラウザはImport Mapsで「Bundler無しESM」運用も可能

モジュールシステムを「なんとなく」で乗り切ってきた方も、本記事を参考に1ファイルずつ修正していけば、Tree Shakingが効いた軽量バンドル・型安全なAPI・公開可能な品質のパッケージが手に届きます。次回の C07 では「JavaScript エラー処理完全実践ガイド」を予定しています。あわせてお読みください。

コメント

タイトルとURLをコピーしました