ウェブエンジニア問題集
第15章

モジュールシステム — import/exportとCommonJSの違い

14
この章の目次開く

実務のJavaScriptプロジェクトは、数十〜数千のファイルでできています。ファイルを分割し、必要なものを取り込み合う仕組みがモジュールシステムです。あなたが毎日書いている import 文がまさにそれです。

ただしJavaScriptには、歴史的な事情でモジュールの方式が2つあります。標準のESModulesimport / export)と、Node.js発祥のCommonJSrequire / module.exports)です。この2つの違いを知らないと、「Reactのコードでは import なのに、古い設定ファイルでは require と書いてある」「Cannot use import statement outside a module というエラーが出た」といった場面で手が止まります。

学習者学習者

import は毎日書いてますけど、正直「おまじない」です…。{} を付けるときと付けないときの違いも、雰囲気で使い分けてます。

この章では、import / export の全パターンを整理したうえで、CommonJSとの違い、動的import、Tree Shakingまでを一気につなげます。読み終わるころには「おまじない」が「仕組み」に変わっているはずです。

なぜモジュールが必要か — 全部グローバルだった時代

かつてのJavaScriptにはモジュールの仕組みがなく、複数の <script> タグでファイルを読み込んでいました。この方式には致命的な問題があります。すべてのファイルがグローバルスコープを共有するのです。

<script src="utils.js"></script>
<script src="app.js"></script>
html
// utils.js
var format = function (date) { /* ... */ };
 
// app.js — 別ファイルなのに、同じ名前を使うと上書きしてしまう
var format = function (price) { /* ... */ }; // utils.jsのformatが消えた!
js

ファイルが増えるほど、名前の衝突・読み込み順の管理・「この関数どこで定義されてるの?」問題が深刻になります。モジュールシステムは、この問題を根本から解決します。

モジュールとは「独立したスコープを持ち、公開したいものだけを export で明示するファイル」のことです。export していないものは外から一切見えません。クロージャで学んだ「隠蔽」を、ファイル単位で言語がサポートしてくれる仕組みと言えます。

モジュール同士が連携するイメージ
各ファイルは独立した部屋。exportした窓口を通じてだけ、やり取りできる

ESModules — 標準のimport/export

**ESModules(ESM)**は、ES2015で言語標準になったモジュール方式です。ブラウザもNode.jsもネイティブに対応しており、新しく書くコードはESM一択と考えて構いません。

エクスポートには名前付きデフォルトの2種類があります。ここが {} の有無の分かれ目なので、しっかり区別しましょう。

名前付きエクスポート — 複数OK・名前で取り出す

宣言の頭に export を付けると、その名前のまま公開されます。1ファイルからいくつでもエクスポートできます。

// math.js
export const TAX_RATE = 0.1;
 
export function addTax(price) {
  return price * (1 + TAX_RATE);
}
js

インポートする側は、同じ名前{} で指定して取り出します。

// app.js
import { addTax, TAX_RATE } from './math.js';
 
addTax(1000); // 1100
js

名前付きエクスポートは「エクスポート時の名前」と「インポート時の名前」が一致している必要があります。名前が違うとエラーではなく undefined になる…ことはなく、ESMではビルド時・読み込み時にエラーで検出されます。この「名前の対応を機械的にチェックできる」性質が、後述のTree Shakingにもつながります。

名前を変えたいときは as を使います。

import { addTax as calcPriceWithTax } from './math.js';
js

デフォルトエクスポート — 1ファイル1つ・名前は自由

export default は「このファイルの代表」を1つだけ公開する書き方です。

// Button.js
export default function Button() {
  /* ... */
}
js

インポートする側は {} を付けず、好きな名前で受け取れます。

import Button from './Button.js'; // この名前は自由(MyButtonでも動く)
js

インポートの書き方まとめ

書き方用途
名前付きインポートimport { a, b } from './m.js'名前付きエクスポートを取り出す
リネームimport { a as x } from './m.js'名前の衝突を避ける
デフォルトインポートimport a from './m.js'デフォルトエクスポートを受け取る
名前空間インポートimport * as m from './m.js'全エクスポートを m.a の形でまとめて使う
副作用インポートimport './m.js'何も受け取らず、実行だけする(CSS読み込み等)
学習者学習者

{} を付けるかどうかって、「気分」じゃなくて「相手が名前付きかデフォルトか」で決まってたんですね…!

先生先生

そのとおり。だからインポートでつまずいたら、相手のファイルのexportの書き方を見るのが正解。export default なら {} なし、export const なら {} あり。これだけで迷いは消えるよ。

ブラウザでESMを使う

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

<script type="module" src="app.js"></script>
html

type="module" のスクリプトは、通常のスクリプトといくつか挙動が違います。

  • 自動的にstrictモードになる(thisの章で触れたとおり、単独呼び出しの thisundefined
  • 変数がグローバルを汚染しない(モジュールスコープ)
  • 読み込みが自動的に遅延され、HTMLの解析をブロックしない

なお、ReactやNext.jsのプロジェクトではバンドラー(ViteやTurbopackなど)がモジュールの解決を担うため、type="module" を自分で書く機会はほぼありません。書き方はまったく同じESMです。

CommonJS — Node.js発祥のrequire

ESMが標準化される前、サーバーサイドのNode.jsは独自のモジュール方式**CommonJS(CJS)**を採用しました。require で読み込み、module.exports で公開します。

// math.js(CommonJS)
const TAX_RATE = 0.1;
 
function addTax(price) {
  return price * (1 + TAX_RATE);
}
 
module.exports = { addTax, TAX_RATE };
js
// app.js(CommonJS)
const { addTax } = require('./math.js');
js

新規に書く機会は減りましたが、読めることは今も必須です。npmの古いパッケージ、Node.js用の設定ファイル、既存プロジェクトのコードなど、CJSは至るところに現存しています。

ESMとCommonJSの違い

観点ESModulesCommonJS
構文import / exportrequire() / module.exports
読み込みの決定静的(コードの実行前に解析される)動的(実行時に関数として評価される)
書ける場所ファイルのトップレベルのみどこでも(if文の中でも可)
読み込み方式非同期を前提とした設計同期(読み終わるまで次に進まない)
ブラウザ対応ネイティブ対応非対応(バンドラーによる変換が必要)
Tree Shaking効く原則効かない

いちばん重要な違いは「静的か動的か」です。ESMの importコードを1行も実行する前にすべて確定します。一方CJSの require はただの関数呼び出しなので、実行してみるまで何が読み込まれるかわかりません。

// CommonJSではこう書けてしまう(実行時にしか判明しない)
if (process.env.NODE_ENV === 'development') {
  const debugTool = require('./debug.js');
}
js
「実行前に依存関係がすべて確定する」というESMの静的な性質こそが、ビルドツールによる最適化(Tree Shaking)を可能にしています。

動的import — 必要になってから読み込む

ESMの import 文は静的で、条件分岐の中には書けません。しかし「この機能は、使う人だけが読み込めばいい」という場面は確実にあります。そのための仕組みが動的import、つまり関数のように呼べる import() です。

構文: import(moduleSpecifier)

引数説明
moduleSpecifier(第1引数)読み込むモジュールのパス(文字列)。変数や式でもよい

戻り値: モジュールオブジェクト(全エクスポートを持つオブジェクト)で解決される Promise

戻り値がPromiseなので、非同期処理の章で学んだ await がそのまま使えます。

// 重いグラフ描画ライブラリを、ボタンが押されたときだけ読み込む
button.addEventListener('click', async () => {
  const { drawChart } = await import('./chart.js');
  drawChart(data);
});
js

初期表示に不要なコードを後回しにしてページの読み込みを速くする、この手法は**コード分割(code splitting)**と呼ばれます。Next.jsのページ単位の自動分割や next/dynamic も、この import() が土台です。

必要になってから取りに行くイメージ
全部を最初に抱えて出発せず、必要になった時点で取りに行くのが動的import

Tree Shaking — 使わないコードを削ぎ落とす

モジュールの静的な性質が最も活きるのがTree Shakingです。ビルドツール(Viteやwebpackなどのバンドラー)が、インポートされていないエクスポートを最終成果物から削除する最適化を指します。木を揺すって枯れ葉を落とすイメージからこの名前が付きました。

// utils.js — 10個の関数をエクスポートしているとする
export function formatDate() { /* ... */ }
export function formatPrice() { /* ... */ }
// ...他8個
 
// app.js — 使うのは1個だけ
import { formatDate } from './utils.js';
js

これが成立するのは、ESMの import / export が静的で、「どの名前が使われているか」を実行せずに追跡できるからです。実行してみないと何をエクスポートするかわからないCommonJSでは、バンドラーは安全のために全部を残すしかありません。

よくあるハマりどころ

{} の有無を間違える — defaultと名前付きの混同

エクスポート方式とインポート構文が食い違うのは、最も頻繁に起きるエラーです。

// Button.js
export default function Button() { /* ... */ }
 
// ❌ デフォルトエクスポートを {} で受けようとしている
import { Button } from './Button.js';
// → SyntaxError または「Buttonがundefined」系のエラー
js

エラーメッセージに does not provide an export named 'Button' とあれば、相手のファイルの export の書き方を確認してください。インポートの書き方は、常にエクスポート側が決めるのでした。

デフォルトエクスポートは名前のtypoに気づけない

デフォルトインポートの名前は自由——この便利さは、裏返すとノーチェックということです。

// ❌ 動いてしまう(本来はButtonという名前のコンポーネント)
import Botton from './Button.js';
js

名前付きエクスポートなら、typoは即エラーで検出されます。エディタの自動インポートや一括リネームとも相性がよいため、チーム開発では「原則、名前付きエクスポートを使う」という規約を採用するプロジェクトが増えています。

ESMのファイルでrequireは使えない(逆も然り)

2つの方式の構文は混ぜられません。Node.jsで頻出のエラーとして覚えておきましょう。

エラーメッセージ原因
Cannot use import statement outside a moduleCJS扱いのファイルに import を書いた
require is not defined in ES module scopeESM扱いのファイルに require を書いた

対処は「ファイルの扱いを合わせる」ことです。package.json"type": "module" を追加してプロジェクトをESM化するか、そのファイルの拡張子を .mjs / .cjs にして個別に指定します。

ブラウザのESMではパスを省略できない

バンドラー環境では import React from 'react' のようなパッケージ名だけの指定(ベア指定子)が使えますが、これはバンドラーが node_modules から解決してくれているからです。ブラウザで直接ESMを使う場合、相対パス(./../)または絶対URLで、拡張子まで書く必要があります。

// バンドラー環境ではOK / ブラウザ直接ではエラー
import { format } from 'date-fns';
import { addTax } from './math'; // 拡張子なしもNG
 
// ブラウザ直接で動く形
import { addTax } from './math.js';
js

ちゃんと使うためのポイント

  • モジュール=独立したスコープを持ち、export したものだけを公開するファイル。グローバル汚染と名前衝突を根本から防ぐ
  • エクスポートは2種類。名前付き(複数OK・名前一致必須・{} で受ける)とデフォルト(1つだけ・名前自由・{} なし)
  • インポートの書き方は常にエクスポート側の書き方で決まる。迷ったら相手のファイルを見る
  • CommonJS(require / module.exports)はNode.js発祥の旧方式。新規はESMで書き、CJSは「読める」状態を保つ
  • import()(動的import)はモジュールオブジェクトのPromiseを返す。初期表示に不要なコードの遅延読み込み(コード分割)に使う
  • ESMの静的な性質がTree Shakingを可能にする。バンドルサイズが気になるなら名前付き・個別インポートを意識する

ここまでで、JavaScriptの言語としての主要な仕組み——関数、オブジェクト、this、クロージャ、非同期処理、モジュール——が一通り揃いました。次の章では、その力を使ってブラウザの画面を動かすDOM操作に進みます。「JavaScriptでHTMLを書き換える」という、フロントエンドの原点です。

参考リンク

JavaScriptクイズに挑戦するこの章で学んだJavaScriptの知識を、4択クイズでアウトプットして定着させよう