Dev Study
← アプリケーションの土台
レッスン 8 / 11·約 9 分·第7期 アプリケーションの土台

モジュール — import は何をしているか

リクエストがアプリケーションに届くまで
  1. ブラウザURL入力
  2. DNS名前→IP
  3. ルータ経路選択
  4. ロードバランサ振り分け
  5. Nginx受け口
  6. アプリ処理今ここ
  7. DB保存

名前を実体に結び付ける表引きは、第9週『仮想メモリ — なぜプロセスは互いのメモリを壊さないのか』で見た番地解決と同じ形です。違うのは表を引く時期です。

まず結論

import が行っているのは、ファイルの中の名前を別のファイルにある実体に結び付ける表引きです。名前そのものに実体は入っておらず、どこを指すかを決める作業が別にあります。

ESM はこの表引きをコードが1行も走る前(Link 段)に済ませ、CommonJS の require() は実行中の関数呼び出しとして済ませます。時期が違うので、同じ名前を取り出しても結果が変わることがあります。

? 読む前に予測

`{ "type": "module" }` の package.json の隣にある .js で `require("fs")` を呼ぶと、何が起きるでしょうか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

import は名前を実体に結び付ける表引き

import { render } from "./view.js" と書いたとき、render という名前はそのファイルの中に存在しません。別のファイルにある実体を指すだけの名前です。指定子からファイルを特定し、その中の render と結び付ける。これをモジュール解決と呼びます。

肝心なのは、この表引きをいつ行うかです。ECMAScript 2025 の仕様は、モジュールを2段で処理すると定めています。

Link が「依存を辿って解決する段」、Evaluate が「コードを走らせる段」。仕様は Link の完了が Evaluate の前提だと書いています。つまり import の表引きは実行前に終わっています。

第9週『仮想メモリ — なぜプロセスは互いのメモリを壊さないのか』で変換表を引いて番地に辿り着いたのと同じ形で、違うのは時期です。

具体実物

解決が失敗すると、コードは1行も走らない

条文で確かめます。16.2.1.7.3.1 InitializeEnvironment の step 7 は、import した名前ごとにこう処理します。

ECMAScript 2025 16.2.1.7.3.1 InitializeEnvironment ( ) step 7(体裁を平文化)コマンド出力
7. For each ImportEntry Record in of module.[[ImportEntries]], do
a. Let importedModule be GetImportedModule(module, in.[[ModuleRequest]]).
b. If in.[[ImportName]] is namespace-object, then
...
c. Else,
i. Let resolution be importedModule.ResolveExport(in.[[ImportName]]).
ii. If resolution is either null or ambiguous, throw a SyntaxError exception.
...
iv. Else,
1. Perform CreateImportBinding(env, in.[[LocalName]], resolution.[[Module]], resolution.[[BindingName]]).

ResolveExport が null なら、その場で SyntaxError。この処理は Link 段にあるのでエラーは評価より前に出ます。存在しない名前を import したファイルは1行も走りません。

解決できた場合に作られるのが CreateImportBinding の束縛です。

仕様の語は immutable indirect binding(変更不可の間接束縛)です。値のコピーではなく相手側の束縛へ間接的に到達します。「ライブバインディング」と呼ばれているものの正体はこれで、仕様はその語を使っていません(引用中の In additional to は原文のままで、誤植ではありません)。

✓ ここまでの確認

存在しない名前を `import { nope } from "./x.js"` で取り出そうとすると、ESM ではいつエラーになりますか。

抽象原理

require は予約語ではなく、ただの引数

もう一方の CommonJS。require("./x") は構文ではなく関数呼び出しで、実行中でなければ起きません。では require という名前はどこから来るのか。docs にそのまま書いてあります。

require も module も __dirname も、このラッパー関数の引数です。ES module にはラッパーが被せられないので引数も渡りません。docs の ESM 側も No __filename or __dirname の見出しで These CommonJS variables are not available in ES modules. と書き、require は module.createRequire() で作るよう案内しています。

差は文法にも出ます。16.2 Modules は ModuleItem : ImportDeclaration / ExportDeclaration / StatementListItem と定め、ImportDeclaration は ModuleItem です。つまりトップレベル専用で、if の中にも関数の中にも書けない。一方 require() はただの呼び出しなので条件分岐の中にも書けます。置ける場所の違いが、解決の時期の違いをそのまま表しています。

具体実物

同じカウンタを、2つの方式で読む

時期の差が値の差になるところを実機で見ます。同じ構造の2ファイルを両方式で用意しました。

ESM 側(Node v24.21.0, macOS)コマンド出力
$ cat counter.mjs
export let n = 0;
export function inc() { n++; }
$ cat main.mjs
import { n, inc } from "./counter.mjs";
inc();
console.log(n);
$ node main.mjs
1
CommonJS 側(Node v24.21.0, macOS)コマンド出力
$ cat counter.cjs
exports.n = 0;
exports.inc = () => { exports.n++; };
$ cat main.cjs
const { n, inc } = require("./counter.cjs");
inc();
console.log(n);
$ node main.cjs
0
$ cat main2.cjs
const c = require("./counter.cjs");
c.inc();
console.log(c.n);
$ node main2.cjs
1

main.cjs が 0、main2.cjs が 1。同じモジュールを同じ require で読んで結果が違います。

✓ ここまでの確認

`const { n, inc } = require("./counter.cjs")` で取り出した `n` が、`inc()` を呼んだ後も古い値のままなのはなぜですか。

⇄ 2つを見比べる
具体例 A

ESM: 評価前に解決し、間接束縛を作る

import 宣言はトップレベル専用で、Link 段で依存が全部辿られます。作られるのは値のコピーではなく相手の束縛への間接束縛なので、inc() が更新した値がそのまま見えます。

$ node main.mjs(Node v24.21.0, macOS)コマンド出力
import { n, inc } from "./counter.mjs";
inc();
console.log(n);
--- 出力 ---
1
具体例 B

CommonJS: 実行中に呼び出して、戻り値を受け取る

require は関数呼び出しなので、その行に到達したときに初めて解決されます。戻り値はオブジェクトで、分割代入で取り出した瞬間に値がコピーされます。以後 inc() が何をしてもコピー側には伝わりません。

$ node main.cjs(Node v24.21.0, macOS)コマンド出力
const { n, inc } = require("./counter.cjs");
inc();
console.log(n);
--- 出力 ---
0

同じ `{ n, inc }` を取り出して同じ `inc()` を呼んだのに、片方は 1、片方は 0。何が違うのでしょうか。

「n という名前が何を指しているか」を両方で考えてみてください。片方は実体を指し、片方は値を持っています。

抽象原理

時期の違いが、道具のできることを決める

表引きの時期は、エンジンやツールが実行せずに何を知れるかを決めます。

ESM と CommonJS で、解決が起きる位置
ESM: パースimport 宣言を集める○ 依存の一覧が確定
ESM: Link依存を辿り ResolveExport○ 解決が完了。名前が無ければ SyntaxError
ESM: Evaluateコードを走らせる○ 解決済みの間接束縛を読む
CJS: 評価開始ラッパーに require を渡して実行○ 依存はまだ1つも読まれない
CJS: require の行呼び出しとしてファイルを読む○ ここで初めて解決。戻り値を受け取る
CJS: 戻り値を使うオブジェクトから取り出す○ 取り出した時点で普通の変数
↓
ESM は「走る前に全部分かっている」、CommonJS は「走ってみないと分からない」
  • ・import がトップレベル専用なのは、この Link 段を可能にするため
  • ・実行時に指定子を決めるなら 13.3.10 Import Calls の import()(戻り値は Promise)

「走る前に全部分かっている」ことの帰結が、使われていないコードの削除です。esbuild の定義を読みます。

どの宣言がどこからも参照されていないかを実行せずに言い切るには、依存の対応表が静的に得られている必要があります。逆に require() はどのファイルが読まれるか実行してみるまで確定しません。この続きは次のレッスンで扱います。

具体実物

混ぜるとどうなるか — 実機のエラー文を読む

2つの方式が同居する現場では混ざります。{ "type": "module" } の隣で require を呼んだときの出力です。

$ node uses-require.js(package.json は { "type": "module" }。Node v24.21.0, macOS)コマンド出力
file:///.../exp-a-esm/uses-require.js:1
const fs = require("fs");
^
 
ReferenceError: require is not defined in ES module scope, you can use import instead
This file is being treated as an ES module because it has a '.js' file extension and '/.../exp-a-esm/package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension.
at file:///.../exp-a-esm/uses-require.js:1:12
at ModuleJob.run (node:internal/modules/esm/module_job:561:25)

種類が ReferenceError であることに注目してください。**「require は使えません」ではなく「require という名前が定義されていません」**です。ラッパーの引数が渡らなかった事実をそのまま述べています。逆向きは別のエラーです。

$ node uses-import.js(隣に package.json 無し。Node v24.21.0, macOS)コマンド出力
(node:16233) Warning: Failed to load the ES module: /.../exp-a-cjs/uses-import.js. Make sure to set "type": "module" in the nearest package.json file or use the .mjs extension.
/.../exp-a-cjs/uses-import.js:1
import fs from "fs";
^^^^^^
 
SyntaxError: Cannot use import statement outside a module
at wrapSafe (node:internal/modules/cjs/loader:1861:18)

こちらは SyntaxError。CommonJS として構文解析した結果、import 文がそこにあってはいけない、という報告です。

なお、CommonJS から ESM を読む方向は現在の Node で動きます。

要件の筆頭は The module is fully synchronous (contains no top-level await)。実機(v24.21.0)では const m = require("./lib.mjs") が [Module: null prototype] { __esModule: true, answer: 42, default: [Function: hello] } を返しました。フラグ無しで使えるのは v23.0.0 / v22.12.0 / v20.19.0 から、既定で experimental 警告が出なくなったのは v23.5.0 / v22.13.0 / v20.19.0 から、experimental 指定が外れたのは v24.15.0 から。この3つは別の時点なので、混同すると版の判断を誤ります。

この章のまとめ

  • import が行うのは名前を別ファイルの実体に結び付ける表引き。第9週の仮想メモリと同じ形で、違うのは時期。
  • ESM は **Link 段(評価前)**に依存グラフを解決する。名前が無ければ SyntaxError で、1行も走らない。
  • 作られるのは仕様の語で immutable indirect binding。値のコピーではなく相手の束縛への間接参照(通称「ライブバインディング」)。
  • CommonJS の require / module / __dirname はラッパー関数の引数。ES module には渡ってこず、ReferenceError になる。
  • import { n } が 1、const { n } = require(...) が 0 になるのは分割代入がコピーしたから。c.n と読めば 1。
  • 解決が静的なら実行せずに依存が分かる。esbuild の declaration-level dead code removal(tree shaking)はこの見通しの上に成り立つ。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

環境:

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 4正解: 0

ECMAScript 2025 仕様が import の束縛について使っている用語はどれですか。

用語集(5語)
モジュール解決
指定子から実際のファイルを特定し、その中の名前と結び付ける処理。ESM は評価前の Link 段で、CommonJS は実行中の require() 呼び出しで行う。
Link / Evaluate
ECMAScript 2025 が定めるモジュール処理の2段。Link は依存を辿って解決し Module Environment Record を作る段、Evaluate はコードを走らせる段。仕様は Link の完了が Evaluate の前提だと規定する。
immutable indirect binding
import した名前に作られる束縛。値のコピーではなく、別の Environment Record にある束縛へ間接的に到達する。通称「ライブバインディング」。仕様の語はこちら。
module wrapper
CommonJS のファイルを包む関数。`(function(exports, require, module, __filename, __dirname) { ... })` の形で、require や __dirname はその引数。ES module には渡らない。
module namespace object
モジュールのエクスポートをまとめたオブジェクト。`import * as m` や Node の `require(esm)` の戻り値。実機では `[Module: null prototype]` と表示される。