この記事は、RevComm Advent Calendar 2025 の 19 日目の記事です。
- はじめに
- MiiTel Phone とは
- デザインパターンとは
- なぜ今さら?
- MiiTel Phone で使われているデザインパターン
- さいごに
- 参考文献
はじめに
2025 年 7 月に RevComm にフロントエンドエンジニアとして入社した林です。現在は主に MiiTel Phone の開発を担当しています。
今回は MiiTel Phone におけるデザインパターンの活用事例について紹介したいと思います。
MiiTel Phone とは
MiiTel Phone とは、電話の通話内容を AI が自動で録音・文字起こしし、その会話の特徴を数値化して分析するクラウド型の IP 電話サービスです。
パソコンやスマートフォンとインターネットがあれば固定電話は不要で、通話と同時にその内容が自動で記録され、話すスピードや会話のラリー回数、キーワードの出現状況などが可視化されます。
こうしたデータは CRM や SFA といった営業管理ツールと連携でき、通話履歴を一元管理したり、商談内容をチームで共有したりするのに役立ちます。
また、AI が通話の傾向を解析して改善ポイントを指摘するため、営業トークの質向上や新人教育にもよく活用されています。
デザインパターンとは
デザインパターンとは、ソフトウェア開発で頻繁に発生する設計上の問題に対する再利用可能で汎用的な解決方法のことです。
ある程度以上の規模や複雑さを持つシステムでは、状態管理、依存関係の制御、拡張性の確保、責務分離など、設計上の課題が自然と発生してしまいます。
そのような場面でデザインパターンを理解していると、すでに確立された構造を使って問題を整理し、保守性の高い設計を行うことができます。
一方で、デザインパターンは銀の弾丸ではなく、あくまで過去の開発者たちがまとめたよくある問題に対する典型的な解法にすぎません。小規模なコードやシンプルな処理に対して無理にパターンを当てはめると、かえって構造が複雑になってしまうので注意が必要です。
GoF デザインパターン
デザインパターンの中でも特に有名なのが、今ではほとんど伝説として語られる GoF *1 がまとめた 23 のパターンです。これらはオブジェクト指向設計の基本原則を体系化したもので、次の 3 種類に分類されます。
- 生成:オブジェクトの作り方を工夫する(Factory Method、Singleton など)
- 構造:クラスやオブジェクトの組み合わせ方を整理する(Adapter、Decorator など)
- 振る舞い:オブジェクト同士の連携方法を定義する(Observer、Strategy など)
GoF デザインパターンは、現在でも設計の基礎として広く活用されています。
ここでは細かくは触れません。もし興味のある方はオライリーの『Head First デザインパターン』が図やイラストが多くて分かりやすいのでおすすめです。
JavaScript のデザインパターン
一方で JavaScript はマルチパラダイム言語であり、関数をファーストクラスオブジェクトとして扱えるなど、オブジェクト指向言語とは異なる特徴を持ちます。そのため、GoF デザインパターンを JavaScript に適用する際、従来のオブジェクト指向言語のようにそのままの形で利用できるとは限りません。
pattens.dev では、こうした言語特性の違いを踏まえ、実際の Web 開発で活用しやすいパターンが次のように整理されています。
- Vanilla / React / Vue Patterns: JS の言語特性に合わせたパターン
- Rendering Patterns: レンダリングを最適化するためのパターン
- Performance Patterns: パフォーマンスを最適化するためのパターン
例えば Bundle Splitting や Tree Shaking といったパターンは、多くの場合ライブラリやフレームワーク側で自動的に対応されており、開発者が特別に意識しなくても自然に利用されていることが少なくありません。
この記事では主にフロントエンドで使う JavaScript のデザインパターンを想定していますが、バックエンド側のデザインパターンを学びたい場合は『Node.js デザインパターン』という本がおすすめです。
なぜ今さら?
デザインパターンは古典的な概念ですが、モダンなフロントエンド開発においても依然として重要です。その理由はいくつかあります。
まず、コードベースの複雑化です。React や Vue といったコンポーネントベースのフレームワークでは、状態管理、非同期処理、パフォーマンス最適化など、多くの設計上の課題が自然に発生します。デザインパターンを意識することで、こうした課題に対して構造を整理し、保守性や可読性を高めることができます。
次に、AI エージェントを活用した開発の観点です。AI が生成するコードにデザインパターンが含まれていても、私たちがその意図や構造を理解できなければ、結果的に技術的負債につながる可能性があります。一方で、デザインパターンの名称を指示として AI に与えることで、生成されるコードの構造を意図通りに導き、負債を抑制することができます。
これは、名前を付ける行為が単なるラベリングではなく、概念を抽象化して世界を理解しやすくする行為であることと同じです。名前を付ける=カテゴリー化することで、無限に複雑な現象を整理し理解可能にし、車輪の再発明を防ぐことができます。
さらに、名前やパターンを共有することで、チーム内のコミュニケーションが効率化されます。共通の語彙があることで、設計意図やコードの構造を迅速に共有でき、レビューや議論をスムーズに進められます。
加えて、フロントエンド技術は非常に速いスピードで進化します。新しいフレームワークやライブラリ、ビルドツールが次々に登場し、短期間で流行が変化することも珍しくありません。しかし、デザインパターンは言語やフレームワークに依存しない設計原則であるため、その価値は陳腐化しません。使用する技術スタックが変わっても、パターンの本質を理解していれば、あらゆる環境で応用できます。
MiiTel Phone で使われているデザインパターン
MiiTel Phone では、さまざまなデザインパターンが活用されていますが、ここではその中から特に興味深い部分を抜粋して紹介します。
あわせて、使用しているライブラリ側でデザインパターンが用いられている箇所についても触れて説明したいと思います。
Factory Pattern
まずは、最も単純かつ割とデザインパターンとは意識せずに使われている印象が強い Factory Pattern について取り上げます。
Factory Pattern とは、オブジェクトの生成方法をカプセル化し、クライアントコードが直接 new を呼ばずにオブジェクトを作れるようにするデザインパターンのことです。
Factory Pattern の基本例
これだけだと分かりづらいのでサンプルコードを例示します。
// animal-factory.ts interface Animal { speak(): void; } class Dog implements Animal { speak() { console.log('Woof!'); } } class Cat implements Animal { speak() { console.log('Meow!'); } } const createAnimalFactory = (type: 'dog' | 'cat'): Animal => { switch (type) { case 'dog': return new Dog(); case 'cat': return new Cat(); default: throw new Error('Unknown animal'); } }; // bun run animal-factory.ts createAnimalFactory('dog').speak(); // Woof! createAnimalFactory('cat').speak(); // Meow!
上記の例では、createAnimalFactory 関数が Factory メソッドとして機能しています。
クライアントコードは Animal 型に依存しているだけで、具体的な Dog や Cat クラスを直接参照する必要がありません。
Factory Pattern を使わない場合
次にFactory Pattern を使わない例を見てみましょう。
// animal-no-factory.ts interface Animal { speak(): void; } class Dog implements Animal { speak() { console.log('Woof!'); } } class Cat implements Animal { speak() { console.log('Meow!'); } } // Factory を使用せずに直接インスタンス化 const dog = new Dog(); const cat = new Cat(); // bun run animal-no-factory.ts dog.speak(); // Woof! cat.speak(); // Meow!
Factory Pattern を使わない場合、クライアントは具体的なクラスに依存してしまいます。
つまり、new Dog() や new Cat() のように直接インスタンス化する必要があり、新しい動物クラス、例えば Bird を追加した場合はクライアントコードの修正も必要になります。
また、生成ロジックが複数の場所に分散してしまうため、初期化処理や条件分岐が散らばり、複雑なコンストラクタや初期設定が増えると保守が大変になります。
さらに、実行時にどのクラスを生成するかを動的に切り替えるのが難しく、柔軟性にも欠けるという問題があります。
一方で Factory Pattern を使うと、クライアントは型である Animal に依存するだけで済むため、具体クラスに直接触れる必要がなくなります。生成ロジックを一箇所にまとめられるため、コードの可読性や保守性が向上します。
patterns.dev にはオブジェクトを生成する関数が例として紹介されています(Factory Pattern より)。
いずれにせよ、Factory Pattern の核心はオブジェクト生成ロジックのカプセル化にあります。Factory Function を使うか Class Constructor を使うかは実装の詳細であり、重要なのは生成ロジックを一箇所にまとめることで、クライアントコードを具体的な実装から切り離すことができる点です。
MiiTel Phone における活用例
MiiTel Phone では、ブラウザ互換性を確保するために Factory Pattern を活用しています。以下、具体的な事例を紹介します(コード例は実際のコードとは異なります)。
Chrome ブラウザの getUserMedia() API にバグがあり(Issue 370332086)、特定の条件下でマイク選択が正しく動作しないという問題がありました。Factory Pattern を使うことで、この問題をクライアントコードから隠蔽しています。
// --- Custom MediaStream Factory --- // MediaStreamを取得する方法をカプセル化する const mediaStreamFactory = async (constraints: MediaStreamConstraints) => { if (typeof constraints.audio === 'object' && typeof constraints.audio.deviceId === 'string') { const deviceId = constraints.audio.deviceId; try { // Chrome Bug Fix: deviceId を exact 形式に変換してみる const modifiedConstraints = { ...constraints, audio: { ...constraints.audio, deviceId: { exact: deviceId } }, }; return await navigator.mediaDevices.getUserMedia(modifiedConstraints); } catch (error) { // exact 形式で失敗した場合、元の形式で再試行する console.warn('Exact deviceId failed, falling back', error); } } // それ以外の場合はそのまま実行 return await navigator.mediaDevices.getUserMedia(constraints); }; // --- クライアント側のコード例 --- // クライアントは内部処理を意識せずに必要な MediaStream を取得できる const startCall = async () => { // audio stream を取得 const stream = await mediaStreamFactory({ audio: { deviceId: 'default' }}); // この後、通話・録音などに stream を利用できる } startCall();
Factory Pattern を使わない場合の問題点
Factory Pattern を使わない場合、コードは以下のように各所で条件分岐を書く必要があります。
const constraints = { audio: { deviceId: micId } }; let mediaStream: MediaStream; try { // Chrome Bug Fix mediaStream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: { exact: micId } } }); } catch (error) { // フォールバック mediaStream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: micId } }); }
ブラウザ互換性のためのコードが複数箇所に散らばってしまうことや、通話開始、デバイス変更、Re-INVITE *2 などの場面で同じ処理を繰り返し書く必要がある点、さらに新しいバグ対応が必要になった場合にはすべての箇所を修正しなければならない点が問題となります。
このような状況に対して、Factory Pattern を使うことで生成ロジックを一箇所にまとめられるため、クライアントコードをシンプルに保ち、保守性を大幅に向上させることができます。
Container/Presentational Pattern
フロントエンド開発では、アプリケーションが大きくなるとコンポーネントの管理が難しくなります。そんなときに役立つのが Container/Presentational Pattern です。
Container/Presentational Pattern とは、コンポーネントを状態やロジックを持つコンテナと表示だけを担当するプレゼンテーションに分けるデザインパターンのことです。
- Container Component
- データ取得、状態管理、ビジネスロジックを担当
- UI 表示にはほとんど関与しない
- Presentational コンポーネントに props を渡して描画させる
- Presentational Component
- 親から渡されたデータをもとに UI を描画する
- 基本的に状態を持たない
- 再利用性が高く、単体テストがしやすい
以下は簡単なサンプルコードになります。
// Presentational Component export const UserList = ({ users }) => ( <ul> {users.map(user => ( <li key={user.id} className="text-zinc-900">{user.name}</li> ))} </ul> ); // Container Component import { useState, useEffect } from 'react'; export const UserListContainer = () => { const [users, setUsers] = useState([]); useEffect(() => { fetch('<https://api.example.com/users>') .then(res => res.json()) .then(data => setUsers(data)); }, []); return <UserList users={users} />; };
このよう役割ごとにコンポーネントを分けることで、UI とロジックを分離できます。
React Hooks の活用
React Hooks が登場してからは、状態管理や副作用の処理を簡単に切り出せるようになりました。以下のように、カスタムフックを作れば Container Component をさらにスッキリさせられます。
import UserList from './user-list'; import useUsers from './use-users'; export const UserListContainer = () => { const { users } = useUsers(); return <UserList users={users} />; };
patterns.dev では「React Hooks を使用した場合でも Container/Presentational Pattern を使用できるが、小規模なアプリケーションでは過剰になる可能性がある」と記載されています(Container/Presentational Pattern より)。このパターンを使うかどうかはプロジェクトの規模を考慮した上で判断するのが良さそうです。
Container/Presentational Pattern を使わない場合
import useUsers from './use-users'; export const UserListContainer = () => { const { users } = useUsers(); return ( <ul> {users.map(user => ( <li key={user.id} className="text-zinc-900">{user.name}</li> ))} </ul> ); };
Container/Presentational Pattern を使わない場合は上記のようなコードになります。1 つのコンポーネント内でデータ取得とスタイリングを行っていますが、Hooks でデータ取得を行っているので、部分的に関心の分離 *3 ができていると言えます。ただし、完全に関心の分離ができているとは言えない点に注意してください。
MiiTel Phone における活用例
MiiTel Phone では、Atomic Design と Container/Presentational Pattern を組み合わせて活用しています。
Atomic Design とは、UI を構造的に設計するための方法論です。Brad Frost 氏によって提唱されました(ブログ記事)。小さな部品を組み合わせて効率よく、一貫性のある UI を作ることを目的としています。
詳しい説明は前述した Brad Frost 氏のブログ記事に譲るとして、プロジェクトのディレクトリ構造は以下のようになっています。
src/components/ ├── atoms/ # これ以上分解できない基本的な要素 (Button, Input など) ├── molecules/ # 小さな機能を持つ部品として再利用可能 (Accordion, FieldInput など) ├── organisms/ # ページの中で独立して機能する部分 (Modal, Header など) ├── templates/ # organisms を配置してページのレイアウトを定義 └── pages/ # 実際のコンテンツを templates に流し込んだ完成形
この構造の中で、Container/Presentational Pattern は主に次のように適用されています。
- Container Component:
pagesディレクトリに配置 - Presentation Component:
pagesディレクトリ以外に配置
Atomic Design と Container/Presentational Pattern を組み合わせるメリット
この組み合わせの利点は複数あります。
まず、関心の分離が強化されるため、UI の変更がビジネスロジックに与える影響を最小限に抑えることができます。例えば、Organisms や Molecules の見た目を変更しても、データ取得のロジックを持つ Container Component にはほとんど影響を与えません。逆に、データ取得や状態管理の方法を変更しても、Presentational Component 側はそのまま使い続けることができます。
また、この設計はテストの容易性にも寄与します。Presentational Component は純粋関数のように振る舞うため、与えられた props に応じて正しい UI が描画されるかを簡単に検証できます(MiiTel Phone では Storybook や Chromatic を使った UI テストを実施しています)。一方で Container Component は、API からのデータ取得や状態管理のテストに集中できるため、役割ごとにテストの粒度を分けやすくなります。
さらに、Atomic Design の階層と Container/Presentational Pattern の組み合わせにより、コンポーネントの再利用性が向上します。Atoms や Molecules はプロジェクト全体で共通して使用できる部品として整備され、Organisms はある程度独立した UI 機能を持つ単位として他のページでも再利用できます。Container Component は各ページや画面ごとに異なるデータフローを管理する役割に集中するため、UI 部品の再利用性を損なうことなく画面ごとの差異を吸収できます。
Middleware Pattern
次に、JavaScript で最も特徴的なデザインパターンである Middleware Pattern について見ていきたいと思います。
Middleware Pattern とは、処理の途中に挟む共通の処理層を作ることで、コードの再利用性や柔軟性を高めるデザインパターンのことです。
MiiTel Phone では、HTTP クライアントライブラリの Axios を使用しており、その Interceptor 機能が Middleware Pattern の典型的な実装例となっています。
Axios Interceptor の使用例
Axios の Interceptor を使うことで、HTTP リクエストやレスポンスの直前・直後に共通処理を挿入できます。例えば、リクエストヘッダーへの認証情報の追加や、レスポンスエラーの統一的な処理などを行うことが可能です。
import axios from 'axios'; const apiClient = axios.create({}); // Request Interceptor apiClient.interceptors.request.use( (config) => { // 認証トークンをヘッダーに追加 const token = localStorage.getItem('authToken'); if (token) { config.headers.Authorization = `Bearer ${token}`; } console.log('Request sent:', config.url); return config; }, (error) => { return Promise.reject(error); } ); // Response Interceptor apiClient.interceptors.response.use( (response) => { console.log('Response received:', response.status); return response; }, (error) => { // 401 エラーの共通処理 if (error.response?.status === 401) { console.log('Unauthorized! Redirecting to login...'); // ログインページへリダイレクト処理 } return Promise.reject(error); } ); // API call apiClient.get('/users') .then(response => console.log(response.data)) .catch(error => console.error(error));
このように、Axios の Interceptor を活用することで、HTTP リクエストやレスポンスの処理を共通化・一元管理できるようになります。Interceptor がなければ、認証トークンの付与、共通ヘッダーの設定、レスポンスのエラーチェックなどを全てのリクエストで毎回書く必要があります。
Axios の Middleware Pattern 実装を見る
さて、ここまでは Middleware Pattern で実装されたライブラリの使い方を見ましたが、Middleware Pattern の実装方法自体を確認したわけではありません。このまま終わっては面白くないので Axios のコードを確認して、実装方法を見てみましょう。
InterceptorManager の実装
それでは、実際に Axios のソースコードを確認して、Middleware Pattern がどのように実装されているのかを見ていきます。
まず、Interceptor を管理する InterceptorManager クラスです。
lib/core/InterceptorManager.js
class InterceptorManager { constructor() { this.handlers = []; // interceptor を配列で管理 } /** * 新しい interceptor を登録 */ use(fulfilled, rejected, options) { this.handlers.push({ fulfilled, // 成功時の処理 rejected, // 失敗時の処理 synchronous: options?.synchronous ?? false, runWhen: options?.runWhen ?? null // 条件付き実行 }); // ID を返す(後で削除できるように) return this.handlers.length - 1; } /** * interceptor を削除 */ eject(id) { if (this.handlers[id]) { this.handlers[id] = null; // 無効化のため null をセット } } /** * 全ての interceptor を削除 */ clear() { if (this.handlers) { this.handlers = []; } } /** * 登録されている interceptor を順に処理 */ forEach(fn) { this.handlers.forEach((h) => { if (h !== null) { fn(h); } }); } }
この実装で注目すべき点は、Interceptor を配列で管理していることです。use() メソッドで追加し、eject() メソッドで削除できる柔軟な設計になっています。
また、削除時に配列から要素を取り除くのではなく null に設定することで、他の Interceptor の ID がずれないようにしている点もポイントです。これにより、Interceptor の ID を使った安全な削除が可能になります。
Axios クラスの構造
次に、Axios クラスのコンストラクタを見てみましょう。
class Axios { constructor(instanceConfig) { this.defaults = instanceConfig || {}; this.interceptors = { request: new InterceptorManager(), // For requests response: new InterceptorManager() // For response }; } }
リクエスト用とレスポンス用、それぞれ独立した InterceptorManager インスタンスを持っています。
Interceptor チェーンの実行
Interceptor で最も重要なポイントは、request() メソッド内でのチェーン実行の順序です。
request(configOrUrl, config) { // ... 設定のマージなど ... // ① request interceptor を収集 const requestInterceptorChain = []; let synchronousRequestInterceptors = true; this.interceptors.request.forEach((interceptor) => { // runWhen 条件チェック if (typeof interceptor.runWhen === 'function' && interceptor.runWhen(config) === false) { return; // 条件が合わない場合はスキップ } synchronousRequestInterceptors = synchronousRequestInterceptors && interceptor.synchronous; // unshift を使用し先頭に追加(後に追加されたものが先に実行される) requestInterceptorChain.unshift( interceptor.fulfilled, interceptor.rejected ); }); // ② response interceptor を収集 const responseInterceptorChain = []; this.interceptors.response.forEach((interceptor) => { // push で末尾に追加(登録された順に実行される) responseInterceptorChain.push( interceptor.fulfilled, interceptor.rejected ); }); let promise; // ③ 非同期の場合:Promise チェーンを構築 if (!synchronousRequestInterceptors) { // 実際の HTTP リクエスト処理を中心とする const chain = [dispatchRequest.bind(this), undefined]; // request interceptor を前に追加 chain.unshift(...requestInterceptorChain); // response interceptor を後ろに追加 chain.push(...responseInterceptorChain); // ④ チェーンを順番に実行 promise = Promise.resolve(config); let i = 0; while (i < chain.length) { promise = promise.then(chain[i++], chain[i++]); } return promise; } // 同期処理の場合の処理... }
Middleware の実行順序(LIFO と FIFO)
Request Intercepter は unshift を使って配列の先頭に追加しているため、登録順の逆順で実行されます。これは LIFO *4の動作です。
// Request Intercepter 1 axios.interceptors.request.use(config => { console.log('First'); // 実際には 2 番目に実行される return config; }); // Request Intercepter 2 axios.interceptors.request.use(config => { console.log('Second'); // 実際には 1 番目に実行される return config; }); // Output: "Second" → "First"
これは直感的ではないように見えますが、実は理にかなっています。後から追加される処理(例えば認証チェックや特定のヘッダー追加)をより早い段階で実行したいケースが多いためです。スタック構造(LIFO)を採用することで、より具体的な処理を後から上乗せできる設計になっています。
一方、Response Intercepter は push を使って配列の末尾に追加するため、登録順に実行されます。これは FIFO *5の動作です。
// Response Intercepter 1 axios.interceptors.response.use(response => { console.log('First'); // 最初に実行される return response; }); // Response Intercepter 2 axios.interceptors.response.use(response => { console.log('Second'); // 次に実行される return response; }); // Output: "First" → "Second"
レスポンスの場合はキュー構造(FIFO)となり、登録順に処理されます。これにより、基本的なデータ変換を先に行い、その後でより具体的な処理を行うという自然な流れを作ることができます。
Middleware Pattern の利点
この実装から、Middleware Pattern の利点が見えてきます。まず、認証、ログ、エラーハンドリングなど、異なる責務を独立したミドルウェアとして実装できるため、関心の分離が実現できます。また、一度書いたミドルウェアを複数の場所で使い回せる再利用性の高さも魅力です。
今回は HTTP クライアント Axios の Middleware Pattern の実装を見ましたが、HTTP サーバー(Express, Koa など) にも Axios とは違った Middleware の実装がされているので興味がある方は調べてみてください。
Observer Pattern
Observer Pattern は MiiTel Phone において中心的な役割を担っているデザインパターンです。後ほど触れますが、具体的には SIP イベントとアプリケーション状態を同期させる重要な役割を担っています。
まず、Observer Pattern とはあるオブジェクトの状態変化を、依存する複数のオブジェクトへ自動的に通知する仕組みを提供するデザインパターンのことです。
Node.js には組み込みの EventEmitter クラスが定義されており、それを使うことで特定のイベントが発生した時に呼び出される関数をリスナーとして登録できます。また、ブラウザ用にバンドルされた events モジュールを使えばブラウザでも EventEmitter が使えます。
ただし、ここでは Observer Pattern の概念を直感的に理解することを目的に、Node.js の EventEmitter に近いインターフェースを持つシンプルな自作 EventEmitter をブラウザ用に実装した例を紹介します。
シンプルな自作 EventEmitter の実装
以下は最小限のコードで実装された EventEmitter で、on / off / emit といった基本的なイベント購読・解除・通知が行えます。
// emitter.ts type EventsMap = Record<string, unknown>; type Handler<T> = (event: T) => void; type ListenerEntry<T> = { handler: Handler<T>; once: boolean; }; export const createEmitter = <T extends EventsMap>() => { // 各イベント名ごとにリスナーの配列を保持する const all = new Map<keyof T, ListenerEntry<unknown>[]>(); // イベントが発火するたびに呼ばれる通常のリスナーを登録する const on = <K extends keyof T>(type: K, handler: Handler<T[K]>) => { const handlers = (all.get(type) as ListenerEntry<T[K]>[] | undefined) ?? []; handlers.push({ handler, once: false }); all.set(type, handlers as ListenerEntry<unknown>[]); }; // 次にそのイベントが発火したとき 1 回だけ実行され、自動的に解除されるリスナーを登録する const once = <K extends keyof T>(type: K, handler: Handler<T[K]>) => { const handlers = (all.get(type) as ListenerEntry<T[K]>[] | undefined) ?? []; handlers.push({ handler, once: true }); all.set(type, handlers as ListenerEntry<unknown>[]); }; // 特定のイベントリスナーだけを削除する const off = <K extends keyof T>(type: K, handler: Handler<T[K]>) => { const handlers = all.get(type) as ListenerEntry<T[K]>[] | undefined; if (!handlers) return; const filtered = handlers.filter((h) => h.handler !== handler); all.set(type, filtered as ListenerEntry<unknown>[]); }; // 登録されたリスナーを順番に実行し、once の場合は残さないようにフィルタリングする const emit = <K extends keyof T>(type: K, event: T[K]) => { const handlers = all.get(type) as ListenerEntry<T[K]>[] | undefined; if (!handlers) return; const remaining: ListenerEntry<T[K]>[] = []; for (const h of handlers) { h.handler(event); if (!h.once) remaining.push(h); } all.set(type, remaining as ListenerEntry<unknown>[]); }; return { on, once, off, emit }; };
上記は最小限の実装ですが、Node.js の EventEmitter の基本的な処理を踏襲しています。
以下の使用例は message と count という 2 種類のイベントを持つ Emitter を作成し、それぞれにリスナーを登録してイベント発火するだけのシンプルなものです。
// example.ts type MyEvents = { message: string; count: number; }; const emitter = createEmitter<MyEvents>(); emitter.on("message", (msg) => console.log("on message:", msg)); emitter.once("count", (n) => console.log("once count:", n)); console.log("---- First emit ----"); emitter.emit("message", "1st hello!"); emitter.emit("count", 1); console.log("---- Second emit ----"); emitter.emit("message", "2nd hello!"); emitter.emit("count", 2); // ← once 用リスナーはすでに削除済み // bun run example.ts // ---- First emit ---- // on message: 1st hello! // once count: 1 // ---- Second emit ---- // on message: 2nd hello!
実行結果を見ると、message イベントは発火するたびに登録されたリスナーが毎回実行されるのに対し、count イベントに登録されたリスナーは once を使っているため最初の 1 回だけ実行され、2 回目の emit("count", 2) ではすでにリスナーが削除されているため何も起こりません。
この簡易的な EventEmitter でイベントを複数のリスナーに通知するという振る舞いがなんとなく分かっていただけたと思います。今回、一応実装はしてみましたが、ブラウザで EventEmitter を使いたい場合は、軽量な mitt というライブラリがおすすめです。
SIP.js と Observer Pattern
さて、次に MiiTel Phone において最も重要なライブラリと言っても過言ではない SIP.js について触れていきたいと思います。
SIP.js とは WebRTC ベースの VoIP 通話機能を実現するためのライブラリです。SIP プロトコルを介した PBX との通信、WebRTC セッションの管理、音声ストリームの制御など、通話機能の低レベルな実装を担当します。SIP について解説するのはこの記事では全然足りないので割愛します。
SIP.js の最大の特徴は、内部でイベント駆動型のアーキテクチャを採用している点です。Registerer や Session といった主要オブジェクトは Observer Pattern に則って構築されており、通話の着信や接続、終了、メッセージ受信など、さまざまな状態変化をイベントとして外部に通知します。開発者はこれらのイベントにリスナーを登録することで、アプリケーションの UI や内部状態をリアルタイムに同期させることができます。
SIP.js の EventEmitter 実装
SIP.js では以下の EventEmitter が実装されています。
export interface Emitter<T> { addListener(listener: (data: T) => void, options?: { once?: boolean }): void; removeListener(listener: (data: T) => void): void; on(listener: (data: T) => void): void; // deprecated off(listener: (data: T) => void): void; // deprecated once(listener: (data: T) => void): void; // deprecated } export class EmitterImpl<T> implements Emitter<T> { private listeners = new Array<(data: T) => void>(); public addListener(listener: (data: T) => void, options?: { once?: boolean }): void { const onceWrapper = (data: T): void => { this.removeListener(onceWrapper); listener(data); }; options?.once === true ? this.listeners.push(onceWrapper) : this.listeners.push(listener); } public emit(data: T): void { this.listeners.slice().forEach((listener) => listener(data)); } public removeAllListeners(): void { this.listeners = []; } public removeListener(listener: (data: T) => void): void { this.listeners = this.listeners.filter((l) => l !== listener); } public on(listener: (data: T) => void): void { return this.addListener(listener); } public off(listener: (data: T) => void): void { return this.removeListener(listener); } public once(listener: (data: T) => void): void { return this.addListener(listener, { once: true }); } }
on / off / once といった基本的なイベント操作が提供されており、先ほど書いた自作の EventEmitter と同様に、特定のイベントに対してリスナーを登録したり削除したりすることができます。
次に実際にどのようにして SIP.js から MiiTel Phone に通知が送られているのかを一連の流れを追いながら簡単に確認してみたいと思います。
今回は Session クラスの実装を確認してみます。SIP.js の Session は、2 つの端末間の通話や接続を管理するオブジェクトです。通話の開始(INVITE)、応答(ACK)、終了(BYE)、転送(REFER)などの処理をまとめて扱うことができます。また、通話の保留や条件変更も Session を通して行います。
Session クラスにおける EventEmitter の利用
まず初めに、Session クラスがどのように EventEmitter を初期化しているかを見てみましょう。
export abstract class Session { private _state: SessionState = SessionState.Initial; private _stateEventEmitter: EmitterImpl<SessionState>; protected constructor(userAgent: UserAgent, options: SessionOptions = {}) { this.delegate = options.delegate; // EmitterImpl を初期化(listeners = []) this._stateEventEmitter = new EmitterImpl<SessionState>(); this._userAgent = userAgent; } public get stateChange(): Emitter<SessionState> { return this._stateEventEmitter; // 外部には Emitter として公開 } protected stateTransition(newState: SessionState): void { // 状態遷移の妥当性チェック // 状態を更新 this._state = newState; this.logger.log(`Session ${this.id} transitioned to state ${this._state}`); // ここで emit() を呼んで全リスナーに通知 this._stateEventEmitter.emit(this._state); // Terminated になったら自動的に dispose if (newState === SessionState.Terminated) { this.dispose(); } } }
Session クラスは内部に _stateEventEmitter という EmitterImpl のインスタンスを持っています。コンストラクタでこれが初期化され、この時点では listeners 配列は空です。外部からは stateChange というゲッターを通じてアクセスでき、ここにリスナーを登録することで状態変化の通知を受け取れるようになります。
状態が遷移するときは stateTransition メソッドが呼ばれ、このメソッド内で _stateEventEmitter.emit() を実行することで、登録されているすべてのリスナーへ通知が送られます。
発信シナリオのイベント通知フロー
それでは、実際に発信(Inviter)のシナリオで、どのように状態変化が通知されるのかをステップごとに見ていきましょう。
アプリケーションコード(MiiTel Phone 側の実装)で Inviter のインスタンスを作成します。
// アプリケーションコード const target = UserAgent.makeURI("sip:bob@example.com"); const inviter = new Inviter(userAgent, target);
内部では Inviter クラスのコンストラクタが実行されます。
// Inviter クラスのコンストラクタ export class Inviter extends Session { constructor(userAgent: UserAgent, targetURI: URI, options?: InviterOptions) { // 親クラス(Session)のコンストラクタを呼ぶ super(userAgent, options); // Session のコンストラクタ内 // this._stateEventEmitter = new EmitterImpl<SessionState>(); // this._state = SessionState.Initial; // この時点での状態 // _state = SessionState.Initial // _stateEventEmitter.listeners = [] (まだリスナーは0個) } }
Inviter は Session を継承しているため、親クラスのコンストラクタが呼ばれ、EmitterImpl が初期化されます。この時点では状態は Initial で、リスナーは 1 つも登録されていません。
次に、アプリケーション側で状態変化を監視するリスナーを登録します。
// アプリケーションコード inviter.stateChange.addListener((newState) => { console.log(`[リスナー1] セッション状態: ${newState}`); }); inviter.stateChange.addListener((newState) => { if (newState === SessionState.Established) { console.log(`[リスナー2] 通話が確立されました`); } }); inviter.stateChange.addListener((newState) => { console.log(`[リスナー3] 初回のみ: ${newState}`); }, { once: true });
これらのリスナーが登録されると、EmitterImpl の内部でどのような処理が行われるのでしょうか。
// EmitterImpl の addListener() が呼ばれる public addListener(listener: (data: T) => void, options?: { once?: boolean }): void { const onceWrapper = (data: T): void => { this.removeListener(onceWrapper); listener(data); }; options?.once === true ? this.listeners.push(onceWrapper) // リスナー3: ラッパー関数 : this.listeners.push(listener); // リスナー1, 2: そのまま } // この時点での内部状態 // _stateEventEmitter.listeners = [ // listener1, // (newState) => { console.log(`[リスナー1] ... `) } // listener2, // (newState) => { if (newState === ...) { ... } } // onceWrapper // once オプション用のラッパー // ]
通常のリスナーはそのまま listeners 配列に追加されますが、once: true オプション付きのリスナーは、ラッパー関数でくるまれて追加されます。このラッパー関数は、実行時に自分自身を削除してから元のリスナーを呼び出すという仕組みになっています。
では、実際に発信を開始します。
// アプリケーションコード await inviter.invite();
Inviter クラスの invite() メソッドが実行されます。
// Inviter クラスの invite() メソッド export class Inviter extends Session { public async invite(options: InviterInviteOptions = {}): Promise<OutgoingInviteRequest> { // 状態チェック if (this.state !== SessionState.Initial) { throw new Error(`Invalid session state ${this.state}`); } // 状態を Establishing に遷移 this.stateTransition(SessionState.Establishing); // → この時点で emit() が呼ばれる! // INVITE リクエストを送信 const inviteRequest = this.userAgentCore.invite(/* ... */); return inviteRequest; } }
まず現在の状態が Initial であることをチェックし、その後 stateTransition を呼び出して状態を Establishing に遷移させます。
stateTransition メソッドの内部処理を詳しく見てみましょう。
// Session クラスの stateTransition() メソッド protected stateTransition(newState: SessionState): void { // 現在の状態は Initial console.log(`現在の状態: ${this._state}`); // "Initial" // 状態遷移の妥当性チェック switch (this._state) { case SessionState.Initial: if ( newState !== SessionState.Establishing && newState !== SessionState.Established && newState !== SessionState.Terminating && newState !== SessionState.Terminated ) { throw new Error(`Invalid state transition`); } break; // ... 他のケース ... } // 状態を更新 this._state = newState; // Initial → Establishing this.logger.log(`Session ${this.id} transitioned to state ${this._state}`); // ★★★ ここで emit() を呼ぶ ★★★ this._stateEventEmitter.emit(this._state); // → emit(SessionState.Establishing) が実行される }
まず現在の状態から新しい状態への遷移が妥当かどうかをチェックします。Initial から Establishing への遷移は許可されているため、状態を更新し、その後 emit() を呼び出します。
emit() メソッドが呼ばれると、登録されているすべてのリスナーが実行されます。
// EmitterImpl の emit() メソッド public emit(data: T): void { // listeners 配列のコピーを作成 const listenersCopy = this.listeners.slice(); // listenersCopy = [listener1, listener2, onceWrapper] // 各リスナーを順番に実行 listenersCopy.forEach((listener) => listener(data)); // data = SessionState.Establishing }
emit() メソッドは、まず listeners 配列のコピーを作成します。これは、リスナーの実行中に配列が変更される可能性があるためです。その後、各リスナーを順番に実行していきます。
各リスナーの実行順序を見てみましょう。
// リスナー1 が実行される listener1(SessionState.Establishing) → console.log(`[リスナー1] セッション状態: Establishing`); // コンソール出力: "[リスナー1] セッション状態: Establishing" // リスナー2 が実行される listener2(SessionState.Establishing) → if (SessionState.Establishing === SessionState.Established) { ... } // false なので何も出力されない // リスナー3(onceWrapper)が実行される onceWrapper(SessionState.Establishing) ① this.removeListener(onceWrapper) // 自分を削除 ② listener3(SessionState.Establishing) → console.log(`[リスナー3] 初回のみ: Establishing`); // コンソール出力: "[リスナー3] 初回のみ: Establishing" // この時点で listeners 配列の状態: // _stateEventEmitter.listeners = [listener1, listener2] // (onceWrapper は削除された)
最初のリスナーは単純にコンソールに出力します。2 つ目のリスナーは条件に合致しないため何もしません。3 つ目の onceWrapper は、まず自分自身を listeners 配列から削除し、その後元のリスナーを実行します。これにより、このリスナーは今後呼ばれることはありません。
SIP サーバーから 200 OK レスポンスを受信すると、Inviter クラスの内部処理が実行されます。
// SIPサーバーから 200 OK レスポンスを受信 // ↓ // Inviter クラスの内部処理 private onAccept(response: IncomingResponseMessage): void { // ACK を送信 response.ack(); // SessionDescriptionHandler でメディアを設定 const body = getBody(response.message); await this.setAnswer(body, options); // 状態を Established に遷移 this.stateTransition(SessionState.Established); // → 再度 emit() が呼ばれる! }
ACK を送信してメディアを設定した後、再び stateTransition を呼び出して状態を Established に遷移させます。
再度 stateTransition メソッドが呼ばれます。
// Session クラスの stateTransition() メソッド(再度呼ばれる) protected stateTransition(newState: SessionState): void { // 現在の状態は Establishing console.log(`現在の状態: ${this._state}`); // "Establishing" // 状態遷移の妥当性チェック switch (this._state) { case SessionState.Establishing: if ( newState !== SessionState.Established && newState !== SessionState.Terminating && newState !== SessionState.Terminated ) { throw new Error(`Invalid state transition`); } break; // ... 他のケース ... } // 状態を更新 this._state = newState; // Establishing → Established this.logger.log(`Session ${this.id} transitioned to state ${this._state}`); // ★★★ 再度 emit() を呼ぶ ★★★ this._stateEventEmitter.emit(this._state); // → emit(SessionState.Established) が実行される }
今度は Establishing から Established への遷移です。この遷移も妥当であるため、状態を更新して emit() を呼び出します。すると再び emit() メソッドが実行されて、リスナーが実行されます。
後続の処理は省略しますが、状態を更新して emit() を呼び出してリスナーを実行するという部分は上述した内容と大きくは変わりません。
Observer Pattern の利点
このパターンの優れている点は、通知する側(Subject)が通知される側(Observer)の具体的な実装を知る必要がないという点です。いわゆる疎結合ということです。Session クラスは単に状態が変わったので通知するという責任だけを持ち、その通知を誰がどのように使うかは、リスナーを登録する側が決定します。
共通のインターフェースを通じて通知を行うだけでよいため、変更や拡張に強い設計を実現できますし、新しい Observer を追加する場合でも Subject のコードを修正する必要がなく、既存機能に影響を与えずに振る舞いを拡張できるので、保守性の向上につながります。
さいごに
SIP.js で使われている Delegate pattern や List Virtualization にも触れたいと思っていましたが、想像以上に記事が長くなってしまったため、ここで終わりたいと思います。
この記事では、MiiTel Phone における具体的なデザインパターンの活用事例を紹介しました。
デザインパターンは単なる理論ではなく、実際の開発現場で直面する具体的な課題に対する実践的な解決策です。特に、フロントエンド開発では状態管理の複雑化、非同期処理の制御、コンポーネント間の依存関係など、さまざまな設計上の課題が自然に発生します。こうした場面でデザインパターンを理解していると、車輪の再発明を避け、保守性の高い設計を行うことができます。
この記事が、デザインパターンへの理解を深め、日々の開発に活かすきっかけになれば幸いです。
参考文献
- Casciaro, Mario, and Luciano Mammino 著,武舎広幸訳,Node.js デザインパターン 第 2 版,オライリー・ジャパン,2019 年
- Patterns.dev, Patterns.dev – Improve how you architect webapps, 取得日 2025 年 12 月 6 日, https://www.patterns.dev
*1:Design Patterns: Elements of Reusable Object-Oriented Software(1994)を著した 4 名(Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides)のこと
*2:Re-INVITE は、主に SIP に関連する通信用語で、既に確立された通信セッションの中で設定を変更したいときに使われるリクエストのこと
*3:コードベースを複数の「関心(=役割・責務)」に分けて、それぞれを独立して設計・実装・変更できるようにする原則のこと
*4:Last In, First Out:後入れ先出し
*5:First In, First Out:先入れ先出し