引言
纯前端版本,websocket 不算
结构化克隆算法
对于以下通信方案中的数据传递,内部会使用结构化克隆算法来处理,以下是不能处理的情况
-
Function对象是不能被结构化克隆算法复制的;如果你尝试这样子去做,这会导致抛出DATA_CLONE_ERR的异常。 -
企图去克隆 DOM 节点同样会抛出
DATA_CLONE_ERR异常。 -
对象的某些特定参数也不会被保留
-
RegExp对象的lastIndex字段不会被保留 -
属性描述符,setters 以及 getters(以及其他类似元数据的功能)同样不会被复制。例如,如果一个对象用属性描述符标记为 read-only,它将会被复制为 read-write,因为这是默认的情况下。
-
原形链上的属性也不会被追踪以及复制。
-
1、Broadcast Channel
// 发送方
const channel = new BroadcastChannel('myChannel');
// 发送消息,下面是一个测试数据
const obj = {
a: 1,
b: "string",
c: true,
d: {
e: 2,
},
g: null,
h: undefined,
i: Symbol(),
f: function () {
console.log("channel");
},
};
obj.__proto__.getName = () => {
console.log('name');
};
Object.defineProperty(obj, 'j', {
get() {
return 'getter'
},
set(val) {
console.log('setter', val)
}
})
obj.k = obj;
channel.postMessage(obj);
// 接收方,需要定义一个同名的 channel
const channel = new BroadcastChannel('myChannel');
// 监听消息事件
channel.onmessage = function(event) {
console.log('Received message:', event.data);
};
// 关闭连接
channel.close();
特点:适用于发送轻量消息,无状态,自带广播机制,不支持 IE
2、SharedWorker
// 发送方
// 创建一个 SharedWorker
const worker = new SharedWorker('worker.js');
// 发送消息
worker.port.postMessage('Hello from Tab 1');
// worker.js
// self 是 Shared Worker 脚本中的全局对象,提供了处理连接请求、消息传递和其他工作线程相关操作的方法和属性
// 通过 self,你可以在 Shared Worker 中实现复杂的逻辑和任务处理,并与多个客户端进行通信
self.onconnect = function(event) {
const port = event.ports[0];
// 监听消息事件
port.onmessage = function(event) {
console.log('Received message:', event.data);
};
// 发送消息
port.postMessage('Hello from Worker');
};
// 可以改造成广播形式
const connections = [];
onconnect = function (event) {
const port = event.ports[0];
connections.push(port);
port.onmessage = function (event) {
connections.forEach(function (conn, connName) {
if (connName !== port.name) {
conn.postMessage(event.data);
}
});
};
};
特点:必须创建一个 worker.js 文件,适合复杂计算场景,或者需要多页面之间共享状态
调试方式:打开 chrome://inspect/#workers 进行调试
3、postMessage
通常来说,跨窗口通信需要同源的前提,但是 postMessage 却可以突破这个限制,基础用法如下
otherWindow.postMessage(message, targetOrigin, [transfer]);
通过窗口的 origin 属性来指定哪些窗口能接收到消息事件,其值可以是字符串"*"(表示无限制)或者一个 URI。在发送消息的时候,如果目标窗口的协议、主机地址或端口这三者的任意一项不匹配 targetOrigin 提供的值,那么消息就不会被发送;只有三者完全匹配,消息才会被发送。这个机制用来控制消息可以发送到哪些窗口;例如,当用 postMessage 传送密码时,这个参数就显得尤为重要,必须保证它的值与这条包含密码的信息的预期接受者的 origin 属性完全一致,来防止密码被恶意的第三方截获。**如果你明确的知道消息应该发送到哪个窗口,那么请始终提供一个有确切值的 targetOrigin,而不是 *** 。不提供确切的目标将导致数据泄露到任何对数据感兴趣的恶意站点
如果你不希望从其他网站接收 message,请不要为 message 事件添加任何事件侦听器。 这是一个完全万无一失的方式来避免安全问题,即使与信任站点进行通信,也要始终保持对 origin 的校验
以下是一个例子,需要准备两个不同源的页面,可以使用脚手架来搭建项目,我这里使用了 serve 来启动
// localhost:3000
const message = "Hello from localhost:3000!";
const targetOrigin = "http://localhost:58970";
// 打开目标窗口
targetWindow = window.open(targetOrigin, "_blank");
// 监听来自目标窗口的确认消息
window.addEventListener("message", function (event) {
if (event.origin !== targetOrigin) {
return;
}
if (event.data === "Ready to receive messages") {
// 发送消息
targetWindow.postMessage(message, targetOrigin);
}
});
// 监听来自接收方的响应
window.addEventListener("message", function (event) {
if (event.origin !== targetOrigin) {
return;
}
console.log("Received response:", event.data);
});
// localhost:58970
const source = "http://localhost:3000";
window.addEventListener("load", function () {
// 发送确认消息
window.opener.postMessage("Ready to receive messages", source);
});
// 监听 message 事件
window.addEventListener("message", function (event) {
if (event.origin !== source) return
// 发送响应
event.source.postMessage("Response from localhost:58970!", event.origin);
});
有几个点需要注意
- 需要借助 window.open 来获取另一个页面的引用,否则需要借助 websocket
- 发送方不能直接 postMessage,因为页面的渲染本身是一个异步行为,需要在 57890 页面中的 onload 事件中,发送一次确认消息,后续才可以进行正常的通信,否则不会有任何打印,正常来说,会有两次打印,第一次是握手,第二次是正式通信,如下图所示
暂时无法在飞书文档外展示此内容
4、localStorage
特点:使用 storage 事件来完成通信,只能同源,注意有大小限制,一般 5M,需要手动管理其内存
// 发送方
localStorage.setItem('key', value)
// 接收方
window.addEventListener('storage', (e) => {
console.log(e.oldValue)
console.log(e.newValue)
})
总结
| 优点 | 缺点 | |
|---|---|---|
| Broadcast Channel | API 简单,易于理解和使用,不需要手动管理连接,自动处理连接和断开 | 不是所有浏览器都支持 ,不能跨域通信 |
| SharedWorker | 多个脚本可以共享同一个工作线程,提高资源利用率,可以减少资源消耗和提高性能 | 相对于 Broadcast Channel,SharedWorker 的实现和管理更复杂,首次创建 SharedWorker 时可能有一定的初始化时间 |
| postMessage | 可以在不同窗口、不同域之间进行通信,几乎所有现代浏览器都支持 postMessage | 需要仔细处理消息来源,防止跨站脚本攻击(XSS) |
| localStorage | 数据会持久保存在用户的浏览器中,即使关闭浏览器后仍然存在,API 简单,易于使用 | 大多数浏览器对 localStorage 的存储大小有限制(通常为 5MB),大量数据操作可能影响性能,setItem 是同步方法,会阻塞主线程 |
小游戏
使用上述知识点,完成一个扑克牌的拖动联动效果,最终效果如下,完整代码

梳理下需要实现的技术点
- 元素的拖动
- 坐标系转换
- 多窗口通信
- 先来实现第一个技术点,其实元素的拖动可以理解为,需要实时得出鼠标从按下到抬起,所移动的距离,然后将这个距离同步到元素上,就完成了元素的拖动,核心事件有三个,mousedown,mouseover,mouseleave,初步代码如下
const img = document.getElementById("poker");
let drag = false
// 保存鼠标的初始位置
let initialPosition = { x: 0, y: 0 };
// 保存元素的初始位置
let currentPosition = { x: 0, y: 0 };
const startDrag = (e) => {
drag = true;
initialPosition = { x: e.clientX, y: e.clientY };
const { left, top } = img.getBoundingClientRect();
currentPosition = { x: left, y: top };
img.style.position = "absolute"; // 确保元素可以被绝对定位
img.style.zIndex = 1000; // 确保元素在最上层
}
const dragMove = (e) => {
if (!drag) return;
// 通过当前位置减去上一次的鼠标位置,得出偏移量
const moveX = e.clientX - initialPosition.x;
const moveY = e.clientY - initialPosition.y;
// 元素当前位置,加上偏移量,得出最终位置,使用 transform,单独图层渲染,提高性能
const newX = currentPosition.x + moveX;
const newY = currentPosition.y + moveY;
img.style.transform = `translate(${newX}px, ${newY}px)`;
};
const endDrag = () => {
drag = false;
img.style.zIndex = ""; // 恢复默认 z-index
};
img.addEventListener("mousedown", startDrag);
img.addEventListener("mousemove", dragMove);
img.addEventListener("mouseup", endDrag);
看上去好像没什么问题,但是细心的小伙伴会发现,实际使用时,会有一个交互问题,如果鼠标拖动速度过快,指针就会离开元素内部,导致拖拽状态丢失,这也很正常,因为目前的代码实现,会在 mouseup 时,结束拖动事件,想要改善这个问题,在监听 mousemove 和 mousedown 时,不能再监听被拖动的元素,而是要监听全局的拖动事件,因为有 drag 变量的控制,所以不会影响其他的逻辑,需要修改的部分如下
window.addEventListener("mousemove", dragMove);
window.addEventListener("mouseup", endDrag);
其实到这里,已经实现了元素的丝滑拖拽,但是还发现一个问题,由于测试元素为 img 元素,实际在使用时,会拖拽出一个影子,先来看看 MDN 的描述
在 HTML 中,除了图像、链接和选择的文本默认的可拖拽行为之外,其他元素在默认情况下是不可拖拽的
draggable 属性可在任意元素上设置,包括图像和链接。然而,对于后两者,该属性的默认值是 true,所以你只会在禁用这二者的拖拽时使用到 draggable 属性,将其设置为 false
可以得知,上面的现象其实是 drag 事件的默认行为,有两种方法解决,在 img 标签上声明 draggable="false" ,或者在 mousedown 时,阻止事件的默认行为,至此,第一点已经解决
- 接下来解决第二点,如何完成坐标系转换,因为这个效果实现的基础,就是所有卡片的位置需要相同,那么参考系就是屏幕,而不是视口
暂时无法在飞书文档外展示此内容
由图可得坐标转换函数,代码如下
// 屏幕坐标转换成窗口内部坐标
function screenToViewport(x, y) {
return [x - window.screenX, y - window.screenY - windowBarHeight()];
}
// 窗口内部坐标转换成屏幕坐标
function viewportToScreen(x, y) {
return [x + window.screenX, y + window.screenY + windowBarHeight()];
}
// 计算浏览器上方导航条的高度
function windowBarHeight() {
return window.outerHeight - window.innerHeight;
}
- 接下要实现窗口间通信,本文采用 BroadcastChannel 来实现,在 mouseover 时通信,同步每个窗口的元素位置,代码如下
channel.onmessage = (e) => {
const [x, y] = screenToViewport(...e.data);
img.style.transform = `translate(${x}px, ${y}px)`;
};
// 关闭 BroadcastChannel 以释放资源
window.addEventListener("beforeunload", () => {
channel.close();
});
const dragMove = (e) => {
// ...
channel.postMessage(viewportToScreen(newX, newY));
};
// index.html
<img id="poker"/>
<script src="./js/index.js"></script>
// index.js
const img = document.getElementById("poker");
const setImgByQuery = () => {
const url = new URL(window.location.href);
const queryObj = new URLSearchParams(url.search);
const imgName = queryObj.get("type").toUpperCase();
img.src = `./images/${imgName}.png`;
};
const channel = new BroadcastChannel("poker-channel");
channel.onmessage = (e) => {
const [x, y] = screenToViewport(...e.data);
img.style.transform = `translate(${x}px, ${y}px)`;
};
// 关闭 BroadcastChannel 以释放资源
window.addEventListener("beforeunload", () => {
channel.close();
});
let drag = false;
let initialPosition = { x: 0, y: 0 };
let currentPosition = { x: 0, y: 0 };
const startDrag = (e) => {
e.preventDefault()
drag = true;
initialPosition = { x: e.clientX, y: e.clientY };
const { left, top } = img.getBoundingClientRect();
currentPosition = { x: left, y: top };
img.style.position = "absolute"; // 确保元素可以被绝对定位
img.style.zIndex = 1000; // 确保元素在最上层
};
const dragMove = (e) => {
if (!drag) return;
const moveX = e.clientX - initialPosition.x;
const moveY = e.clientY - initialPosition.y;
const newX = currentPosition.x + moveX;
const newY = currentPosition.y + moveY;
img.style.transform = `translate(${newX}px, ${newY}px)`;
channel.postMessage(viewportToScreen(newX, newY));
};
const endDrag = () => {
drag = false;
img.style.zIndex = ""; // 恢复默认 z-index
};
// 屏幕坐标转换成窗口内部坐标
function screenToViewport(x, y) {
return [x - window.screenX, y - window.screenY - windowBarHeight()];
}
// 窗口内部坐标转换成屏幕坐标
function viewportToScreen(x, y) {
return [x + window.screenX, y + window.screenY + windowBarHeight()];
}
// 计算浏览器上方导航条的高度
function windowBarHeight() {
return window.outerHeight - window.innerHeight;
}
const main = () => {
img.addEventListener("mousedown", startDrag);
document.addEventListener("mousemove", dragMove);
document.addEventListener("mouseup", endDrag);
setImgByQuery();
};
main();