最近,我看到这样一件令人惊叹的艺术品:
酷毙了,是不是?看着心痒难耐,于是我试着重建,发现这个项目的本质是——在多个窗口之间共享状态。这个可以有!
一起来看看我的研究经过吧。
首先声明,我这个是简化版的哈!
我做的第一件事是列出我所知道的在多个客户端之间共享信息的所有方法:
服务器
显然,拥有服务器可以直接简化问题。但是,由于这个项目需要在不使用服务器的情况下实现,所以直接pass这个选项。
本地存储
本地存储本质上是浏览器键值存储,通常用于在浏览器会话之间持久保存信息。虽然本地存储常用于存储身份验证令牌或重定向URL,但可以存储任何可序列化的内容。
这里重点要介绍一个非常有趣的本地存储API,storage事件——每当本地存储由于同一网站上的另一个会话而更改时,就会触发事件。
图片
我灵机一动试着在本地存储中存储每个窗口的状态,每当有窗口的状态改变时,其他窗口就通过storage事件进行更新。
对头,似乎解决方案就是这个了。
那么代码可以解决这个问题吗?到底有没有其他方法呢?答案是:有!
共享worker
在这个华丽的术语背后,我们需要先了解WebWorkers的概念。
简单来说,worker其实是在另一个线程上运行的第二个脚本。虽然因为存在于HTML文档之外导致worker无权访问 DOM,但worker仍然可以与主脚本进行通信。
worker主要用于通过处理后台作业,例如预获取信息或处理不太重要的任务(如流式日志和轮询),来卸载主脚本。
图片
共享worker是一种特殊的WebWorker,它可以与同一脚本的多个实例进行通信,可以将信息发送到同一脚本的多个会话!
图片
设置worker
如前所述,worker是“第二个脚本”。我们需要根据设置(TypeScript、捆绑器、开发服务器),调整tsconfig、添加指令或使用特定的导入语法。
在众多使用WebWorker的方法中,就我而言,我喜欢使用Vite和TypeScript,所以需要一个worker.ts文件并将@types/sharedworker安装为开发依赖项。
我们可以使用以下语法在主脚本中创建连接:
new SharedWorker(new URL("worker.ts", import.meta.url));
整个过程就是:
1.识别每个窗口
2.跟踪所有窗口状态
3.在窗口改变状态时提醒其他窗口重新绘制
状态也是非常简单:
type WindowState = {
screenX: number; // window.screenX
screenY: number; // window.screenY
width: number; // window.innerWidth
height: number; // window.innerHeight
};
当然,最关键的信息是window.screenX和window.screenY,因为需要它们告诉我们窗口相对于显示器左上角的位置。
我们还需要提供两种类型的消息:
1.每当有窗口改变状态,发布带有新状态的windowStateChangedmessage。
2.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的Message类型进行了输入:
// 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 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);
实际上,我试验了很多次,以便更抽象更有科幻感,但总而言之要点都是一样的。
如果一切顺利,最后我们可以得到这样炫酷的结果!
感谢阅读!