サーバーなしでウィンドウ間の状態を共有する

最近、ソーシャルネットワークでBjorn Staalが作った素晴らしいアート作品のgifが流行っていました

Bjornのプロジェクトの一番かっこいいところだと思う、複数のウィンドウ間での状態共有の仕組みを理解しようとしました!
このトピックについての良い記事やチュートリアルが見つからなかったので、自分の発見を皆さんとシェアすることにしました。

Bjornの作品を基に、簡素化された概念実証(POC)を作ってみましょう!

まず初めに、複数のクライアント間で情報を共有するために知っている方法をすべて挙げてみました:

サーバー

もちろん、サーバー(ポーリングやウェブソケットを使用)があれば問題を単純化できます。しかし、Bjornはサーバーを使わずに成果を得ていたので、これは除外しました。

ローカルストレージ

ローカルストレージは基本的にブラウザのキーバリューストアであり、ブラウザセッション間で情報を持続するためによく使用されます。認証トークンやリダイレクトURLを保存するために使用されることが多いですが、シリアル化可能なものなら何でも保存できます。こちらで詳しく学べます

ローカルストレージの楽しいAPIを最近発見しました。それは、同じウェブサイトの別のセッションでローカルストレージが変更された際に発火するstorageイベントです。

ローカルストレージを利用して、各ウィンドウの状態を保存します。あるウィンドウが状態を変更するたびに、他のウィンドウはストレージイベントを通じて更新されます。

このアイデアは私の最初の考えで、Bjornが選んだ解決策のようです。彼はLocal Storageマネージャのコードを共有し、threeJsを使用した例をこちらにシェアしています

しかし、この問題を解決するコードが既にあると分かったら、他に方法があるのかを見てみたくなりました。そして、ネタバレですが、はい、あります!

シェアードワーカー

この派手な用語の背後には、WebWorkersという興味深い概念があります。

シンプルに言うと、ワーカーは基本的に別のスレッドで動作するセカンドスクリプトです。HTMLドキュメントの外側に存在するためDOMにはアクセスできませんが、メインスクリプトとのコミュニケーションは可能です。
主にメインスクリプトをオフロードするために使用され、情報の事前取得やストリーミングログやポーリングなどのより重要度の低いタスクを扱います。

シェアードワーカーは特別な種類のWebWorkersで、同じスクリプトの複数のインスタンスと通信することができます。これが私たちのユースケースにとって面白い点です!さあ、コードに飛び込みましょう!

ワーカーのセットアップ

前述したように、ワーカーは独自のエントリーポイントを持つ「セカンドスクリプト」です。TypeScript、バンドラ、開発サーバーなどのセットアップに応じて、tsconfigを調整したり、ディレクティブを追加したり、特定のインポート構文を使用する必要があるかもしれません。

ワークスペースの使用法全てをカバーすることはできませんが、MDNやインターネット上で情報を見つけることができます。
必要であれば、セットアップ方法の詳細を説明するこの記事の前編を喜んで作成するでしょう!

私の場合はViteとTypeScriptを使用しているので、worker.tsファイルが必要で、@types/sharedworkerをdev依存関係としてインストールする必要があります。メインスクリプト内でこの構文を使用して接続を作成できます。

new SharedWorker(new URL("worker.ts", import.meta.url));

基本的に行う必要があることは:

  • 各ウィンドウを特定する
  • すべてのウィンドウの状態を追跡する
  • ウィンドウの状態が変わった時に他のウィンドウに再描画を促す

私たちの状態は非常にシンプルです:

type WindowState = {
      screenX: number;  // window.screenX
      screenY: number;  // window.screenY
      width: number;    // window.innerWidth
      height: number;   // window.innerHeight
};

もちろん、ウィンドウの位置をモニターの左上隅に対して教えてくれるwindow.screenXwindow.screenYが最も重要な情報です。

メッセージには2種類あります:

  • 各ウィンドウは、状態が変わるたびにwindowStateChangedメッセージを公開して新しい状態を通知します。
  • ワーカーは、どれかのウィンドウが変わったことを他のウィンドウに知らせるための更新を送信します。syncメッセージを使ってすべてのウィンドウの状態を送ります。

こんな感じで簡単なワーカーを始めることができます:

    // worker.ts 
    let windows: { windowState: WindowState; id: number; port: MessagePort }[] = [];

    onconnect = ({ ports }) => {
      const port = ports[0];

      port.onmessage = function (event: MessageEvent<WorkerMessage>) {
        console.log("何かをやる予定だ");
      };
    };

そして、SharedWorkerへの基本的な接続は、以下のようになります。いくつかの基本的な関数でidを生成し、現在のウィンドウ状態を計算しています。また、WorkerMessageと呼ばれるメッセージの種類にいくつかのタイピングをしました:

    // main.ts
    import { WorkerMessage } from "./types";
    import {
      generateId,
      getCurrentWindowState,
    } from "./windowState";

    const sharedWorker = new SharedWorker(new URL("worker.ts", import.meta.url));
    let currentWindow = getCurrentWindowState();
    let id = generateId();

アプリケーションを開始すると、新しいウィンドウがあることをワーカーに通知するために、すぐにメッセージを送信する必要があります:

    // main.ts 
    sharedWorker.port.postMessage({
      action: "windowStateChanged",
      payload: {
        id,
        newWindow: currentWindow,
      },
    } satisfies WorkerMessage);

ワーカー側でこのメッセージを聞いて、onmessageをそれに合わせて変更できます。基本的にワーカーがwindowStateChangedメッセージを受信すると、それが新しいウィンドウであれば状態に追加し、古いウィンドウであれば変更したウィンドウとして取り扱い、状態が変更されたことを皆に知らせる必要があります:

    // worker.ts
    port.onmessage = function (event: MessageEvent<WorkerMessage>) {
      const msg = event.data;
      switch (msg.action) {
        case "windowStateChanged": {
          const { id, newWindow } = msg.payload;
          const oldWindowIndex = windows.findIndex((w) => w.id === id);
          if (oldWindowIndex !== -1) {
            // 古いウィンドウが変更された
            windows[oldWindowIndex].windowState = newWindow;
          } else {
            // 新しいウィンドウ
            windows.push({ id, windowState: newWindow, port });
          }
          windows.forEach((w) =>
            // ここでsyncを送る
          );
          break;
        }
      }
    };

syncを送るためには、"port"プロパティがシリアル化できないためにちょっとしたハックが必要です。ぼくは面倒なので、windowsをよりシリアル化可能な配列にマッピングせずに文字列化してからパースを戻しています:

    w.port.postMessage({
      action: "sync",
      payload: { allWindows: JSON.parse(JSON.stringify(windows)) },
    } satisfies WorkerMessage);

そしたら描画の楽しい部分になります!

楽しい部分:描画!

もちろん、複雑な3D球体は描かないでしょう:各ウィンドウの中央に円を描き、その間に線を引くだけです!

基本的な2Dコンテキストを使ってHTMLキャンバスに描画しますが、何を使っても構いません。中央の円を描画するのはとても簡単です:

    const drawCenterCircle = (ctx: CanvasRenderingContext2D, center: Coordinates) => {
      const { x, y } = center;
      ctx.strokeStyle = "#eeeeee";
      ctx.lineWidth = 10;
      ctx.beginPath();
      ctx.arc(x, y, 100, 0, Math.PI * 2, false);
      ctx.stroke();
      ctx.closePath();
    };

線を描くためには、もう少し数学(お約束ですが、そんなに多くはありません🤓)が必要で、別のウィンドウの中央の相対的な位置を現在のウィンドウの座標に変換する必要があります。
基本的に座標系の変更を行っています。その数式をこの方法で使っています。まず、モニタの座標系に変換し、現在のウィンドウのscreenX/screenYでオフセットを付けます。

    const baseChange = ({
      currentWindowOffset,
      targetWindowOffset,
      targetPosition,
    }: {
      currentWindowOffset: Coordinates;
      targetWindowOffset: Coordinates;
      targetPosition: Coordinates;
    }) => {
      const monitorCoordinate = {
        x: targetPosition.x + targetWindowOffset.x,
        y: targetPosition.y + targetWindowOffset.y,
      };

      const currentWindowCoordinate = {
        x: monitorCoordinate.x - currentWindowOffset.x,
        y: monitorCoordinate.y - currentWindowOffset.y,
      };

      return currentWindowCoordinate;
    };

そして、お分かりの通り、今は同じ相対座標系に二つの点があるので、線を引くことができます!

    const drawConnectingLine = ({
      ctx,
      hostWindow,
      targetWindow,
    }: {
      ctx: CanvasRenderingContext2D;
      hostWindow: WindowState;
      targetWindow: WindowState;
    }) => {
      ctx.strokeStyle = "#ff0000";
      ctx.lineCap = "round";
      const currentWindowOffset: Coordinates = {
        x: hostWindow.screenX,
        y: hostWindow.screenY,
      };
      const targetWindowOffset: Coordinates = {
        x: targetWindow.screenX,
        y: targetWindow.screenY,
      };

      const origin = getWindowCenter(hostWindow);
      const target = getWindowCenter(targetWindow);

      const targetWithBaseChange = baseChange({
        currentWindowOffset,
        targetWindowOffset,
        targetPosition: target,
      });

      ctx.strokeStyle = "#ff0000";
      ctx.lineCap = "round";
      ctx.beginPath();
      ctx.moveTo(origin.x, origin.y);
      ctx.lineTo(targetWithBaseChange.x, targetWithBaseChange.y);
      ctx.stroke();
      ctx.closePath();
    };

そして、状態が変わった場合に反応するだけです。

    // main.ts
    sharedWorker.port.onmessage = (event: MessageEvent<WorkerMessage>) => {
        const msg = event.data;
        switch (msg.action) {
          case "sync": {
            const windows = msg.payload.allWindows;
            ctx.reset();
            drawMainCircle(ctx, center);
            windows
              .forEach(({ windowState: targetWindow }) => {
                drawConnectingLine({
                  ctx,
                  hostWindow: currentWindow,
                  targetWindow,
                });
              });
          }
        }
    };

最後に、定期的にウィンドウが変わったかを確認して、そうであればメッセージを送信します。

      setInterval(() => {
        const newWindow = getCurrentWindowState();
        if (
          didWindowChange({
            newWindow,
            oldWindow: currentWindow,
          })
        ) {
          sharedWorker.port.postMessage({
            action: "windowStateChanged",
            payload: {
              id,
              newWindow,
            },
          } satisfies WorkerMessage);
          currentWindow = newWindow;
        }
      }, 100);

このリポジトリで全コードを見ることができます。実際にはもっと抽象化しており、それで多くの実験をしましたが、要点は同じです。

複数のウィンドウで実行すると、うまくいけば以下のようになるはずです!

読んでくれてありがとう!

この記事が役立つ、面白い、または単に楽しいと思ったら、友人や同僚、コミュニティにシェアしてください。
私のニュースレターにも登録できます。無料です!

編集:

BroadcastChannel APIを使った別の解決策を提案した皆さんに注目していただきたいです。特に@framemuseさんと@axiolさんに感謝します。
実際、@axiolさんはBroadcastChannel APIを使った解決策について完全な記述をこちらで見つけることができます

新しいことを学ぶために(私から始まって)皆を助けてくれた彼らに大感謝です。

こちらの記事はdev.toの良い記事を日本人向けに翻訳しています。
https://dev.to/notachraf/sharing-a-state-between-windows-without-a-serve-23an