ES Modules と type="module" の本質を整理する

  • JavaScript
  • ES Modules
  • Vite

きっかけ

WordPressテーマの script.js がこのような構成になっていた。

import { initDrawer } from './_drawer.js';
import { initFadein } from './_fadein.js';
import { initTab } from './_tab.js';
// ...

document.addEventListener('DOMContentLoaded', () => {
  initDrawer();
  initFadein();
  initTab();
});

「なぜ各ファイルで即時実行せず、export して呼び出すのか」という疑問から、ES Modules の仕組みを整理することになった。

export function init() パターンの意図

各ファイルで処理を export function initXxx() として定義し、script.js で呼び出すパターンには以下の意図が考えられる。

  • 関心の分離:各ファイルが1つの機能に集中できる
  • オーケストレーターscript.js が「何をいつ実行するか」を管理する役割になる
  • 再利用性:関数として定義されているため、別のエントリーポイントや条件付き実行に使いまわせる

シンプルな import "./_fadein.js" との違い

ファイルをインポートするだけのパターンと比較すると、根本的な違いは「いつ実行されるか」。

  • import だけ:インポートされた瞬間にファイル内の処理が即時実行される
  • export + 呼び出し:明示的に関数を呼んだときだけ実行される

type="module" 環境では DOM タイミング問題は起きない

<script type="module"> はデフォルトで defer 相当の挙動をする。DOM の解析が完了してから実行されるため、各ファイルに DOMContentLoaded を書く必要はない。

実行順序の違いはほぼない

インポート順に実行されるという点では、2つのパターンの制御の精度は変わらない。

実用上の差は2点

  1. 条件付き実行export + 呼び出しパターンなら if で制御できる(各ファイルに書いても同等)
  2. コードの見通し:エントリーポイントを見るだけで「何が起動するか」がわかる(好みの範囲)

プロジェクト規約として「全ファイルを export + エントリーポイントで呼ぶ」に統一することで、ファイルを追加するときのルールが明確になるという実際的なメリットがある。

type="module" の本質

「ファイルをスコープとして独立させること」 が本質。

モジュールなしの問題

<script src="a.js"></script>
<script src="b.js"></script>

複数の <script> がグローバルスコープを共有するため、変数名の衝突が起きる。

// a.js
const foo = 'hello';

// b.js
console.log(foo); // 'hello' ← 別ファイルの変数が参照できてしまう
const foo = 'world'; // ❌ エラー:すでに foo が宣言されている

const/letwindow オブジェクトには付かないが、同一ページ内の別ファイルから参照できてしまう点は var と同様の問題が起きる。

モジュールありでの解決

type="module" を付けると、各ファイルが独自のスコープを持ち、export しない限り他のファイルから見えなくなる。

// a.js(モジュール)
const foo = 'hello'; // このファイルのスコープに閉じる

// b.js(モジュール)
console.log(foo); // ❌ 参照できない
const foo = 'world'; // ✅ 衝突しない

import はモジュール環境でのみ使える

import 構文は type="module" 環境でのみ動作する。HTMLに書くのはエントリーポイント1ファイルだけでよく、import で連鎖するファイルをブラウザが自動取得する。

<!-- これだけでよい -->
<script type="module" src="script.js"></script>

バンドルなし vs Vite バンドル

import でファイルを分割して読み込む構成は、バンドラーなしでも動作する。では Vite などでバンドルする意味は何か。

観点バンドルなしVite バンドル
ファイル取得ファイル数分のHTTPリクエスト(HTTP/2で並列)1ファイルにまとまる
npm パッケージ使えない使える
SCSS等の変換別途コンパイルが必要一括処理できる
本番最適化なしminify・ハッシュ付きファイル名
キャッシュ制御ファイル名が変わらず難しいハッシュで自動制御

HTTP/2環境ではファイルが並列取得されるため、ファイル数が増えても実用上の問題は少ない。課題になりやすいのはキャッシュの制御。

npm パッケージを使わず、SCSS は別途コンパイルしている構成であれば、バンドラーなしでも十分成立する。

バンドル後のスコープ

バンドラーは各モジュールを関数スコープで包んで独立させるため、type="module" の有無にかかわらず、バンドル後はスコープが保護される。

// バンドル後のイメージ
(function() {
  // a.js の中身
  const foo = 'hello';
})();

(function() {
  // b.js の中身
  const foo = 'world'; // 衝突しない
})();

これにより、古いブラウザ向けに type="module" なしで出力することも可能になる。

まとめ

  • type="module" の本質は「ファイルをスコープとして独立させること」
  • import 構文はモジュール環境でのみ使えるため、エントリーポイント1ファイルをHTMLに書けばよい
  • export function init() パターンとシンプルな import パターンの実用上の差は小さい。一貫性のある規約として採用されることが多い
  • バンドラーの主なメリットはnpmパッケージの利用・本番最適化・キャッシュ制御。ファイル数の問題ではない