如何与 iframe 进行通信呢?

468 阅读9分钟

HTML内联框架元素 <iframe> 用于在HTML文档中嵌入另一个HTML文档,提供了一种方便的方式来集成外部内容。

虽然<iframe>会存在一些安全、性能等方面的问题,但日常开发中<iframe>也还是会经常用于一些外部内容的集成。例如:

  • 一些后台管理系统会搭建一个外部的壳子,然后通过注册菜单的形式添加新的功能,而每个新功能的页面则是以iframe的形式嵌入到这个壳子中。
  • H5 开发中某些 App 的 用户服务协议、隐私政策等也常会采用 iframe 的形式嵌入到 App 中。
  • ……

近期也是在做 H5 开发过程中接到这样两个需求:

  1. 需求1:用户服务协议页面主题 App 主题保持一致
  2. 需求2:用户服务协议页面中的官网链接点击后能够调用手机默认浏览器打开

这两个需求看上去实现并不困难,但是关键在于这个页面是通过 <iframe> 的形式嵌入的,与主页面的不同,主页面的脚本无法直接访问 <iframe> 中的内容,进行一些交互。

那么如何和iframe嵌入的页面进行通信呢?本文将围绕这个问题展开一些探索。

示例代码

假设用户服务协议页面代码如下,该页面有明暗两个主题,通过CSS变量来实现。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>用户服务协议</title>
    <style>
        body.light {
            --main-bg-color: #ffffff;
            --main-font-color: #000000;
        }

        body.dark {
            --main-bg-color: #000000;
            --main-font-color: #ffffff;
        }

        .main {
            width: 400px;
            height: 100px;
            border: 1px solid #000;
            background-color: var(--main-bg-color);
            color: var(--main-font-color);
        }
    </style>
</head>
<body class="user-service-aggrement light">
    <div class="main">
        <p>用户服务协议</p>
        <p> 
            官网地址:
            <a>https://xxx.cn</a>
        </p>
    </div>
</body>
</html>

如下是嵌入用户服务协议协议的上层页面:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        iframe {
            width: 100%;
            height: 100%;
        }
    </style>
</head>
<body>
    <iframe src="http://xxx.cn/userServiceAggrement.html"></iframe>
</body>
</html>

image.png

一、URL 查询参数

通过iframe嵌入外部页面时,我们需要给 iframe 元素设置 src 属性,指明被嵌套的页面的 URL 地址。对此我们可以通过 URL查询参数 来给嵌入的页面传递一些信息。

所以,对于对于第一个需求,我们可以通过URL查询参数,将当前 App 的主题色传递给用户服务协议页面。代码如下:

<iframe src="http://127.0.0.1:5500/privacy.html?theme=dark"></iframe>

当然,我们也需要在用户服务协议页面中添加如下脚本,用于读取URL查询参数,获取App的主题:

<script>
    const urlSearchParams = new URLSearchParams(window.location.search);
    const theme = urlSearchParams.get('theme');
    const body = document.querySelector('body.user-service-aggrement');
    body.classList.remove('light', 'dark')
    body.classList.add(theme);
</script>

可以看到,当传递的 theme 参数为 dark时,嵌入的用户服务协议页面切换成了黑色主题。

image.png

通过 URL 查询参数传递信息给 iframe 嵌入的页面,这种方式虽然方便,但是也只适用于主页面向嵌入页面单向传递消息的场景。如果需要主页面与嵌入的页面之间进行双向通信的话,这种方式就无法满足要求了。

例如上文提到的第二个需求,点击官网地址链接需要唤起手机默认浏览器打开。唤起手机默认浏览器,这是app的原生能力,iframe根本无法调用。所以在点击链接后,iframe将官网链接发送给主页面,然后主页面去调用app原生能力唤起手机默认浏览器,然后打开官网地址。

对此,我们引出第二种通信方式:window.postMessage

二、window.postMessage

我们先来看下 MDN 上对于 window.postMessage() 的介绍:

window.postMessage()  方法可以安全地实现跨源通信。

通常,对于两个不同页面的脚本,只有当执行它们的页面位于具有相同的协议、端口号、主机 时,这两个脚本才能相互通信。window.postMessage()  方法提供了一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。

从广义上讲:

  • 一个窗口可以获得对另一个窗口的引用(比如 targetWindow = window.opener),然后在窗口上调用 targetWindow.postMessage() 方法分发一个 MessageEvent 消息。
  • 接收消息的窗口可以根据需要自由处理此事件。传递给 window.postMessage() 的参数(比如 message)将通过消息事件对象暴露给接收消息的窗口。

语法:

otherWindow.postMessage(message, targetOrigin, [transfer]);

参数:

  • otherWindow:其他窗口的一个引用,比如 iframe 的 contentWindow 属性

  • message:将要发送到其他 window 的数据。它将会被结构化克隆算法序列化。这意味着你可以不受什么限制的将数据对象安全的传送给目标窗口而无需自己序列化。

  • targetOrigin:通过窗口的 origin 属性来指定哪些窗口能接收到消息事件,其值可以是字符串*(表示无限制)或者一个 URI。在发送消息的时候,如果目标窗口的协议、主机地址或端口这三者的任意一项不匹配 targetOrigin 提供的值,那么消息就不会被发送;只有三者完全匹配,消息才会被发送。

注意: 如果你明确的知道消息应该发送到哪个窗口,那么请始终提供一个有确切值的 targetOrigin,而不是 *。不提供确切的目标将导致数据泄露到任何对数据感兴趣的恶意站点。

  • transfer:是一串和 message 同时传递的 Transferable对象。这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权

基本使用

  • 发送消息:

发送方需要先获取目标窗口的引用targetWindow,然后调用postMessage方法发送消息,即:

const targetWindow = xxx;
targetWindow.postMessage(message, targetOrigin)
  • 接收消息:

调用postMessage()方法会分发一个 MessageEvent 消息,目标窗口通过监听这个message事件,来接收消息,即:

window.addEventListener("message", function (e) {
    ...
});

其中消息事件(MessageEvent)包含以下属性:

  • MessageEvent.data:来自发送者的数据。
  • MessageEvent.origin:返回一个表示消息发送者来源的USVString
  • MessageEvent.source:代表消息发送者。如果在接收到消息时想立即反馈,可以通过 source.postMessage() 发送回去。

了解了postMessage()的基本使用后,下面我们看看如何通过该方法来实现上述的第二个需求。

  • 首先,我们需要在用户服务协议页面中添加如下点击事件,使得点击后能够将官网地址发送给主页面。
<body class="user-service-aggrement light">
    <div class="main">
        <p>用户服务协议</p>
        <p> 
            官网地址:
            <a  
                href="#"
                onclick="onLinkClick('https://xxx.cn')"
            >
                https://xxx.cn
            </a>
        </p>
    </div>
    
    <script>
        function onLinkClick(url) {
            window.parent?.postMessage({
              type: 'openInBrowser',
              url,
            }, '*');
        }
    </script>
</body>

因为用户服务协议页面是通过iframe嵌入在主页面的,我可以简单理解该页面为主页面的子页面,所以我们可以通过window.parent获得主页面的窗口引用。然后调用postMessage()发送消息。发送的消息可以是任意数据类型的,例子中的type是为了标识消息的类型,告知接收方如何处理该数据,也是为了与其他消息有所区分。

  • 接着我们需要在主页面中监听message事件,接收用户服务协议页面传来的官网地址,然后调用默认浏览器打开:
<script>
    const messageEventHandler = (event) => {
      // 获取消息
      const message = event.data;
      if (message.type === 'openInBrowser') {
        // 手机默认浏览器打开
        BuzPlugin.browser(message.url);
      }
    }
    window.addEventListener('message', messageEventHandler);
</script

上例中我们只展示了嵌入的页面向主页面传递消息,但是window.postMessage()方法是可以进行双向通信的。下面我们不同场景下如何使用 window.postMessage()来传递消息。

2.1 父传子

父传子的话,我们可以通过 contentWindow 访问内联框架的 window 对象。然后调用postMessage() 方法发送信息。

// 获取 iframe 元素(也可通过其他方法获取)
const iframe = document.getElementById('xxx');
// 通过 contentWindow 获取iframe的window对象,调用postMesssage发送信息
iframe.contentWindow.postMessage(data, '*')

此外,我们也可以通过window.frames获取当前窗口的所有直接子窗口。假设,当前页面只嵌入的iframe如下:

<iframe id="user-service-aggrement-iframe" name="usa" src="http://xxx.com"></iframe>

那么我们可以通过索引或name属性来获取指定的子窗口:

// 索引方式:第一个 iframe 的 window 对象
const iframeWindow = window.frames[0];
// name方式:name为'usa'的 iframe 的 window 对象
const iframeWindow = window.frames['usa'];

注意window.frames 获取的是子窗口,而并非 iframe 元素, 即 上述代码中的windows.frames[0]window.frames['usa']iframe.contentWindow 是等价的。

2.2 子传父

子传父的话,在框架内部,我们可以通过 window.parent 引用父窗口对象。然后调用postMessage() 方法发送信息。

window.parent.postMessage(data, '*')

2.3 多级嵌套

假设当前页面嵌入的子 iframe 中也嵌入了子 iframe,那么当前页面如何与孙 iframe 通信呢?

  • 爷 -> 孙

我们可以通过contentDocument 属性获取 <iframe> 内部的 document 元素,然后通过 document 元素获取内部的嵌入的iframe。

const childIframe = document.getElementById('xxx');
const grandChildIframe = childIframe.contentDocument.getElementById('xxx');
grandIframe.contentWindow.postMessage(data, '*')

当然,通过子iframe窗口的frames属性去获取

const childIframeWindow = window.frames[0];
const grandChildIframeWindow = childIframeWindow.frames[0];
grandIframeWindow.postMessage(data, '*')
  • 孙 -> 爷

我们可以通过 window.parent.parent 引用爷窗口对象

window.parent.parent.postMessage(data, '*')

无论嵌套多少层,都可以通过上述方法去处理。

此外,我们还可以通过window.top获取窗口对象层次结构中的最顶层窗口,此属性在处理位于父窗口或多个父窗口的子框架中的窗口时特别有用。

2.4 注意事项

  1. 发送消息时:

    • 尽量给targetOrigin设置确切值,而不是 *。不提供确切的目标可能导致数据泄露到任何对数据感兴趣的恶意站点。
    • 给哪个窗口发送信息,就要先获取该窗口的window对象引用,然后再调用该窗口的postMessage方法
  2. 接收消息时:尽量通过event.origin属性检查消息来源,防止恶意数据造成安全问题。

三、挂载对象到window

开头我们提到某些后台管理系统会搭建一个外部的壳子,然后通过注册菜单的形式添加新的功能,而每个新功能的页面则是以iframe的形式嵌入到这个壳子中。那么壳子肯定会提供一些公用的方法以支持开发,例如:菜单切换等。

那么嵌入的页面如何调用壳子提供的方法呢?

假设,我们在嵌入页面中点击某个按钮后,希望壳子能切换到其他菜单项。通过前面的介绍,你可能会想到子页面中点击按钮后,通过postMessage()向壳子发送一条消息,告诉它要才切换到某个菜单,

window.top.postMessage({
    type: 'switchMenu',  
    menuCode: 'xxx'
}, '*')

但是,这样就需要在壳子中监听message事件,而且发送的消息必须要约定好格式,消息处理不得当还可能发生安全问题。那么,我们该怎么办呢?

还记得前面提到,我们可以通过window.top获取到顶层窗口,既然所有ifrme都是嵌入在该壳子中,那么所有iframe中的window.top指向就是壳子窗口。既然我们可以获取到壳子的 window 对象,那么我们就可以把一些方法挂载到 window 对象上,这样嵌入的iframe就能调用了。

  • 壳子中挂载方法到window对象
window.switchMenu = () => {

}
  • iframe 中调用壳子的方法
window.top.switchMenu()

以上,就是个人对于iframe通信的一些探索,如有不对的地方,请大神们指教。