JavaScriptにおけるシングルトン:便利なツールか隠れた罠か?
設定済みのロガーやAPIクライアントのインスタンスをエクスポートするモジュールを作成したとします。すべてのファイルがそれをインポートし、アプリケーション全体で正確に1つのインスタンスが実行されていると想定しています。ところが、テストの実行間で状態がリークし始めます。あるいは、マイクロフロントエンドアーキテクチャで突然、「シングルトン」の2つのコピーが互いに競合し始めます。何が起こったのでしょうか?
JavaScriptのシングルトンパターンは、古典的なデザインパターン理論が示唆するようには機能しません。ESモジュールシングルトンが実際にどこに存在し、どこで破綻するかを理解することで、幽霊を追いかけるようなデバッグセッションから解放されます。
重要なポイント
- ESモジュールシングルトンは、システム全体でグローバルにキャッシュされるのではなく、モジュールグラフとランタイムごとにキャッシュされる
- シングルトンは、不変データやロギングユーティリティ、読み取り専用設定などのステートレスな操作に適している
- シングルトン内の可変状態は、サーバーサイドレンダリング、テスト環境、マイクロフロントエンドアーキテクチャにおいて危険になる
- シングルトンを使用する前に、コードが複数のバンドル、ワーカー、またはサーバーリクエストで実行されるか、テストでインスタンスのリセットが必要かを検討する
モダンJavaScriptにおける「シングルトン」の実際の意味
Gang of Fourの定義は一旦忘れてください。モダンJavaScriptでは、シングルトンは通常、すでにインスタンス化されたオブジェクトをエクスポートするモジュールにすぎません:
// logger.js
class Logger {
log(message) {
console.log(`[${Date.now()}] ${message}`)
}
}
export const logger = new Logger()
複数のファイルからloggerをインポートすると、同じインスタンスが得られます。これは巧妙なコンストラクタのトリックによるものではなく、ESモジュールがモジュールグラフとランタイムごとにキャッシュされるためです。モジュールは一度実行され、インスタンスは一度作成され、すべてのインポートは同じオブジェクトへの参照を受け取ります。
これがESモジュールシングルトンの基礎です。シンプルで、多くの場合まさに必要なものです。
破綻する前提
ここでJavaScriptにおけるシングルトンの落とし穴が現れます:その「単一インスタンス」は、特定のJavaScriptランタイム内の特定のモジュールグラフにスコープされています。システム全体で魔法のようにグローバルではありません。
この前提は、いくつかの実際のシナリオで破綻します:
複数のバンドルまたは重複パッケージ。 モノレポに、それぞれが依存関係の独自のコピーをバンドルする2つのパッケージがある場合、2つの別々のモジュールグラフが得られます。2つの「シングルトン」です。共有状態はもはや共有されません。
テストランナー。 Jest、Vitestなどのツールは、テストファイル間でモジュールキャッシュをリセットしたり、ワーカープロセスを使用したりすることがよくあります。あるテストファイルのシングルトンは、別のテストファイルでは同じインスタンスではない可能性があります。
マイクロフロントエンド。 独立してデプロイされる各フロントエンドは、通常独自のJavaScriptランタイムを持っています。あるマイクロフロントエンドのシングルトンは、ビルドまたはランタイムを介してインスタンスが明示的に共有されない限り、別のマイクロフロントエンドからは見えません。
Web WorkerとService Worker。 これらは別々のJavaScriptコンテキストで実行されます。ワーカーでインポートされたモジュールは、メインスレッドの同じモジュールとは完全に異なるインスタンスです。
リクエスト分離を持つサーバーランタイム。 Next.jsやNuxtなどのフレームワークがサーバー上で実行される場合、シングルトンはランタイムとデプロイメントモデルに応じて複数のユーザーリクエスト間で永続化する可能性があり、可変のリクエスト固有の状態を保持している場合、データ漏洩のリスクがあります。
Discover how at OpenReplay.com.
シングルトンが有効に機能する場面
これらの落とし穴にもかかわらず、ESモジュールシングルトンは、特定のフロントエンドユーティリティと調整パターンにおいて本当に有用です:
- ロギングユーティリティ。 一貫したフォーマットを持つ共有ロガーは、重複しても害はなく、リクエストごとの機密状態を保持しません。
- 設定のスナップショット。 起動時にロードされる読み取り専用の設定は、シングルトンとして問題なく機能します。キーワードは読み取り専用です。
- ステートレスユーティリティ。 呼び出し間で可変状態を維持しないヘルパー関数やクラスは、安全な候補です。
共通点:これらのシングルトンは、不変データを保持するか、ステートレスな操作を実行します。
シングルトンが負債になる場面
可変状態が危険になる場所です。以下を考えてみてください:
- ユーザーセッションデータ。 サーバーサイドレンダリングでは、ユーザー情報を保持するシングルトンがリクエスト間でリークする可能性があります。
- リクエストスコープのキャッシュ。 リクエストごとにリセットされるべきデータが誤って永続化されます。
- 共有可変設定。 アプリの一部が設定を変更すると、予期せず別の部分に影響を与えます。
モダンなツールはこれらの問題を増幅します。React 18+では、Strict Modeが開発環境でレンダリングと特定のエフェクトを意図的に二重に呼び出し、適切に分離されていないシングルトン状態を露呈します。ViteやwebpackのHot Module Replacementは、コード変更間でシングルトン状態を保持する可能性があり、「新鮮な」コードが古いデータで動作する微妙なバグを作成します。
実践的なスタンス
JavaScriptのシングルトンパターンは本質的に悪いものではありません。多くの開発者が想定するよりも狭いだけです。モジュールレベルのインスタンスに手を伸ばす前に、次のことを自問してください:
- この状態は本当に不変またはステートレスか?
- このコードは複数のバンドル、ワーカー、またはサーバーリクエストで実行される可能性があるか?
- テストでこのインスタンスをリセットまたはモックする必要があるか?
可変状態で質問2または3に「はい」と答える場合は、代替案を検討してください:ファクトリ関数、依存性注入、またはReact Contextやリクエストスコープサービスなどのフレームワーク固有のパターンです。
結論
シングルトンは、その実際のスコープを理解すれば便利なツールです。ランタイムが決して約束しなかったことを「単一インスタンス」が意味すると想定すると、隠れた罠になります。モジュールレベルのインスタンスには不変またはステートレスなデータに固執し、テスト、リクエスト、またはランタイム境界間で適切な分離が必要な場合は、依存性注入またはファクトリパターンに手を伸ばしてください。
FAQ
Jestはテストファイル間でモジュールキャッシュをリセットしたり、分離されたワーカープロセスでテストを実行したりすることが多く、新しいモジュールグラフを作成してシングルトンを再インスタンス化します。必要に応じてjest.resetModules()またはjest.isolateModules()を使用するか、依存関係を注入することでモジュールレベルの可変状態を避けてください。
いいえ。Web Workerは独自のモジュールグラフを持つ別々のJavaScriptコンテキストで実行されます。ワーカーでインポートされたモジュールは、メインスレッドの同じモジュールとは完全に異なるインスタンスです。状態を共有するには、postMessageまたはSharedArrayBufferを使用してコンテキスト間でデータを明示的に渡す必要があります。
不変またはステートレスなデータの場合のみです。サーバー上のシングルトンは、ランタイムとデプロイメントモデルに応じて複数のユーザーリクエスト間で永続化する可能性があり、ユーザー間で機密データが漏洩する可能性があります。ユーザーセッションやキャッシュなどのリクエスト固有の状態には、モジュールレベルのインスタンスの代わりに、フレームワークが提供するリクエストスコープパターンを使用してください。
ファクトリ関数を使用すると、必要に応じて新しいインスタンスを作成できます。依存性注入はインスタンスを明示的に渡し、テストを容易にします。React Contextなどのフレームワーク固有のソリューションは、スコープされた状態管理を提供します。サーバーアプリケーションの場合、リクエストスコープサービスは、共有ユーティリティの利便性を維持しながら、リクエスト間の適切な分離を保証します。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Check our GitHub repo and join the thousands of developers in our community.