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