无需服务器即可在窗口之间共享状态

2025-05-25

无需服务器即可在窗口之间共享状态

最近,社交网络上流行一张 gif,展示了Bjorn Staal 创作的一件令人惊叹的艺术作品

Bjorn Staal 的艺术作品

我想重新创建它,但缺乏球体、粒子和物理的 3D 技能,我的目标是了解如何让一个窗口对另一个窗口的位置做出反应。

本质上,就是在多个窗口之间共享状态,我发现这是 Bjorn 项目最酷的方面之一!
因为找不到关于这个主题的好文章或教程,所以我决定把我的发现分享给大家。

让我们尝试根据 Bjorn 的工作创建一个简化的概念证明 (POC)!

我们将尝试创造什么(当然它远没有 Bjorn 的作品那么性感)

我做的第一件事就是列出我所知道的所有在多个客户端之间共享信息的方法:

呃:一台服务器

显然,拥有一个服务器(无论是轮询还是 WebSocket)可以简化这个问题。然而,由于 Bjorn 在没有使用服务器的情况下就实现了他的目标,所以这是不可能的。

本地存储

本地存储本质上是一个浏览器键值存储,通常用于在浏览器会话之间持久化信息。虽然它通常用于存储身份验证令牌或重定向 URL,但它可以存储任何可序列化的数据。您可以点击此处了解更多信息

我最近发现了一些有趣的本地存储 API,包括storage事件,每当本地存储被同一网站的另一个会话更改时,该事件就会触发。

想发现新的 API 吗?
订阅我的新闻通讯(免费!)

存储事件如何运作(当然是简化的)

我们可以利用这一点,将每个窗口的状态存储在本地存储中。每当一个窗口改变其状态时,其他窗口都会通过存储事件进行更新。

这是我最初的想法,而且这似乎是 Bjorn 选择的解决方案,因为他在这里分享了他的 LocalStorage 管理器代码以及将其与 threeJs 一起使用的示例

但是,当我发现有代码可以解决这个问题时,我想看看是否还有其他方法......剧透警告:是的,有!

共享工作者

这个华丽的术语背后是一个令人着迷的概念——WebWorkers 的概念。

简单来说,Worker 本质上是在另一个线程上运行的第二个脚本。由于它们位于 HTML 文档之外,因此无法访问 DOM,但它们仍然可以与主脚本通信。
它们主要用于通过处理后台作业(例如预取信息)或处理不太重要的任务(例如流式日志和轮询)来减轻主脚本的负担。

脚本和工作者之间的通信机制的简单解释

共享 Worker 是一种特殊的 WebWorker,它可以与同一脚本的多个实例进行通信,这对我们来说非常实用!好的,让我们直接进入代码!

共享工作者可以向同一脚本的多个会话发送信息

设置工作者

如上所述,worker 是具有独立入口点的“第二个脚本”。根据你的设置(TypeScript、打包器、开发服务器),你可能需要调整 tsconfig、添加指令或使用特定的导入语法。

我无法涵盖所有​​使用 Web Worker 的方法,但您可以在 MDN 或互联网上找到相关信息。
如果需要,我很乐意为本文写一篇前传,详细介绍所有设置方法!

就我而言,我使用的是 Vite 和 TypeScript,因此需要一个worker.ts文件并将其安装@types/sharedworker为开发依赖项。我们可以使用以下语法在主脚本中创建连接:

new SharedWorker(new URL("worker.ts", import.meta.url));
Enter fullscreen mode Exit fullscreen mode

基本上,我们需要:

  • 识别每个窗口

  • 跟踪所有窗口状态

  • 一旦窗口改变其状态,就通知其他窗口重新绘制

我们的状态非常简单:

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

当然,最重要的信息是,window.screenX它们window.screenY告诉我们窗口相对于显示器左上角的位置。

我们将收到两种类型的消息:

  • 每个窗口,每当它的状态改变时,都会发布一个windowStateChangedmessage具有其新状态的信息。

  • Worker 会向所有其他窗口发送更新,提醒它们其中一个窗口已发生更改。Worker 会发送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");
      };
    };
Enter fullscreen mode Exit fullscreen mode

我们与 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();
Enter fullscreen mode Exit fullscreen mode

一旦我们启动应用程序,我们应该提醒工作人员有一个新窗口,因此我们立即发送一条消息:

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

我们可以在 Worker 端监听此消息,并相应地修改 onmessage。基本上,一旦 Worker 收到 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;
        }
      }
    };
Enter fullscreen mode Exit fullscreen mode

为了发送同步,我实际上需要一点技巧,因为“port”属性无法序列化,所以我将其字符串化并解析回去。因为我比较懒,所以我不会直接将窗口映射到更可序列化的数组:

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

现在是时候画东西了!

有趣的部分:绘画!

当然,我们不会做复杂的 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();
    };
Enter fullscreen mode Exit fullscreen mode

为了绘制这些线,我们需要做一些数学运算(我保证,不会很多🤓),将另一个窗口中心的相对位置转换为当前窗口的坐标。
本质上,我们是在改变基坐标。我使用了一些数学运算来实现这一点。首先,我们将基坐标更改为显示器上的坐标,并将其偏移当前窗口的 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;
    };
Enter fullscreen mode Exit fullscreen mode

正如你所知,现在我们在同一个相对坐标系上有两个点,我们现在可以画线了!

    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();
    };
Enter fullscreen mode Exit fullscreen mode

现在,我们只需要对状态变化做出反应。

    // 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,
                });
              });
          }
        }
    };
Enter fullscreen mode Exit fullscreen mode

最后一步,我们只需要定期检查窗口是否发生变化,如果发生变化,则发送一条消息

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

你可以在这个仓库中找到完整的代码。实际上,由于我做了很多实验,所以代码变得更抽象一些,但其核心思想是一样的。

如果您在多个窗口上运行它,希望您可以得到与此相同的结果!

完整结果

谢谢阅读!

如果您发现这篇文章有用、有趣或只是有趣,您可以与您的朋友/同事/社区分享,
您也可以订阅我的时事通讯,它是免费的!

堕落的工程师 | Achraf | Substack

这位喝着咖啡因的脑残工程师,谈论的内容基本上是60%的科技/30%的人际互动/10%的互联网知识。点击阅读 Achraf 的《堕落工程师》(The Degenerate Engineer),这是一本由 Substack 出版、拥有数百名订阅者的出版物。

网站图标notachraf.substack.com

编辑:

你们中的一些人提出了解决这个问题的另一种方法,即使用BroadcastChannel API,我想向他们致敬,主要是@framemuse@axiol,
实际上@axiol使用 BroadcastChannel API 完整地写了解决方案,你可以在这里找到:https://github.com/Axiol/linked-windows-broadcast-api

非常感谢他们帮助其他人学习新东西(从我开始)

文章来源:https://dev.to/notachraf/sharing-a-state- Between-windows-without-a-serve-23an
PREV
Docker 初学者指南
NEXT
一些实用的 JavaScript 技巧