定义 iframe
在页面中定义一个 iframe
<iframe
id="iframe"
ref="frameRef"
src="<另一个页面的URL地址>"
class="frame"
></iframe>
如果 iframe 的域允许嵌入到别的网站中(通过 X-Frame-Option 来控制),则浏览器会加载 iframe 中的网页内容
发送消息
otherWindow.postMessage(message, targetOrigin, [transfer]);
otherWindow 为 iframe 的 window 对象 在 iframe 外可以通过 iframe 对象的 contentWindow 属性来获取
const otherWindow = document.querySelector("#iframe").contentWindow;
postMessage 的参数如下
| 参数 | 说明 | 类型 |
|---|---|---|
| message | 消息内容,会自动进行深复制,无需序列化 | any |
| targetOrigin | iframe 的 URL 所在的域 | String |
API 的详细说明见 MDN 文档
举例说明
父页面向 iframe 中的页面(子页面)发送消息
const frameRef = ref();
const sub = frameRef.value.contentWindow;
const obj = {
name: "main",
data: "hello, from vue3",
}
sub.postMessage(
obj,
<iframe所在的域>
);
iframe 中的页面(子页面)向父页面发送消息
window.parent.postMessage(
{
name: 'sub',
data: 'hello,from vue2',
},
<父页面的域>,
);
订阅消息
有 2 个订阅事件的函数
onmessage: (event) => {};
addEventListener: (type, listener, useCapture) => {};
参数如下
| 参数 | 说明 | 类型 |
|---|---|---|
| type | 事件名,和postMessage 对应的事件名是message | String |
| listener | 回调函数 | (event)=>{} |
| useCapture | 是否使用事件捕获 | Boolean |
onmessage 和 addEventListener 的区别
| onmessage | addEventListener | |
|---|---|---|
| 回调函数的数量 | 1 个 如果注册了多个回调函数,后面注册的回调会覆盖前面注册的回调 | 可以注册多个回调 |
| 取消订阅的方式 | 把保存的 onmessage 返回的引用设为 null | removeEventListener type 为 message,listener 和 addEventListener 的 listener 相同(同一个函数的引用) |
发布和订阅的顺序
根据 发布订阅模式,应该先 订阅,再 发布,否则消息发送后,回调函数不会执行
场景
下面,结合 2 个场景,来实现 iframe 的通信。
父页面在子页面初始化后,再向子页面发送消息
为什么不能直接发送消息,而是要等待子页面初始化后才发送消息?
由于 iframe 其实是内嵌了一个页面,子页面也会经历 DOM 解析 和 页面渲染 的过程,所以,子页面的初始化是异步的。
正如上面讲的,要先订阅,再发布,如果父页面在子页面还没初始化完毕(还没有订阅事件),就发送了事件,那么这次事件永远被子页面订阅。
如何保证父页面发送消息时,子页面一定初始化完成呢?
有 2 种办法
1. 预估子页面渲染的时间(e.g. 1s),延迟发送消息
优点:实现简单
缺点:
- 没法保证每次页面都在指定时间内渲染完毕,仍然存在子页面收不到消息的风险
- 如果延迟过久,会影响用户体验
2. 子页面初始化完成后,向父页面发送消息,父页面收到消息后,再向子页面发送消息
画成 顺序图,就是下图这样:
实现代码
子页面
window.parent.postMessage(
{
name: 'frameInit',
},
<父页面的域>,
);
父页面
const handleMessage = (evt) => {
if (
evt.origin !==
<iframe的域>
) {
return;
}
if (evt.data.name === "frameInit") {
console.log("收到子窗口的消息:", evt.data);
}
};
window.addEventListener("message", handleMessage, false);
一个更复杂的场景
父页面有个按钮,点击后向子页面发送事件。子页面加载时间较长,需要保证父页面每次发送的事件,子页面都能收到。
分析
父页面打开后,同时存在 2 个事件:
- 子页面向父页面发送初始化完成的 init 事件
- 用户点击按钮的事件
这两个事件的先后顺序是不确定的,不过,有一点可以确定,就是当 2 个事件都出现后,才能进行下一步操作,即 父页面向子页面发送 message 事件
画成 UML 活动图,如下图所示
实现思路
由于“子页面加载完成”事件在子页面生命周期中只会触发一次,而“用户点击按钮”事件可能会触发多次,所以,可以借鉴 rxjs 的事件流 的概念来实现。
两个事件用两个 Subject 对象来表示。
import { combineLatest, Subject } from "rxjs";
const click$ = new Subject();
const frameInit$ = new Subject();
两个事件每次触发时,调用 Subject 对象的 next 函数,发送一次事件流
iframe 渲染完成 事件
const handleMessage = () => {
if (evt.data.name === "frameInit") {
frameInit$.next();
}
};
window.addEventListener("message", handleMessage, false);
按钮点击事件
<el-button @click="sendMessage">发送消息</el-button>
const sendMessage = () => {
click$.next({
name: "main",
data: "hello, from vue3",
});
};
每次当 2 个事件都发生时,发送消息,可以用 combineLatest 操作符 来实现
为什么要使用
combineLatest而不是forkJoin呢?原因是:
两个事件都可以多次触发,每当一个事件再次触发,就执行一次
combineLatest函数而
forkJoin是当两个事件流都结束了,才进行下一步操作。事件流没有结束,就不会进行下一步操作。我们这里的 click 事件流在页面生命周期中是一直存在的,页面销毁时才结束,所以不能用
forkJoin。
const sub = frameRef.value.contentWindow;
combineLatest([click$, frameInit$]).subscribe(([value]) => {
sub.postMessage(
value,
<iframe的域>
);
});