如何在浏览器窗口之间共享状态和传输信息
最近,社交网络上流行一个 gif,展示了比约恩-斯塔尔(Bjorn Staal)制作的一件令人惊叹的网页作品。
我想重现它,但由于缺乏球体、粒子和物理方面的三维技术,我的目标是了解如何让一个窗口对另一个窗口的位置做出反应。
从本质上讲,在多个窗口之间共享一个状态,我认为这是 Bjorn 项目最酷的地方之一!由于找不到相关的好文章或教程,我决定与大家分享我的发现。
让我们根据 Bjorn 的工作尝试创建一个简化的概念验证 (POC)!
我做的第一件事就是列出我所知道的在多个客户之间共享信息的所有方法:
哦,一个服务器
很明显,如果有一个服务器(轮询或 websockets),问题就会简化。但是,由于 Bjorn 在不使用服务器的情况下也取得了成果,所以这是不可能的。
Local Storage
Local Storage本质上是一种浏览器键值存储,通常用于在浏览器会话之间持久保存信息。它通常用于存储 Auth 标记或重定向 URL,也可以存储任何可序列化的信息。您可以在此了解更多信息。
我最近发现了Local Storage的一些有趣的 API,包括存储事件,只要同一网站的另一个会话更改了Local Storage,存储事件就会触发。
我们可以通过在local storage中存储每个窗口的状态来利用这一点。每当一个窗口改变其状态时,其他窗口就会通过存储事件进行更新。
这是我最初的想法,而这似乎也是 Bjorn 选择的解决方案,因为他在这里分享了他的 LocalStorage 管理器代码以及一个与 threeJs 配合使用的示例。
但是,当我发现有代码可以解决这个问题时,我想看看是否还有其他方法……剧透一下:是的,有!
Shared Workers
在这些华丽的术语背后是一个迷人的概念–WebWorkers 概念。
简单来说, Worker 本质上是运行在另一个线程上的第二个脚本。虽然它们无法访问 DOM(因为它们存在于 HTML 文档之外),但它们仍然可以与主脚本通信。它们主要用于通过处理后台作业(如预先抓取信息)或处理流式日志和轮询等不太重要的任务来缓解主脚本。
共享 Worker 是一种特殊的 WebWorker,它可以与同一脚本的多个实例通信,因此在我们的使用案例中非常有趣!好了,让我们直接进入代码!
设置 Worker
如前所述,worker 是 “第二个脚本”,有自己的入口点。根据您的设置(TypeScript、bundler、开发服务器),您可能需要调整您的 tsconfig
、添加指令或使用特定的导入语法。
我无法涵盖使用网络 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。
- Worker 将向所有其他窗口发送更新,提醒它们其中一个窗口已发生变化。Worker 将发送包含所有窗口状态的 syncmessage。
我们可以从类似下面这样的普通 Worker 开始:
// 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();
启动应用程序后,我们应提醒 Worker 有一个新窗口,因此我们应立即发送一条消息:
// main.ts
sharedWorker.port.postMessage({
action: "windowStateChanged",
payload: {
id,
newWindow: currentWindow,
},
} satisfies WorkerMessage);
我们可以在 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;
}
}
};
为了发送同步,因为 “port “属性无法序列化,所以我需要对其进行字符串化,然后再解析回来。因为我很懒,没有把窗口映射到更可序列化的数组中
w.port.postMessage({
action: "sync",
payload: { allWindows: JSON.parse(JSON.stringify(windows)) },
} satisfies WorkerMessage);
现在该画画了!
有趣的部分:绘图!
当然,我们不会绘制复杂的 3D 球体:我们只需在每个窗口的中心画一个圆,并在球体之间画一条连接线!
我将使用 HTML 画布的基本 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);
你可以在这个资源库中找到全部代码。实际上,由于我做了很多实验,代码变得更抽象了,但要点是一样的。
如果在多个窗口上运行,希望能得到与此相同的效果!
感谢您的阅读!
如果您觉得这篇文章对您有帮助、有趣,或者只是好玩,您可以与您的朋友/同事/社区分享!
本文文字及图片出自 Sharing a State Between Windows without a Server