モジュールの循環参照と対処法

モジュール 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"
  }
}

循環参照は完全に避けることが理想ですが、避けられない場合は関数でラップするなどの対策を講じて、安全に扱えるようにしましょう。