循環参照とは何か(まずイメージから)
モジュールの「循環参照(循環依存)」は、
モジュール A がモジュール B を import し、同時にモジュール B もモジュール A を import している状態 です。
もっと言うとこうです。
A.jsがB.jsを import しているB.jsがA.jsを import している
これで「A → B → A」とグルッと一周してしまう。
これが循環参照です。
循環しているだけなら「ダメ絶対」ではありませんが、
タイミングによって「まだ初期化されていない中途半端な値」を見る可能性がある ので、設計としてとても注意が必要です。
具体例で見る「素直に書くとこうなる危険パターン」
A.js と B.js が互いに import しあう例
// A.js
import { bFunc } from "./B.js";
export function aFunc() {
console.log("A の aFunc が呼ばれた");
bFunc();
}
JavaScript// B.js
import { aFunc } from "./A.js";
export function bFunc() {
console.log("B の bFunc が呼ばれた");
aFunc();
}
JavaScript// main.js
import { aFunc } from "./A.js";
aFunc();
JavaScript一見すると、お互いがお互いを呼び合うだけに見えますが、
問題は「モジュールが読み込まれる順番」と「評価されるタイミング」です。
ブラウザや Node.js は、main.js を読み始めると:
A.jsを読み込むA.jsの先頭でB.jsを import しようとするB.jsを読み込み始めるB.jsの先頭でまたA.jsを import しようとする(ここが循環)
このとき、A.js の評価がまだ完了していないタイミングで A.js を参照しようとするので、aFunc が undefined のままだったり、思ったように初期化されていなかったりします。
ここが重要です。
ES モジュール自体は 循環参照をサポートしている のですが、
「いつどの順番で評価されるか」を理解していないと、
「import したはずなのに中身が undefined」という怖い状態にハマりやすいのです。
なぜ「undefined」になるのか(少しだけ内部の動きをイメージ)
ES モジュールの評価の流れをざっくり
ES モジュールの読み込みは、ざっくり次のようなステップで行われます。
1つ目に「依存関係の解析」:どのモジュールがどれを import しているかを静的に解析する
2つ目に「モジュールのインスタンス化」:export の「箱」は先に作られる(名前だけ用意されるイメージ)
3つ目に「評価」:実際にコードを実行して、export に値が入る
循環しているときも、「箱」自体は用意されますが、
評価前にその export にアクセスすると「まだ値が入っていない」状態になります。
例えば:
// A.js
import { valueFromB } from "./B.js";
console.log("A.js 内", valueFromB); // ここでまだ未初期化の可能性
export const valueFromA = "A の値";
JavaScript// B.js
import { valueFromA } from "./A.js";
console.log("B.js 内", valueFromA); // ここでまだ未初期化の可能性
export const valueFromB = "B の値";
JavaScriptどちらを先に評価するかによって、
どこかで「未定義」の値を参照してしまう可能性があるわけです。
ここが重要です。
循環参照そのものよりも、「評価タイミングのズレ」によって “中途半端な状態” を読むのが危険。
だから、設計としては「循環しないようにモジュールを分解・整理する」のが基本の考え方になります。
典型的な「やばい」循環パターン
相互にクラスや関数を参照しあう
// User.js
import { Role } from "./Role.js";
export class User {
constructor(name, roleName) {
this.name = name;
this.role = new Role(roleName);
}
}
JavaScript// Role.js
import { User } from "./User.js";
export class Role {
constructor(name) {
this.name = name;
}
createUser(userName) {
return new User(userName, this.name);
}
}
JavaScriptUser と Role が互いに new し合っていて、
しかも互いを import している状態です。
このままだと、どこかのタイミングでまだ定義されていないクラスを使おうとして、ReferenceError や TypeError(User is not a constructor など)に出会う可能性が高くなります。
初期化コードで相互に依存している
// configA.js
import { configB } from "./configB.js";
export const configA = {
name: "A",
bName: configB.name
};
JavaScript// configB.js
import { configA } from "./configA.js";
export const configB = {
name: "B",
aName: configA.name
};
JavaScriptどちらの configX も、相手の configY を前提に初期化しているパターンです。
これも評価タイミングによって aName や bName が undefined になり得ます。
ここが重要です。
「初期化の段階で相互参照している」コードは特に危険。
コンストラクタ、トップレベルの const 初期値、モジュール読み込み時にすぐ実行される処理に注意が必要です。
循環参照を避けるための考え方(設計の視点)
依存方向を「一方向」に揃える
理想は、モジュール同士の矢印が一方向に揃っている状態です。
例えばこうです。
utils.js ← models/User.js ← services/UserService.js ← main.js
このように「下から上へ」だけ依存していると、循環は起きません。
逆に、User.js と Role.js の例のように
User.js → Role.jsRole.js → User.js
のように「リング状」の関係が生まれると、途端に危なくなります。
ここが重要です。
設計として、「依存が一方向に流れるようにレイヤーを分ける」意識を持つと、循環参照が自然と減る。
具体的には、共通で使う部分を「より下のレイヤー(共通モジュール)」に切り出すとよいです。
共通の「土台」モジュールを作る
さきほどの User と Role の例なら、
共通のインターフェースや型だけを別モジュールに出す、という手があります。
// models/types.js(共通の土台)
export class BaseEntity {
constructor(id) {
this.id = id;
}
}
JavaScript// models/User.js
import { BaseEntity } from "./types.js";
export class User extends BaseEntity {
constructor(id, name) {
super(id);
this.name = name;
}
}
JavaScript// models/Role.js
import { BaseEntity } from "./types.js";
export class Role extends BaseEntity {
constructor(id, name) {
super(id);
this.name = name;
}
}
JavaScriptUser と Role は互いを import せず、
共通の親である types.js を import するだけ、という構造にできます。
実行時に相互参照する必要があるなら「遅延参照」にする
どうしても相手のモジュールに依存したいが、
初期化タイミングで触ると危ない場合は、「関数の中で import する」「引数で渡す」などで遅らせます。
例えば:
// ActionA.js
export function doA() {
console.log("A の処理");
}
JavaScript// ActionB.js
export function doB() {
console.log("B の処理");
}
export function doBThenA() {
// ここで遅延 import(動的 import)する手もある
// const { doA } = await import("./ActionA.js");
// doA();
}
JavaScriptあるいは、呼び出す側で組み合わせる:
// main.js
import { doA } from "./ActionA.js";
import { doB } from "./ActionB.js";
async function run() {
doB();
doA();
}
JavaScriptここが重要です。
「モジュール自体が相互依存しないようにして、“組み合わせ” はさらに外側で行う」
という設計にすると、循環参照の罠から抜け出しやすくなります。
循環参照が起きてしまったときの気づき方と対処
こういう症状が出たら要注意
以下のような症状が出たら、「あ、どこかで循環してるかも」と疑ってください。
- import したはずの関数が
undefinedになっている TypeError: xxx is not a function(本当は export した関数のつもり)- クラスが
TypeError: Class constructor Foo cannot be invoked without 'new'ではなく、Foo is not a constructorと怒られる
こういうときは、「そのモジュールが、どこから import されているか」を紙に書いて追ってみると、
「A → B → C → A」のような輪が見つかることが多いです。
見つけたら「依存を一段上(または下)に上げる」
見つけた循環を壊す典型的な方法は:
- 共通部分を別モジュールに切り出して、そこだけを両者が参照する
- 組み合わせ処理を「さらに外側のモジュール」に移して、A/B 自体は互いを知らないようにする
という二つです。
さきほどの User / Role も、
「互いに new しあう」のではなく、
「UserService が User と Role を組み合わせる」ようにする、などが一つの解決策です。
まとめ
ES6 モジュールの「循環参照」の注意点をまとめます。
循環参照とは、モジュール同士がぐるっと一周して import しあっている状態
ES モジュールは循環自体はサポートしているが、「評価タイミング」によっては undefined や未初期化の値を参照してしまう
特に、トップレベルの初期化コードやコンストラクタで相互参照していると危険
設計としては、モジュールの依存を「一方向」に揃える、共通部分を別モジュールに切り出す、組み合わせは外側で行う、などで回避する
症状として「import したはずが undefined」「is not a function」などが出たら、循環参照を疑って依存関係を洗い出す
初心者のうちは、
「モジュール A がモジュール B を import しているなら、なるべく B から A は import しない」
というシンプルなルールを意識しておくと、安全側に倒せます。
そこから少しずつ、「共通モジュールを作る」「組み合わせ専用モジュールを作る」といった設計を試してみてください。
モジュール同士の依存を整理する感覚が身につくと、コード全体の構造が一気にクリアに見えてきます。
