Comlink は Web Worker 通信において素晴らしい性能を発揮し、これが主な設計目標です。しかし、他の環境で使用しようとすると、適応作業が非常に困難であることがわかります。
私が Comctx を開発したのは、この問題を解決するためです。これは Comlink のシンプルな API を維持しつつ、アダプターパターンを通じて環境適応を簡単にします。
具体的に解決した問題#
Comlink は主に Web Worker 用に設計されていますが、理論的には他の環境にも適応可能ですが、実際には非常に困難です。
例えば、ブラウザ拡張では、Content Script と Background Script の間は chrome.runtime
API を介してのみ通信できます。Comlink を使用する場合、この API を MessagePort の形式にラップする方法を考えなければなりません。このプロセスは非常に複雑で、Comlink のアダプタコードを再構築する必要があります issuse(438)。
同様の問題は Electron や特定の制限された環境でも存在します。新しい環境に遭遇するたびに、複雑な適応作業を行う必要があります。
Comctx の考え方はシンプルです:
- 特定の通信方法に制限しない
- メッセージの送信方法、受信方法を教えるアダプタインターフェースを提供する
- 残りの RPC ロジックはすべて処理する
これにより、同じサービスコードをさまざまな環境で再利用できるようになります。
どこで使えるか見てみましょう#
1. ブラウザ拡張開発#
// 共有のストレージサービス
class StorageService {
async get(key) {
const result = await chrome.storage.local.get(key)
return result[key]
}
async set(key, value) {
await chrome.storage.local.set({ [key]: value })
}
async onChanged(callback) {
chrome.storage.onChanged.addListener(callback)
}
}
const [provideStorage, injectStorage] = defineProxy(() => new StorageService())
// Background Script (サービス提供者)
class BackgroundAdapter {
sendMessage = (message) => chrome.runtime.sendMessage(message)
onMessage = (callback) => chrome.runtime.onMessage.addListener(callback)
}
provideStorage(new BackgroundAdapter())
// Content Script (サービス使用者)
const storage = injectStorage(new BackgroundAdapter())
await storage.set('userPrefs', { theme: 'dark' })
const prefs = await storage.get('userPrefs')
2. Web Worker 計算タスク#
// 画像処理サービス
class ImageProcessor {
async processImage(imageData, filters) {
// 複雑な画像処理アルゴリズム
return processedData
}
async onProgress(callback) {
// 処理進捗コールバック
}
}
const [provideProcessor, injectProcessor] = defineProxy(() => new ImageProcessor())
// Worker 側
class WorkerAdapter {
sendMessage = (message) => postMessage(message)
onMessage = (callback) => addEventListener('message', event => callback(event.data))
}
provideProcessor(new WorkerAdapter())
// メインスレッド
const processor = injectProcessor(new WorkerAdapter())
// 進捗コールバック
processor.onProgress(progress => updateUI(progress))
// 処理結果
const result = await processor.processImage(imageData, filters)
3. iframe クロスドメイン通信#
// 支払いサービス(安全な iframe 内で実行)
class PaymentService {
async processPayment(amount, cardInfo) {
// 安全な支払い処理ロジック
return paymentResult
}
async validateCard(cardNumber) {
return isValid
}
}
// iframe 内の支払いサービス
class IframeAdapter {
sendMessage = (message) => parent.postMessage(message, '*')
onMessage = (callback) => addEventListener('message', event => callback(event.data))
}
provide(new IframeAdapter())
// メインページが支払いサービスを呼び出す
const payment = inject(new IframeAdapter())
const result = await payment.processPayment(100, cardInfo)
4. Electron プロセス間通信#
// ファイル操作サービス(メインプロセスでファイルシステムアクセスを提供)
class FileService {
async readFile(path) {
return fs.readFileSync(path, 'utf8')
}
async writeFile(path, content) {
fs.writeFileSync(path, content)
}
async watchFile(path, callback) {
fs.watchFile(path, callback)
}
}
// メインプロセス
class MainProcessAdapter {
sendMessage = (message) => webContents.send('ipc-message', message)
onMessage = (callback) => ipcMain.on('ipc-message', (_, data) => callback(data))
}
provide(new MainProcessAdapter())
// レンダープロセス
class RendererAdapter {
sendMessage = (message) => ipcRenderer.send('ipc-message', message)
onMessage = (callback) => ipcRenderer.on('ipc-message', (_, data) => callback(data))
}
const fileService = inject(new RendererAdapter())
const content = await fileService.readFile('/path/to/file')
5. マイクロフロントエンドアーキテクチャ#
// 共有のユーザー認証サービス
class AuthService {
async login(credentials) { /* ... */ }
async logout() { /* ... */ }
async getCurrentUser() { /* ... */ }
async onAuthStateChange(callback) { /* ... */ }
}
// メインアプリが認証サービスを提供
class MicroFrontendAdapter {
sendMessage = (message) => window.postMessage({ ...message, source: 'main-app' }, '*')
onMessage = (callback) => {
window.addEventListener('message', event => {
if (event.data.source === 'micro-app') callback(event.data)
})
}
}
// 各マイクロフロントエンドアプリが同じ認証サービスを使用できる
const auth = inject(new MicroFrontendAdapter())
const user = await auth.getCurrentUser()
これらの例からわかるように、基盤となる通信メカニズムが何であれ、ビジネスコードは同じです。これがアダプターパターンの利点です。
Comlink に対する改善点#
環境制限の問題を解決するだけでなく、Comctx は他の面でもいくつかの最適化を行っています:
パッケージサイズが小さい コアコードの極めてシンプルな設計のおかげで、Comctx は 1KB+ であり、Comlink は 4KB です。
Transferable Objects の自動処理 ArrayBuffer や ImageData などの大きなオブジェクトを転送する際、Comctx は自動的に transfer に抽出できます。Comlink では手動で処理する必要があります。
より良い接続管理 Comctx はハートビート検出を内蔵しており、リモートサービスが準備できるのを自動的に待機します。これにより、Comlink でよく見られるタイミングの問題が解決されます —— 時にはメソッドを呼び出すときに、相手がメッセージを受け取る準備ができていないことがあります。
型安全性 TypeScript のサポートは Comlink と同様に優れており、必要な型推論がすべてあります。
設計思想の違い#
Comlink と Comctx の設計思想には本質的な違いがあります:
Comlink のやり方
// ワーカー全体を直接ラップする
const api = Comlink.wrap(worker)
await api.someMethod()
この方法は非常に直接的ですが、問題は通信メカニズムが固定されていることです。Worker オブジェクトは MessagePort をサポートする必要があり、環境を変えると機能しません。
Comctx のやり方
// 先にサービスを定義する
const [provide, inject] = defineProxy(() => new Service())
// サーバー側:サービスを公開する
provide(adapter)
// クライアント側:サービスを使用する
const service = inject(adapter)
ここでの重要な点は adapter
です。これは Comctx にメッセージの送受信方法を教えますが、具体的にどの方法を使用するかを制限しません。これにより、通信方法とビジネスロジックの分離が実現されます。
さらに、Comctx にはハートビート検出メカニズムがあり、接続が生きていることを確認します。これにより、Comlink でよく見られる接続のタイミングの問題が解決されます。
まとめ#
Comctx の開発の目的は非常にシンプルです:RPC 通信を環境の制限から解放することです。
もしあなたが Web Worker の中で使うだけであれば、Comlink で十分です。しかし、あなたのプロジェクトがブラウザ拡張、iframe、Electron、または他のカスタム通信シナリオを含む場合、Comctx はより良い選択肢となるでしょう。
これは環境適応の問題を解決するだけでなく、パッケージサイズ、パフォーマンス、信頼性の面でも改善されています。最も重要なのは、API 設計が Comlink のシンプルさを維持しており、学習コストはほぼゼロであることです。
関連リソース#
- 📚 GitHub リポジトリ - 完全なソースコードとサンプル
- 📦 NPM パッケージ - すぐにインストールして使用
- 📖 オンラインドキュメント - 詳細な使用ガイド