モジュールの循環参照と対処法
モジュール A がモジュール B をインポートし、モジュール B もモジュール A をインポートしている状態を「循環参照」と呼びます。循環参照は思わぬバグの原因になるため、その仕組みと対処法を理解しておく必要があります。
循環参照の例
// a.js
import { b } from './b.js';
export const a = 'A';
console.log('a.js:', b);// b.js
import { a } from './a.js';
export const b = 'B';
console.log('b.js:', a);// main.js
import './a.js';何が起こるか
ES Modules では循環参照があってもエラーにはなりませんが、タイミングによっては undefined が取得されることがあります。
main.js が a.js をインポート
a.js が b.js をインポート(a.js の実行は一時停止)
b.js が a.js をインポート(a.js は未完了なので a は undefined)
b.js が完了し、a.js の実行が再開
出力は以下のようになります。
b.js: undefined ← a がまだ初期化されていない
a.js: B問題が起きるパターン
変数の初期化前にアクセスしようとすると問題が発生します。
// user.js
import { getRole } from './role.js';
export const user = {
name: 'Alice',
role: getRole() // role.js がまだ user を参照しようとすると問題
};// role.js
import { user } from './user.js';
export function getRole() {
return user.name === 'Alice' ? 'admin' : 'user';
// user が undefined の可能性
}対処法1:関数でラップする
値を直接エクスポートするのではなく、関数でラップすることで、実行タイミングを遅らせます。
// a.js
import { getB } from './b.js';
export const a = 'A';
export function getA() {
return a;
}
console.log('a.js:', getB()); // 関数呼び出し時には b が初期化済み// b.js
import { getA } from './a.js';
export const b = 'B';
export function getB() {
return b;
}
console.log('b.js:', getA());対処法2:モジュール構造の見直し
循環参照を解消するために、モジュールの依存関係を整理します。
循環参照あり
A → B → A(問題が起きやすい)
循環参照なし
A → C ← B(共通部分を別モジュールに)
// common.js(共通部分を切り出す)
export const shared = {
data: 'shared data'
};// a.js
import { shared } from './common.js';
export const a = { ...shared, name: 'A' };// b.js
import { shared } from './common.js';
export const b = { ...shared, name: 'B' };対処法3:遅延インポート
動的インポートを使用して、必要なタイミングでインポートします。
// a.js
export const a = 'A';
export async function useB() {
const { b } = await import('./b.js');
console.log(b);
}循環参照の検出
循環参照を検出するツールやリンターを活用しましょう。
ESLint
import/no-cycle ルールで循環参照を検出できます。
Madge
依存関係を可視化し、循環参照を発見するツールです。
// .eslintrc
{
"rules": {
"import/no-cycle": "error"
}
}循環参照は完全に避けることが理想ですが、避けられない場合は関数でラップするなどの対策を講じて、安全に扱えるようにしましょう。