1. 前言
最近看到下面这个,多窗口下实现量子纠缠的特效感觉很惊艳和有创意。
除 Three.js 的特效果部分,技术实现上还是很简单的。
2. 技术点分析
这里面核心两个技术点:
- 不同 Tab 窗口下的消息通讯。
- 窗口的位置获取。
2.1 Tab 窗口通讯
比较常用的有两种方式:
- Storage API 的 StorageEvent。
- BroadcastChannel API。
2.1.1 StorageEvent 实现
通过设置 localStorage/sessionStorage 的 setItem 可以在其他同源窗口下触发 StorageEvent 事件,实现广播效果:
[Exposed=Window]
interface StorageEvent : Event {
constructor(DOMString type, optional StorageEventInit eventInitDict = {});
readonly attribute DOMString? key;
readonly attribute DOMString? oldValue;
readonly attribute DOMString? newValue;
readonly attribute USVString url;
readonly attribute Storage? storageArea;
undefined initStorageEvent(DOMString type, optional boolean bubbles = false, optional boolean cancelable = false, optional DOMString? key = null, optional DOMString? oldValue = null, optional DOMString? newValue = null, optional USVString url = "", optional Storage? storageArea = null);
};
dictionary StorageEventInit : EventInit {
DOMString? key = null;
DOMString? oldValue = null;
DOMString? newValue = null;
USVString url = "";
Storage? storageArea = null;
};
演示:
<button id="a">我的位置</button>
<script>
window.onstorage = (e) => {
const { key, newValue, oldValue, url } = e;
console.log(
`key: ${key}, newValue: ${newValue}, oldValue: ${oldValue}, url: ${url}`,
e
);
};
a.addEventListener("click", () => {
localStorage.setItem(
"position",
JSON.stringify({
x: Math.random(),
y: Math.random(),
})
);
});
</script>
兼容性:
2.1.2 BroadcastChannel 实现
通过创建同名频道进行通讯。
[Exposed=(Window,Worker)]
interface BroadcastChannel : EventTarget {
constructor(DOMString name);
readonly attribute DOMString name;
undefined postMessage(any message);
undefined close();
attribute EventHandler onmessage;
attribute EventHandler onmessageerror;
};
演示:
<button id="a">我的位置</button>
<script>
const channel = new BroadcastChannel("my_channel");
channel.onmessage = (e) => {
console.log("收到广播", e.data);
};
a.addEventListener("click", () => {
console.log("发送广播");
channel.postMessage({ x: 100, y: 200 });
});
</script>
兼容性:
2.2 位置获取
通过获取浏览器窗口在屏幕的位置+内外宽高,就可以获取到绝对的位置。
const absoluteCenter = {
x:
window.screenX +
(window.outerWidth - window.innerWidth) +
window.innerWidth / 2,
y:
window.screenY +
(window.outerHeight - window.innerHeight) +
window.innerHeight / 2,
};
3. 实现
下面我基于 BroadcastChannel 实现一个示例。
实现不同窗口间,根据其他窗口自行创建箭头,并实时指向彼此:
通讯间需要定义清楚消息类型:
const MSG_TYPE = {
POSITION: 0,
NEW: 1,
REMOVE: 2,
OTHER: 3,
};
如果要兼容浏览器崩溃下无法发送移除消息,可以加入喂狗程序:
function sendHeartbeat() {
channel.postMessage({ type: "heartbeat", pageId: wId });
lastHeartbeat = Date.now();
}
setInterval(function () {
if (Date.now() - lastHeartbeat > heartbeatInterval * 2) {
console.log(wId + "离线");
}
}, heartbeatInterval);
完整代码:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
body,
html {
height: 100%;
}
.arrow {
width: 80vmin;
height: 80vmin;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(0deg);
}
.arrow::after {
content: "";
display: block;
width: 100vmax;
height: 3px;
background-color: red;
position: absolute;
top: 50%;
right: 50%;
transform: translateY(-50%);
}
.arrow img {
width: 100%;
height: 100%;
}
#center {
width: 30px;
height: 30px;
border-radius: 50%;
z-index: 999;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
#msg {
position: absolute;
left: 1em;
top: 1em;
}
</style>
</head>
<body>
<div id="center"></div>
<div id="msg"></div>
<script>
const otherWindows = new Map();
const channel = new BroadcastChannel("tab_position_channel");
const wId = Date.now() + "-" + Math.random();
const color =
"#" + Math.floor(Math.random() * parseInt("ffff", 16)).toString(16);
let _new = true;
const MSG_TYPE = {
POSITION: 0,
NEW: 1,
REMOVE: 2,
OTHER: 3,
};
const lastPosition = {
x: 0,
y: 0,
};
window.onunload = () => {
channel.postMessage({
type: MSG_TYPE.REMOVE,
wId,
});
};
channel.onmessage = ({ data: { type, x, y, wId: otherId } }) => {
console.log(`type: ${type}, x: ${x}, y: ${y}, wId: ${wId}`);
switch (type) {
case MSG_TYPE.NEW:
_AddWin({ otherId, x, y });
channel.postMessage({
type: MSG_TYPE.OTHER,
x: lastPosition.x,
y: lastPosition.y,
wId,
});
break;
case MSG_TYPE.POSITION:
otherWindows.set(otherId, {
x,
y,
});
[...otherWindows.keys()].forEach((otherId) => {
const arrow = document.getElementById(otherId);
const { x, y } = otherWindows.get(otherId);
arrow.style.transform = `translate(-50%, -50%) rotate(${
(Math.atan2(lastPosition.y - y, lastPosition.x - x) * 180) /
Math.PI
}deg)`;
});
break;
case MSG_TYPE.OTHER:
if (!otherWindows.has(otherId)) {
_AddWin({ otherId, x, y });
}
break;
case MSG_TYPE.REMOVE:
otherWindows.delete(otherId);
document.getElementById(otherId)?.remove();
break;
}
};
function _AddWin({ otherId, x, y }) {
otherWindows.set(otherId, {
x,
y,
});
const arrow = document.createElement("div");
arrow.className = "arrow";
arrow.id = otherId;
arrow.style.transform = `translate(-50%, -50%) rotate(${
(Math.atan2(lastPosition.y - y, lastPosition.x - x) * 180) / Math.PI
}deg)`;
arrow.innerHTML = `<img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%221000%22%20height%3D%221000%22%3E%3Cpath%20fill%3D%22currentColor%22%20fill-opacity%3D%22.8%22%20d%3D%22M1005%20530.682H217.267v-61.364H1005v61.363z%22%2F%3E%3Cpath%20fill%3D%22currentColor%22%20fill-opacity%3D%22.8%22%20d%3D%22M217.989%20581.415%204%20503.552l213.989-79.967z%22%2F%3E%3C%2Fsvg%3E" />`;
document.body.appendChild(arrow);
}
function render() {
requestAnimationFrame(() => {
const absoluteCenter = {
x:
window.screenX /* + (window.outerWidth - window.innerWidth)*/ +
window.innerWidth / 2,
y:
window.screenY +
(window.outerHeight - window.innerHeight) +
window.innerHeight / 2,
};
if (
absoluteCenter.x !== lastPosition.x ||
absoluteCenter.y !== lastPosition.y
) {
lastPosition.x = absoluteCenter.x;
lastPosition.y = absoluteCenter.y;
if (_new) {
_new = false;
channel.postMessage({
type: MSG_TYPE.NEW,
x: lastPosition.x,
y: lastPosition.y,
wId,
});
} else {
channel.postMessage({
type: MSG_TYPE.POSITION,
x: absoluteCenter.x,
y: absoluteCenter.y,
wId,
});
}
otherWindows.forEach(({ x, y }, otherId) => {
const angle =
(Math.atan2(lastPosition.y - y, lastPosition.x - x) * 180) /
Math.PI;
document.getElementById(
otherId
).style.transform = `translate(-50%, -50%) rotate(${angle}deg)`;
});
}
msg.innerHTML =
otherWindows.size === 0
? "未发现其他窗口"
: `发现窗口: <ol>${[...otherWindows.keys()]
.map((it) => `<li>${it}</li>`)
.join("")}</ol>`;
render();
});
}
render();
center.style.backgroundColor = color;
</script>
</body>
</html>
在线效果:https://lecepin.github.io/transfer-across-tabs-by-BroadcastChannel/
Github 项目地址: https://github.com/lecepin/transfer-across-tabs-by-BroadcastChannel
原文地址: https://github.com/lecepin/blog/blob/main/%E5%A4%9A%E7%AA%97%E5%8F%A3%E9%87%8F%E5%AD%90%E7%BA%A0%E7%BC%A0%E6%8A%80%E6%9C%AF%E5%AE%9E%E7%8E%B0.md