窗口间通信方案对比

161 阅读8分钟

引言

纯前端版本,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);
});

有几个点需要注意

  1. 需要借助 window.open 来获取另一个页面的引用,否则需要借助 websocket
  2. 发送方不能直接 postMessage,因为页面的渲染本身是一个异步行为,需要在 57890 页面中的 onload 事件中,发送一次确认消息,后续才可以进行正常的通信,否则不会有任何打印,正常来说,会有两次打印,第一次是握手,第二次是正式通信,如下图所示

暂时无法在飞书文档外展示此内容

4、localStorage

特点:使用 storage 事件来完成通信,只能同源,注意有大小限制,一般 5M,需要手动管理其内存

// 发送方
localStorage.setItem('key', value)

// 接收方
window.addEventListener('storage', (e) => {
    console.log(e.oldValue)
    console.log(e.newValue)
})

总结

优点缺点
Broadcast ChannelAPI 简单,易于理解和使用,不需要手动管理连接,自动处理连接和断开不是所有浏览器都支持 ,不能跨域通信
SharedWorker多个脚本可以共享同一个工作线程,提高资源利用率,可以减少资源消耗和提高性能相对于 Broadcast ChannelSharedWorker 的实现和管理更复杂,首次创建 SharedWorker 时可能有一定的初始化时间
postMessage可以在不同窗口、不同域之间进行通信,几乎所有现代浏览器都支持 postMessage需要仔细处理消息来源,防止跨站脚本攻击(XSS)
localStorage数据会持久保存在用户的浏览器中,即使关闭浏览器后仍然存在,API 简单,易于使用大多数浏览器对 localStorage 的存储大小有限制(通常为 5MB),大量数据操作可能影响性能,setItem 是同步方法,会阻塞主线程

小游戏

使用上述知识点,完成一个扑克牌的拖动联动效果,最终效果如下,完整代码

梳理下需要实现的技术点

  1. 元素的拖动
  2. 坐标系转换
  3. 多窗口通信
  • 先来实现第一个技术点,其实元素的拖动可以理解为,需要实时得出鼠标从按下到抬起,所移动的距离,然后将这个距离同步到元素上,就完成了元素的拖动,核心事件有三个,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();