原來 WebWorker 還可以做這么酷的事情!
最近,我看到這樣一件令人驚嘆的藝術(shù)品:
酷斃了,是不是?看著心癢難耐,于是我試著重建,發(fā)現(xiàn)這個項目的本質(zhì)是——在多個窗口之間共享狀態(tài)。這個可以有!
一起來看看我的研究經(jīng)過吧。
首先聲明,我這個是簡化版的哈!
我做的第一件事是列出我所知道的在多個客戶端之間共享信息的所有方法:
服務(wù)器
顯然,擁有服務(wù)器可以直接簡化問題。但是,由于這個項目需要在不使用服務(wù)器的情況下實現(xiàn),所以直接pass這個選項。
本地存儲
本地存儲本質(zhì)上是瀏覽器鍵值存儲,通常用于在瀏覽器會話之間持久保存信息。雖然本地存儲常用于存儲身份驗證令牌或重定向URL,但可以存儲任何可序列化的內(nèi)容。
這里重點要介紹一個非常有趣的本地存儲API,storage事件——每當(dāng)本地存儲由于同一網(wǎng)站上的另一個會話而更改時,就會觸發(fā)事件。
圖片
我靈機一動試著在本地存儲中存儲每個窗口的狀態(tài),每當(dāng)有窗口的狀態(tài)改變時,其他窗口就通過storage事件進行更新。
對頭,似乎解決方案就是這個了。
那么代碼可以解決這個問題嗎?到底有沒有其他方法呢?答案是:有!
共享worker
在這個華麗的術(shù)語背后,我們需要先了解WebWorkers的概念。
簡單來說,worker其實是在另一個線程上運行的第二個腳本。雖然因為存在于HTML文檔之外導(dǎo)致worker無權(quán)訪問 DOM,但worker仍然可以與主腳本進行通信。
worker主要用于通過處理后臺作業(yè),例如預(yù)獲取信息或處理不太重要的任務(wù)(如流式日志和輪詢),來卸載主腳本。
圖片
共享worker是一種特殊的WebWorker,它可以與同一腳本的多個實例進行通信,可以將信息發(fā)送到同一腳本的多個會話!
圖片
設(shè)置worker
如前所述,worker是“第二個腳本”。我們需要根據(jù)設(shè)置(TypeScript、捆綁器、開發(fā)服務(wù)器),調(diào)整tsconfig、添加指令或使用特定的導(dǎo)入語法。
在眾多使用WebWorker的方法中,就我而言,我喜歡使用Vite和TypeScript,所以需要一個worker.ts文件并將@types/sharedworker安裝為開發(fā)依賴項。
我們可以使用以下語法在主腳本中創(chuàng)建連接:
new SharedWorker(new URL("worker.ts", import.meta.url));
整個過程就是:
1.識別每個窗口
2.跟蹤所有窗口狀態(tài)
3.在窗口改變狀態(tài)時提醒其他窗口重新繪制
狀態(tài)也是非常簡單:
type WindowState = {
screenX: number; // window.screenX
screenY: number; // window.screenY
width: number; // window.innerWidth
height: number; // window.innerHeight
};
當(dāng)然,最關(guān)鍵的信息是window.screenX和window.screenY,因為需要它們告訴我們窗口相對于顯示器左上角的位置。
我們還需要提供兩種類型的消息:
1.每當(dāng)有窗口改變狀態(tài),發(fā)布帶有新狀態(tài)的windowStateChangedmessage。
2.Worker需要向所有其他窗口發(fā)送更新,提醒有窗口已發(fā)生更改。Worker還需要發(fā)送包含所有窗口狀態(tài)的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的基本連接如下所示。這里我通過基本函數(shù)生成id,并計算當(dāng)前窗口狀態(tài)。我還對可以使用的稱為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();
一啟動應(yīng)用程序,就得提醒worker有新窗口,所以我們要立即發(fā)送消息:
// main.ts
sharedWorker.port.postMessage({
action: "windowStateChanged",
payload: {
id,
newWindow: currentWindow,
},
} satisfies WorkerMessage);
我們可以在worker端監(jiān)聽此消息,并相應(yīng)地更改onmessage。這個流程就是,一旦worker收到windowStateChanged消息,那么意味著要么是有新窗口,需要將其附加到狀態(tài),要么舊窗口已發(fā)生更改。因此需要提醒大家,狀態(tài)已發(fā)生了改變:
// 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;
}
}
};
發(fā)送同步需要一些技巧,因為port屬性無法序列化,所以我將其字符串化再解析回來。
w.port.postMessage({
action: "sync",
payload: { allWindows: JSON.parse(JSON.stringify(windows)) },
} satisfies WorkerMessage);
接下來就是繪圖了!
有趣的部分:繪圖!
復(fù)雜的3D球體就饒了我吧,我只打算在每個窗口的中心畫一個圓圈,然后再在球體之間畫一條線意思意思!
我使用HTML Canvas的基本2D上下文進行繪制,當(dāng)然你也可以使用其他方式繪制。反正就是畫圓圈,非常簡單:
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();
};
至于畫線,就需要做一些數(shù)學(xué)運算了——將另一個窗口中心的相對位置轉(zhuǎn)換為當(dāng)前窗口上的坐標(biāo)。
首先改變基準(zhǔn)使顯示器具有坐標(biāo),并根據(jù)當(dāng)前窗口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;
};
看,同一個相對坐標(biāo)系上有兩個點,可以畫線了!
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();
};
現(xiàn)在,我們只需要對狀態(tài)變化做出響應(yīng)。
// 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,
});
});
}
}
};
最后一步,我們只需要定期檢查窗口是否更改,如果更改則發(fā)送消息即可。
setInterval(() => {
const newWindow = getCurrentWindowState();
if (
didWindowChange({
newWindow,
oldWindow: currentWindow,
})
) {
sharedWorker.port.postMessage({
action: "windowStateChanged",
payload: {
id,
newWindow,
},
} satisfies WorkerMessage);
currentWindow = newWindow;
}
}, 100);
實際上,我試驗了很多次,以便更抽象更有科幻感,但總而言之要點都是一樣的。
如果一切順利,最后我們可以得到這樣炫酷的結(jié)果!
感謝閱讀!