将iframe的postmessage通信方式封装成promise形式

592 阅读2分钟

背景

到 iframe 虽然存在诸多弊病,但在开发程序的过程中,有时候还是不可避免的需要使用到 iframe。

对于 iframe 父子窗口通信,其中一种比较常用的方式就是 postMessage,且跨域的时候也只有这种办法能用得上。

但这种通信方式是基于事件的发送和监听实现的,使用的是回调的方式。

如果业务场景中期望“父窗口往子窗口发消息后,要堵塞等待子窗口的返回,再去执行接下来的逻辑”,直接使用这种回调的方式就实现不了。

封装思路

由于本人主要是用 vue 开发前端项目,联想到 vue 中有一个 $once 函数,可以进行一个一次性的事件监听。那么可以尝试把这个特性利用起来。(实际上,如果不是 vue 技术栈,也可以用其他的第三方库或者自己封装出一个 $once,这里为了图省事就用 vue 自带的 api 了)

父窗口的逻辑:

  1. 监听 message 事件,如果收到子窗口发来的消息,就调用this.$emit() 触发一次自定义事件。
  2. 调用 postMessage() 给子窗口发消息,发送之后使用 this.$once() 监听一次自定义事件。

子窗口的逻辑:

  1. 监听 message 事件,如果收到父窗口发来的消息,就调用postMessage 给父窗口回信。

可以看到大致思路:在 message 事件之外,又使用了一个自定义事件,作为中间层。

代码实现

父窗口逻辑:

<template>
  <div>
    <div>
      <h1>这里是父窗口</h1>
      <button @click="handleClick">点这里给子窗口发消息</button>
    </div>

    <iframe id="iframe" src="/#chilren"></iframe>
  </div>
</template>

<script>
export default {
  methods: {
    async handleClick() {
      let resp = await this.sendAndGetResponse();
      console.log(resp);
    },

    // 发送数据给子窗口
    sendAndGetResponse() {
      let targetWindow = document.getElementById("iframe").contentWindow;

      // 用这个来标识父子窗口之间的每一次会话,同时也用作事件名
      let requestId = `request_${new Date().getTime()}`;

      // 父窗口给子窗口发消息
      let requestMsg = {
        requestId: requestId,
        params: {
          msg: "父窗口发给子窗口的消息......",
        },
      };
      targetWindow.postMessage(requestMsg, "*");

      // 超时的Promise
      let timeOutPromise = new Promise((resolve, reject) => {
        setTimeout(() => {
          reject("超时");
        }, 500);
      });

      // 正常的Promise
      let normalPromise = new Promise((resolve, reject) => {
        this.$once(requestId, (responseMsg) => {
          if (responseMsg.resCode == "0") {
            resolve(responseMsg.result);
          } else {
            reject(responseMsg.resMsg || "返回值格式错误");
          }
        });
      });

      // 用Promise.race进行超时处理
      return Promise.race([timeOutPromise, normalPromise]);
    },

    receiveMessage(event) {
      let responseMsg = event.data;
      if (!responseMsg.requestId) {
        return;
      }

      this.$emit(responseMsg.requestId, responseMsg);
    },
  },

  mounted() {
    window.addEventListener("message", this.receiveMessage, false);
  },
};
</script>


子窗口逻辑:

<template>
  <div>
    <h1>这里是子窗口</h1>
  </div>
</template>

<script>
export default {
  methods: {
    receiveMessage(event) {
      let requestMsg = event.data;

      let responseMsg = {
        requestId: requestMsg.requestId,
        resCode: "0",
        resMsg: "成功",
        result: {},
      };

      event.source.postMessage(responseMsg, event.origin);
    },
  },
  mounted() {
    window.addEventListener("message", this.receiveMessage, false);
  },
};
</script>

代码中的几处细节:

  1. 考虑到子窗口有可能无响应的情况,加了一个超时处理,防止父窗口的 await 长时间堵塞等待。
  2. 跨窗口通信无法直接传递异常,所以把消息对象进行了封装,加了状态码的字段用来区分正常返回还是异常返回。
  3. 实际使用中需要关注一下 postmessage 的安全问题,在发送和接收消息的时候要对通信目标进行过滤。这里为了代码逻辑简单就没做这个事情。