iframe使用小结

704 阅读4分钟

定义 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
targetOriginiframe 的 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 对应的事件名是messageString
listener回调函数(event)=>{}
useCapture是否使用事件捕获Boolean

onmessageaddEventListener 的区别

onmessageaddEventListener
回调函数的数量1 个


如果注册了多个回调函数,后面注册的回调会覆盖前面注册的回调
可以注册多个回调
取消订阅的方式把保存的 onmessage 返回的引用设为 nullremoveEventListener
type 为 message,listener 和 addEventListener 的 listener 相同(同一个函数的引用)

发布和订阅的顺序

根据 发布订阅模式,应该先 订阅,再 发布,否则消息发送后,回调函数不会执行

场景

下面,结合 2 个场景,来实现 iframe 的通信。

父页面在子页面初始化后,再向子页面发送消息

为什么不能直接发送消息,而是要等待子页面初始化后才发送消息?

由于 iframe 其实是内嵌了一个页面,子页面也会经历 DOM 解析页面渲染 的过程,所以,子页面的初始化是异步的。

正如上面讲的,要先订阅,再发布,如果父页面在子页面还没初始化完毕(还没有订阅事件),就发送了事件,那么这次事件永远被子页面订阅。

如何保证父页面发送消息时,子页面一定初始化完成呢?

有 2 种办法

1. 预估子页面渲染的时间(e.g. 1s),延迟发送消息

优点:实现简单

缺点:

  • 没法保证每次页面都在指定时间内渲染完毕,仍然存在子页面收不到消息的风险
  • 如果延迟过久,会影响用户体验

2. 子页面初始化完成后,向父页面发送消息,父页面收到消息后,再向子页面发送消息

画成 顺序图,就是下图这样:

20240831_175907_image.png

实现代码

子页面

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 个事件:

  1. 子页面向父页面发送初始化完成的 init 事件
  2. 用户点击按钮的事件

这两个事件的先后顺序是不确定的,不过,有一点可以确定,就是当 2 个事件都出现后,才能进行下一步操作,即 父页面向子页面发送 message 事件

画成 UML 活动图,如下图所示

20240831_205054_image.png

实现思路

由于“子页面加载完成”事件在子页面生命周期中只会触发一次,而“用户点击按钮”事件可能会触发多次,所以,可以借鉴 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的域>
  );
});