🌐

Comlinkを使ってiframeと通信する

2024/12/16に公開2

https://github.com/GoogleChromeLabs/comlink

Comlink は、Web Worker などの異なるコンテキスト間での通信を簡単にするためのライブラリです。

公式では、Web Worker を enjoyable なものにするためのライブラリと紹介されています。

Comlink makes WebWorkers enjoyable. Comlink is a tiny library (1.1kB), that removes the mental barrier of thinking about postMessage and hides the fact that you are working with workers.

1.1kB と非常に軽量なのも嬉しいですね。

本来は、postMessageを通してメッセージを送信する必要がありますが、Comlink を使うことで、以下のサンプルのように、あたかも別のコンテキストの関数を実行しているかのように扱うことができます。

サンプル

// worker.ts
import * as Comlink from "comlink";
const handlers = {
  add: (a: number, b: number) => a + b,
};
Comlink.expose(handlers);
// main.ts
import * as Comlink from "comlink";

const handlers = Comlink.wrap(new Worker("worker.js"));

// worker.ts で定義した関数を呼び出したかのように使用できる
const result = await handlers.add(1, 2);

console.log(result); // 3

expose でオブジェクトを公開し、wrapexpose したオブジェクトを取得します。
wrapで取得したオブジェクトは全て非同期になります。

本来 Comlink は、Web Worker 間で使用することを想定していますが、iframe 間でも同様に使用することができます。

今回の本題です。

基本的な使い方は同じですが、iframe など他のウィンドウと連携する場合は、次のようにwindowEndpoint を活用します。

// main.ts
import * as Comlink from "comlink";

const handlers = Comlink.wrap(Comlink.windowEndpoint(iframe.contentWindow));
// iframe.ts
import * as Comlink from "comlink";

const handlers = {
  add: (a: number, b: number) => a + b,
};
Comlink.expose(handlers, Comlink.windowEndpoint(self.parent));

iframe 側では、Web Worker のときと同様にexpose を行い関数を受け取れるようにします。
こちら側でも、同様windowEndpoint を使用します。

windowEndpoint の詳細は、以下のドキュメントを参照してください。

https://github.com/GoogleChromeLabs/comlink?tab=readme-ov-file#comlinkwindowendpointwindow-context--self-targetorigin--

オリジンが異なる場合

もし、iframe のオリジンが異なる場合は、 targetOrigin を指定します。

// main.ts
import * as Comlink from "comlink";

const handlers = Comlink.wrap(
  Comlink.windowEndpoint(iframe.contentWindow, self, targetOrigin)
);

型をつける

expose するオブジェクトには型をつけることができます。
iframe 側を別のリポジトリで開発するときなどは、 turborepoなどのモノレポで開発して、型を共有すると便利です。

// packages/schema/types.ts
type Handlers = {
  add: (a: number, b: number) => Promise<number>;
};
// iframe.ts
import * as Comlink from "comlink";

const handlers: Handlers = {
  add: (a: number, b: number) => Promise<number>;
};
Comlink.expose(handlers);
// main.ts
import * as Comlink from "comlink";

const handlers: Comlink.Remote<Handlers> = Comlink.wrap<Handlers>(
  Comlink.windowEndpoint(iframe.contentWindow)
);

wrap したオブジェクトは、Remote<T> 型になり、
wrapしたオブジェクトは、非同期になります。

ほぼ同じですが、wrap したオブジェクトを React の useRef で管理します。

// main.tsx
import { useRef } from "react";
import * as Comlink from "comlink";
import type { Handlers } from "@/packages/types";

function Example() {
  const workerRef = useRef<Comlink.Remote<Handlers> | null>(null);

  const handleOnLoad = (e: React.SyntheticEvent<HTMLIFrameElement>) => {
    const iframe = e.target;
    if (!iframe) return;

    const contentWindow = iframe.contentWindow;
    if (!contentWindow) return;

    const worker = Comlink.wrap<Handlers>(
      Comlink.windowEndpoint(contentWindow)
    );
    workerRef.current = worker;
  };

  return <iframe onLoad={handleOnLoad} />;
}
// iframe.tsx
import * as Comlink from "comlink";

const handlers = {
  add: (a: number, b: number) => a + b,
};
Comlink.expose(handlers);

onLoad イベントで workerRef.currentwrap したオブジェクトをセットします。
これで、他の箇所でも workerRef.current を使用して wrap したオブジェクトを扱うことができます。

親側から iframe に exposeする

上記のサンプルでは、iframe 側で expose したオブジェクトを親側で wrap していました。

反対に、親側から iframe に expose することもできます。

// main.tsx
import { useRef } from "react";
import * as Comlink from "comlink";
import type { Handlers } from "@/packages/types";

function Example() {
  const getApiData = async (key: string) => {
    // データをCMSなどから取得
    const data = await getCMSData(key);

    return data;
  };

  const handleOnLoad = (e: React.SyntheticEvent<HTMLIFrameElement>) => {
    const iframe = e.target;
    if (!iframe) return;

    const contentWindow = iframe.contentWindow;
    if (!contentWindow) return;

    // expose する関数の一覧を定義
    const parentHandlers = {
      getApiData,
    };

    Comlink.expose(parentHandlers, Comlink.windowEndpoint(contentWindow));
  };

  return <iframe onLoad={handleOnLoad} />;
}
// iframe.ts
import * as Comlink from "comlink";

const parentHandlers = Comlink.wrap(Comlink.windowEndpoint(self.parent));

const result = await parentHandlers.getApiData("/api/data");

このように、親側で定義した関数を iframe 側で使用することができます。
iframe 側から親側の関数を呼び出すことができるので、postMessage で実装するのが大変だった処理も簡単に実装できます。

Comlink.Proxy を使う

Comlink では、シリアライズ可能なデータ のみ送信できます。
シリアライズできないデータを送信しようとすると、エラーが発生します。

正確な挙動は、Comlink の GitHub 上に記載されています。

GitHub - Comlink/structured-clone-table.md

MDN では、シリアライズ可能なデータの一覧が記載されています。
https://developer.mozilla.org/ja/docs/Glossary/Serializable_object

シリアライズできないデータを送信する場合は、Comlink.Proxy を使ってシリアライズ可能なデータに変換します。

// iframe.ts
import * as Comlink from "comlink";
type Handlers = {
  callFunction: (fn: () => void) => void;
};

const handlers: Handlers = {
  callFunction: (fn) => {
    fn();
  },
};

Comlink.expose(handlers);
// main.tsx
const handleOnClick = () => {
  const fuga = () => {
    console.log("fuga");
  };
  workerRef.current?.callFunction(Comlink.proxy(fuga));
};

上記のように、Comlink.proxy を使ってシリアライズ可能なデータに変換します。

まとめ

Comlink を使うことで、iframe 間での通信を簡単に行うことができます。

それでは、良い Comlink ライフを!

参考

https://github.com/GoogleChromeLabs/comlink
https://developer.mozilla.org/ja/docs/Glossary/Serializable_object

GitHubで編集を提案
chot Inc. tech blog

Discussion