循環参照で『Cannot access before initialization』エラーが出る問題をESLintとツールで解決

Node.jsやReactで開発していて、突然「Cannot access 'X' before initialization」や「undefined is not a function」といったエラーに遭遇したことはありませんか?コードは正しく書けているはずなのに、なぜかモジュールの読み込みでエラーが出る…その原因は循環参照(Circular Dependencies)にあるかもしれません。

この記事では、循環参照の仕組みを理解し、ESLintやツールを使って効率的に検出・解決する方法を実践的に解説します。

GitHub
GitHub - import-js/eslint-plugin-import: ESLint plugin with rules that help validate proper imports. ESLint plugin with rules that help validate proper imports. - import-js/eslint-plugin-import
目次

循環参照エラーの症状と原因を理解しよう

よくあるエラーメッセージと発生パターン

循環参照によって引き起こされる典型的なエラーは以下のようなものです:

// よく見るエラーメッセージ
Cannot access 'ClassA' before initialization
TypeError: undefined is not a function
TypeError: Cannot read properties of undefined
ReferenceError: X is not defined

これらのエラーは、以下のような状況で発生します:

  • Node.js開発中:モジュール間でお互いをrequireし合っている
  • Webpackビルド時:バンドル時に「Circular dependency detected」警告が大量発生
  • React/Vue.js:コンポーネント間の循環参照でアプリが動かない
  • TypeScript:型定義間の循環参照でコンパイルエラー

CommonJSとES6 Modulesでの違い

// CommonJS(Node.js)の場合
// a.js
const b = require('./b.js');
module.exports = { name: 'A' };

// b.js  
const a = require('./a.js'); // この時点でa.jsは完全に読み込まれていない
console.log(a); // {} 空オブジェクトが返される
// ES6 Modules の場合
// a.js
import { b } from './b.js';
export const a = 'A';

// b.js
import { a } from './a.js'; // ReferenceError: Cannot access 'a' before initialization
export const b = 'B';

なぜ循環参照が問題になるのか

循環参照が問題となる根本的な理由は、モジュールの読み込みタイミングにあります:

実行順序 何が起こるか 結果
1. A.jsの読み込み開始 B.jsをimport/require A.jsの処理が一時停止
2. B.jsの読み込み開始 A.jsをimport/require A.jsはまだ初期化途中
3. 循環参照の検知 不完全なA.jsが返される undefined や空オブジェクト

緊急解決!3つの即効対処法

循環参照エラーが今まさに発生している場合、以下の方法で即座に解決できます。

方法1: Lazy require による回避

// ❌ 問題のあるコード
const UserService = require('./UserService');

class CustomerService {
  getUser() {
    return UserService.find(); // エラー:UserServiceが不完全
  }
}

// ✅ 修正後:使う直前でrequire
class CustomerService {
  getUser() {
    const UserService = require('./UserService'); // lazy require
    return UserService.find(); // 正常に動作
  }
}

方法2: module.exports の順序調整

// ❌ 問題のあるコード
const ClassB = require('./ClassB');

class ClassA {
  // クラス定義
}

module.exports = ClassA; // requireの後にexports

// ✅ 修正後:exportsを先に定義
class ClassA {
  // クラス定義
}

module.exports = ClassA; // requireの前にexports
const ClassB = require('./ClassB');

方法3: Dynamic import を使った解決

// ES6 Modulesでの解決法
export class ComponentA {
  async loadComponentB() {
    const { ComponentB } = await import('./ComponentB.js'); // dynamic import
    return new ComponentB();
  }
}

ESLint import/no-cycle で循環参照を予防

eslint-plugin-import の設定方法

# パッケージのインストール
npm install --save-dev eslint-plugin-import
// .eslintrc.json
{
  "extends": [
    "eslint:recommended",
    "plugin:import/recommended"
  ],
  "plugins": ["import"],
  "rules": {
    "import/no-cycle": ["error", {
      "maxDepth": 10,
      "ignoreExternal": true
    }]
  }
}

TypeScriptプロジェクトでの設定

// TypeScript用の設定
{
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:import/typescript"
  ],
  "parser": "@typescript-eslint/parser",
  "settings": {
    "import/parsers": {
      "@typescript-eslint/parser": [".ts", ".tsx"]
    },
    "import/resolver": {
      "typescript": true,
      "node": true
    }
  },
  "rules": {
    "import/no-cycle": ["error", {
      "maxDepth": 10,
      "ignoreExternal": true,
      "allowUnsafeDynamicCyclicDependency": false
    }]
  }
}

設定オプションの使い分け

オプション 説明 推奨設定
maxDepth 検出する循環参照の深さ 10(パフォーマンス考慮)
ignoreExternal node_modulesを除外 true(通常は除外)
allowUnsafeDynamicCyclicDependency dynamic importの循環を許可 false(安全性重視)

検出ツールで循環参照を可視化

Madge による依存関係の可視化

あわせて読みたい
# Madgeのインストール
npm install -g madge

# 循環参照を検出
madge --circular src/

# 視覚化された依存関係グラフを生成
madge --image dependency-graph.svg src/

# 特定のファイルから始まる循環参照を調査
madge --circular --json src/ > circular-deps.json
// プログラムから使用する場合
const madge = require('madge');

madge('src/')
  .then((res) => {
    const circular = res.circular();
    if (circular.length > 0) {
      console.log('循環参照が発見されました:');
      circular.forEach(cycle => {
        console.log(cycle.join(' → '));
      });
    }
  });

Webpack circular-dependency-plugin の活用

あわせて読みたい
// webpack.config.js
const CircularDependencyPlugin = require('circular-dependency-plugin');

module.exports = {
  plugins: [
    new CircularDependencyPlugin({
      // node_modulesを除外
      exclude: /node_modules/,
      // 検出時にビルドを失敗させる
      failOnError: true,
      // より詳細な情報を表示
      allowAsyncCycles: false,
      cwd: process.cwd(),
      // カスタム処理を追加
      onDetected({ module: webpackModuleRecord, paths, compilation }) {
        compilation.warnings.push(new Error(paths.join(' -> ')));
      }
    })
  ]
};

CI/CDパイプラインへの組み込み

# GitHub Actions での例
name: Circular Dependency Check
on: [push, pull_request]

jobs:
  check-circular-deps:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      
      - run: npm ci
      - run: npx madge --circular src/
      - run: npm run lint # ESLint with import/no-cycle

React Hooks での循環参照問題

useEffect 無限ループの回避

// ❌ 無限ループを引き起こすコード
function BadComponent() {
  const [count, setCount] = useState(0);
  const [items, setItems] = useState([]);

  useEffect(() => {
    setItems([...items, count]); // itemsを依存配列に含めると無限ループ
  }, [count, items]); // 危険:itemsが変更されるたびに実行

  return <div>{items.length}</div>;
}
// ✅ 修正後:関数形式のsetStateを使用
function GoodComponent() {
  const [count, setCount] = useState(0);
  const [items, setItems] = useState([]);

  useEffect(() => {
    setItems(prevItems => [...prevItems, count]); // 前の状態を参照
  }, [count]); // countの変更時のみ実行

  return <div>{items.length}</div>;
}

Custom Hooks での循環参照対策

// カスタムフック:useNoRenderRef
function useNoRenderRef(currentValue) {
  const ref = useRef(currentValue);
  ref.current = currentValue;
  return ref;
}

// 使用例
function ComponentWithNoLoop() {
  const [item, setItem] = useState(0);
  const [queue, setQueue] = useState([]);
  const queueRef = useNoRenderRef(queue);

  useEffect(() => {
    // refを使用して循環参照を回避
    setQueue(queueRef.current.concat(item));
  }, [item, queueRef]);

  return <div>Queue length: {queue.length}</div>;
}

useRef を使った解決パターン

function SearchComponent() {
  const [searchTerm, setSearchTerm] = useState('');
  const [results, setResults] = useState([]);
  const searchTimeoutRef = useRef();

  useEffect(() => {
    // 前のタイムアウトをクリア
    if (searchTimeoutRef.current) {
      clearTimeout(searchTimeoutRef.current);
    }

    // 新しいタイムアウトを設定
    searchTimeoutRef.current = setTimeout(() => {
      // 検索実行(循環参照を回避)
      performSearch(searchTerm).then(setResults);
    }, 300);

    // クリーンアップ
    return () => {
      if (searchTimeoutRef.current) {
        clearTimeout(searchTimeoutRef.current);
      }
    };
  }, [searchTerm]);

  return (
    <div>
      <input 
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
      />
      <div>{results.length} results</div>
    </div>
  );
}

根本解決:設計レベルでの循環参照回避

Dependency Injection パターン

// ❌ 循環参照のあるコード
// UserService.js
const OrderService = require('./OrderService');
class UserService {
  getUserOrders(userId) {
    return OrderService.getByUserId(userId);
  }
}

// OrderService.js  
const UserService = require('./UserService');
class OrderService {
  getOrderWithUser(orderId) {
    const order = this.getById(orderId);
    return {
      ...order,
      user: UserService.getById(order.userId)
    };
  }
}
// ✅ DIパターンによる解決
// services/index.js(サービスコンテナ)
class ServiceContainer {
  constructor() {
    this.services = {};
  }

  register(name, service) {
    this.services[name] = service;
  }

  get(name) {
    return this.services[name];
  }
}

const container = new ServiceContainer();

// UserService.js
class UserService {
  constructor(container) {
    this.container = container;
  }

  getUserOrders(userId) {
    const orderService = this.container.get('OrderService');
    return orderService.getByUserId(userId);
  }
}

// 初期化時にコンテナに登録
container.register('UserService', new UserService(container));
container.register('OrderService', new OrderService(container));

レイヤードアーキテクチャの採用

プロジェクト構造例:

src/
├── presentation/     # UI層(React Components)
│   ├── components/
│   └── pages/
├── application/      # アプリケーション層(Business Logic)
│   ├── services/
│   └── usecases/
├── domain/          # ドメイン層(Entities, Value Objects)
│   ├── entities/
│   └── repositories/
└── infrastructure/  # インフラ層(DB, API)
    ├── api/
    └── database/

このような層分けにより、以下のルールを適用:

  • 上位層は下位層を参照できる(presentation → application → domain → infrastructure)
  • 下位層は上位層を参照しない(循環参照の防止)
  • 同一層内での循環参照は避ける

ファイル分割のベストプラクティス

// ✅ 共通の依存関係を別ファイルに分離
// shared/types.js
export const USER_ROLES = {
  ADMIN: 'admin',
  USER: 'user'
};

export const validateUser = (user) => {
  // 共通のバリデーションロジック
};

// UserService.js
import { USER_ROLES, validateUser } from '../shared/types.js';
// OrderServiceは直接importしない

// OrderService.js
import { USER_ROLES, validateUser } from '../shared/types.js';
// UserServiceは直接importしない

// UserOrderService.js(新しいファイル)
import { UserService } from './UserService.js';
import { OrderService } from './OrderService.js';

export class UserOrderService {
  constructor(userService, orderService) {
    this.userService = userService;
    this.orderService = orderService;
  }

  getUserWithOrders(userId) {
    const user = this.userService.getById(userId);
    const orders = this.orderService.getByUserId(userId);
    return { user, orders };
  }
}

イベント駆動アーキテクチャの活用

// EventEmitterを使った疎結合な設計
const EventEmitter = require('events');
const eventBus = new EventEmitter();

// UserService.js
class UserService {
  createUser(userData) {
    const user = this.create(userData);
    
    // 他のサービスに直接依存せず、イベントを発行
    eventBus.emit('user:created', user);
    
    return user;
  }
}

// NotificationService.js
class NotificationService {
  constructor() {
    // イベントをリッスンして処理
    eventBus.on('user:created', this.sendWelcomeEmail.bind(this));
  }

  sendWelcomeEmail(user) {
    // ウェルカムメール送信処理
  }
}

まとめ:循環参照のない健全なコードベースを維持する

循環参照は複雑なアプリケーションでは避けられない問題ですが、適切なツールと設計パターンを使用することで効果的に管理できます。

重要なポイント

  • 早期発見:ESLint import/no-cycleとMadgeで開発時に検出
  • 即座解決:Lazy require、Dynamic import、モジュール順序調整
  • 根本解決:DI、レイヤードアーキテクチャ、イベント駆動設計
  • 継続監視:CI/CDパイプラインでの自動チェック

今後のアクション

  1. プロジェクトにESLint import/no-cycleを導入
  2. Madgeで既存の循環参照を調査
  3. Webpack circular-dependency-pluginでビルド時チェック
  4. 設計レベルでの改善を検討

循環参照は「コードスメル」の一種でもあります。発見したら単純に回避するだけでなく、なぜその循環が生まれたのかを考え、より良い設計を目指していきましょう。

参考リンク

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次