阿川私房教材:學程式,拿 offer!

63 個專案實戰,直接上手!
無需補習,按步驟打造你的面試作品。

立即解鎖你的轉職秘笈

最近,社群網路上流行一張 gif 動圖,展示了一件 Bjorn Staal 製作的令人驚嘆的藝術品

Bjorn Staal 藝術作品

我想重新建立它,但缺乏球體、粒子和物理的 3D 技能,我的目標是了解如何讓一個視窗對另一個視窗的位置做出反應。

本質上,在多個視窗之間共享狀態,我發現這是 Bjorn 專案中最酷的方面之一!

由於無法找到有關該主題的好文章或教程,我決定與您分享我的發現。

讓我們嘗試根據 Bjorn 的工作建立一個簡化的概念驗證 (POC)!

我們將嘗試創造什麼(ofc 它比 Bjorn 的作品沒那麼性感)

我做的第一件事就是列出我所知道的在多個客戶端之間共享資訊的所有方法:

呃:伺服器

顯然,擁有伺服器(帶有輪詢或 Websocket)可以簡化問題。然而,由於 Bjorn 在沒有使用伺服器的情況下實現了他的結果,所以這是不可能的。

本機存儲

本地存儲本質上是瀏覽器鍵值存儲,通常用於在瀏覽器會話之間保存資訊。雖然通常用於儲存身份驗證令牌或重定向 URL,但它可以儲存任何可序列化的內容。 您可以在這裡了解更多

我最近發現了一些有趣的本地儲存 API,包括 storage 事件,每當同一網站的另一個會話更改本地儲存時就會觸發該事件。

儲存事件如何運作(當然是簡化的)

我們可以透過將每個視窗的狀態儲存在本地儲存中來利用這一點。每當一個視窗改變其狀態時,其他視窗將透過儲存事件進行更新。

這是我最初的想法,這似乎是Bjorn 選擇的解決方案,因為他分享了他的LocalStorage 管理器程式碼以及與ThreeJs 一起使用的範例此處

但是當我發現有程式碼可以解決這個問題時,我想看看是否有其他方法…劇透警告:是的,有!

共享工作者

這個華而不實的術語背後是一個令人著迷的概念——WebWorkers 的概念。

簡單來說,工作執行緒本質上是在另一個執行緒上執行的第二個腳本。雖然它們無法存取 DOM(因為它們存在於 HTML 文件之外),但它們仍然可以與您的主腳本進行通訊。

它們主要用於透過處理背景作業來卸載主腳本,例如預取資訊或處理不太關鍵的任務(例如流日誌和輪詢)。

腳本與worker之間通訊機制的簡單解釋

共享工作線程是一種特殊類型的 WebWorkers,它可以與同一腳本的多個實例進行通信,這使得它們對我們的用例很有趣!好吧,讓我們直接進入程式碼!

共享工作人員可以將資訊傳送到同一腳本的多個會話

設定工人

如前所述,工作人員是具有自己的入口點的「第二腳本」。根據您的設定(TypeScript、捆綁程式、開發伺服器),您可能需要調整 tsconfig、新增指令或使用特定的匯入語法。

我無法涵蓋所有使用 Web Worker 的可能方法,但您可以在 MDN 或網路上找到資訊。

如果需要,我很樂意為本文撰寫前傳,詳細介紹設定它們的所有方法!

就我而言,我使用的是 Vite 和 TypeScript,因此我需要一個「worker.ts」檔案並將「@types/sharedworker」安裝為開發依賴項。我們可以使用以下語法在主腳本中建立連結:

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.screenX”和“window.screenY”,因為它們告訴我們視窗相對於顯示器左上角的位置。

我們將有兩種類型的訊息:

  • 每個窗口,無論何時改變其狀態,都會發布一個帶有新狀態的“windowStateChangedmessage”。

  • 工作人員將向所有其他視窗發送更新,以提醒他們其中一個視窗已更改。工作人員將發送包含所有視窗狀態的「syncmessage」。

我們可以從一個看起來有點像這樣的普通工人開始:

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

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

      port.onmessage = function (event: MessageEvent<WorkerMessage>) {
        console.log("We'll do something");
      };
    };

我們與 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) {
            // old one changed
            windows[oldWindowIndex].windowState = newWindow;
          } else {
            // new window 
            windows.push({ id, windowState: newWindow, port });
          }
          windows.forEach((w) =>
            // send sync here 
          );
          break;
        }
      }
    };

為了發送同步,我實際上需要一些技巧,因為“port”屬性無法序列化,所以我將其字串化並解析回來。因為我很懶,所以我不只是將視窗映射到更可序列化的陣列:

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

現在是時候畫東西了!

有趣的部分:繪畫!

當然,我們不會做複雜的 3D 球體:我們只會在每個視窗的中心畫一個圓,並在球體之間畫一條線!

我將使用 HTML Canvas 的基本 2D 上下文進行繪製,但您可以使用任何您想要的內容。畫一個圓,非常簡單:

    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);

您可以在此儲存庫中找到完整的程式碼。實際上,我用它做了很多實驗,使它變得更加抽象,但其要點是相同的。

如果您在多個視窗上執行它,希望您能得到與此相同的結果!

完整結果

謝謝閱讀 !

如果您發現這篇文章有幫助、有趣或只是有趣,您可以將其分享給您的朋友/同事/社區

您也可以訂閱我的電子報它是免費的!


原文出處:https://dev.to/notachraf/sharing-a-state-between-windows-without-a-serve-23an


共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。

阿川私房教材:學程式,拿 offer!

63 個專案實戰,直接上手!
無需補習,按步驟打造你的面試作品。

立即解鎖你的轉職秘笈