3

众所周知,postMessage 是在不同页面间进行通信的一种常用方式:

window.postMessage() 方法可以安全地实现跨源通信。通常,对于两个不同页面的脚本,只有当执行它们的页面位于具有相同的协议(通常为https),端口号(443为https的默认值),以及主机 (两个页面的模数 Document.domain设置为相同的值) 时,这两个脚本才能相互通信。window.postMessage() 方法提供了一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。

postMessage 提供了底层通信能力,有不少优秀的开源库在此基础上进行了封装,以供开发者更便捷地使用。比如1.4k start 的 postmate 就提供了父页面与 iframe 子页面间基于 Promise 的 postmessage 通信能力封装。

而这篇文章要介绍的是另一个新开源的 postmessage 库:postmessagejs ( npm 包名:postmessage-promise,后文都用此名)。

有人可能会问:用 postmate 不就行了吗,怎么又造一个轮子?看官勿急,且听我细细道来。

一、postMessage

postMessage 分为消息发送方和消息接收方。发送方用如下方式:

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

其中相关的对象和参数说明如下,

  • otherWindow:其他窗口的一个引用,比如 iframe 的 contentWindow 属性、执行window.open 返回的窗口对象、或者是命名过或数值索引的 window.frames。
  • targetOrigin:通过窗口的 origin 属性来指定哪些窗口能接收到消息事件,其值可以是字符串"*"(表示无限制)或者一个 URI。
  • message:将要发送到其他 window 的数据。它将会被结构化克隆算法序列化。这意味着你可以不受什么限制的将数据对象安全的传送给目标窗口而无需自己序列化。

接收方使用 window.addEventListener 方法接收消息:

window.addEventListener("message", receiveMessage, false);
function receiveMessage(event)
{
  var origin = event.origin
  if (origin !== "http://example.org:8080")
    return;
  // ...
}

二、postmessage-promise 缘起

2.1 postMessage 有哪些不便捷的地方?

  1. postMessage 是一次性单向事件,也就是说,发消息的动作做完就不管了,也没有消息响应的概念;
  2. postMessage 未对事件进行分类与管理。这点作为基础底层机制是完全没问题的,只是在业务使用上会有一些不便捷和可能带来副作用。

2.2 postmate 等开源库解决了哪些问题?

postmate 官方描述是:

A powerful, simple, promise-based postMessage iFrame communication library.

它具有以下特点:

  1. 是一个解决父页面与 iFrame 子页面通信的库。
  2. 其中, promise-base ,一方面是指,handshake(连接成功) 后可以使用 then 方法得到暴露的 parent/child 接口;另一方面是指,父页面调用 child.get 方法可以用 then 获取返回的数据。
  3. 内部对事件进行了分类与管理:

    this.parent.postMessage({
      postmate: 'emit',
      type: messageType,
      ...

2.3 postmate 库有哪些不足?

尽管有了 postmate 库,但其中有一些问题未能完全解决:

  1. postmate 只支持 iframe 页面
  2. postmate 只有 child.get 方法支持消息响应(then),并且对 get 的数据内容需要在初始化时就定义好
  3. postmate 建立连接是在 iframe.onload 时开始进行的,进行每500ms一次、最多5次的连接尝试,这在页面逻辑单元准备完成有延迟(如 React/Vue 组件实例化等)的情况下是有问题的。

     if (this.frame.attachEvent) {
      this.frame.attachEvent('onload', loaded)
    } else {
      this.frame.addEventListener('load', loaded)
    }
    1. 消息事件的分类与管理是非特化的,虽然有 postmate 属性进行分隔,但在 postmate 页面间是通用的,所有消息是互通的。这在一些场景下可能是有利的,但在另一些场景下,这可能带来非预期的结果,如消息干扰等。

### 2.4 为何需要 postmessage-promise?

注:postmessage-promise 将消息发起方称为 client(客户端),将消息监听方称为 server(服务端)
  1. 有时候,server 页面的逻辑单元并不是在 Document 加载完成后就能就绪的,所以当逻辑单元就绪时,我们需要一个方法去启动一个监听
  2. 有时候,我们需要等待消息的响应后才能发送下一个消息

三、postmessage-promise 基础

postmessage-promise is a client-server like, WebSocket like, full Promise syntax supported postMessage library.

3.1 特性

  • 支持 iframe 和 window.open 打开的窗口

    • postMessage 本身是支持 iframe 与 window.open 窗口的。postmessage-promise 将窗口对象与功能进行了解耦,称为 serverObject
  • 类 client-server 模式、类 WebSocket 模式

    • postmessage-promise 将消息发起方称为 client(客户端),将消息监听方称为 server(服务端)。连接成功后,client 可以向 server 端发送消息并等待消息响应。同时,server 端也可以主动发消息给 client 端并等待消息响应。
  • client 端

    • 使用 callServer 方法创建一个 server (创建一个iframe或打开一个新窗口),然后尝试连接 server 直到超时。如果需要,你可以用同一个 serverObject 来创建新的 server-caller.
  • server 端

    • 使用 startListening 方法开启一个监听,一个监听只能与一个 client 建立连接。如果需要,你也可以开启多个监听。
  • 全 Promise 支持,ES2017 async await 语法支持

    • 全 Promise 支持指连接成功时和每一条消息发出后,都能用 then 等待消息响应。自然地,client 与 server 都可以使用 async 语法

3.2 如何使用

client (iframe case)

import { callServer, utils } from "postmessage-promise";
const { getIframeServer } = utils;
// 载入 iframe 并返回 serverObject
const iframeRoot = document.getElementById("iframe-root");
const serverObject = getIframeServer(iframeRoot, "/targetUrl", "iname", ['iframe-style']);
// 发起连接
const connection = callServer(serverObject, {});
connection.then(e => {
  // 向 server 发消息
  e.postMessage('getInfo', 'any payload').then(e => {
    console.log("response from server: ", e);
  });
  // 监听来自 server 的消息
  e.listenMessage((method, payload, response) => {
    // 响应 server 的消息
    response('any response to server');
  });
});

async 写法

const asyncCaller = async function () {
  const { postMessage, listenMessage, destroy } = await callServer(serverObject, options);
  const info = await postMessage('getAnyInfo');
  const secondResult = await postMessage('secondPost');
};
asyncCaller();

client (window.open case)

import { callServer, utils } from "postmessage-promise";
const { getOpenedServer } = utils;
// 改为 getOpenedServer 获取
const serverObject = getOpenedServer("/targetUrl");
const options = {}; 
const connection = callServer(serverObject, {});
// ...

server

import { startListening } from "postmessage-promise";
// 开启一个监听
const listening = startListening({});
listening.then(e => {
  // 监听来自 client 的消息
  e.listenMessage((method, payload, response) => {
      // 响应 client 的消息
      response('any response to client');
  });
  // 向 client 发消息
  e.postMessage('toClient', 'any payload').then(e => {
    console.log("response from client: ", e);
  });
});

async 写法

const asyncListening = async function () {
  const { postMessage, listenMessage, destroy } = await startListening(options);
  listenMessage((method, payload, response) => {
    response('anyInfo')
  });
};
asyncListening();

四、postmessage-promise 实现原理与源码解析

下面开始讲实现原理与源码解析,如果你看到了这里,请给 postmessagejs 加星吧。

4.1 建立连接

  • serverObject

    • utils 中内置了 getOpenedServer, getIframeServer 两个辅助方法,返回 serverObject。iframe 的嵌入和新窗口的打开其实都是在这个方法内进行的。
    • serverObject 包含 server, origin, destroy。server 可以是 iframe 的 contentWindow 属性、执行 window.open 返回的窗口对象、或者是命名过或数值索引的 window.frames。
  • client

    • client 在调用 callServer 方法后,先会创建一个 MessageProxy(见下文)。
    • 然后,client 开启一个定时器,每隔100ms发出一次 hand-shake 消息进行连接尝试,直到超时(默认20 * 1000ms)。
    • 当 server 响应并发来 hand-shake 消息时,认为连接成功,并创建一个 MessageChannel(见下文)。
  • server

    • server 在调用 startListening 后才开始监听消息。
    • 在收到第一个 postmessage-promise 的 hand-shake 后,创建一个 MessageProxy 和 一个 MessageChannel。
    • 将此消息的源识别为此信道的 client,并响应一个 hand-shake 消息(由 MessageProxy 发出)。

4.2 消息代理和信道

  • 消息代理(MessageProxy)

MessageProxy 是代理进行收发消息。其中 channelId 是连接成功后的信道 id,双方都在此信道上通信,避免了消息干扰。

  1. 在收消息时,对消息事件进行过滤:
    const listener = function listener(event) {
      if (event.origin !== _this.origin
        || event.source !== _this.source
        || !event.data
        || event.data[IDENTITY_KEY] !== identityMap[_this.type].accept
        || event.data.channelId !== _this.channelId
        || !_this.eventFilter(event)
        || !event.data.method) {
        return;
      }
      const { eventId, method, payload } = event.data;
      fn(method, eventId, payload);
    };
  1. 在发消息时,将信道信息注入:
    this.source.postMessage({
      [IDENTITY_KEY]: identityMap[this.type].key,
      channelId: this.channelId,
      eventId,
      method,
      payload
    }, this.origin);
  • 信道(MessageChannel)

MessageChannel 提供:

  1. postMessage 方法,此方法包含了消息响应的承载(messageResponse):
  postMessage = (method, payload) => {
    return new Promise((resolve, reject) => {
      let ctimer = null;
      const reswrap = value => {
        clearTimeout(ctimer);
        resolve(value);
      };
      const eventId = Math.random().toString().substr(3, 10);
      this.doPost({
        resolve: reswrap, reject, eventId
      }, method, payload);
      ctimer = setTimeout(() => {
        delete this.messageResponse[eventId];
        reject(new Error('postMessage timeout'));
      }, this.timeout || (20 * 1000));
    });
  }
  1. listenMessage 方法,最终在 receiveMessage 中使用 (listener), 主要是区分消息响应和普通消息:
  receiveMessage = (method, eventId, payload) => {
    if (method === responseMap[this.type].receive) {
      if (eventId && this.messageResponse[eventId]) {
        const response = this.messageResponse[eventId];
        delete this.messageResponse[eventId];
        response(payload);
      }
    } else {
        const response = pload => {
        this.messageProxy.request(responseMap[this.type].post, eventId, pload);
        };
       this.listener(method, payload, response);
    }
  }

值得特别说明的是,对消息响应的承载会在 timeout 时清除,这是为了防止 server 异常时出现内存泄露。
特别地,当调用多个 callServer 和 startListening 时,将创建多个消息代理与信道,互不干扰。

4.3. 消息发送、接收与消息响应

消息发送、接收与消息响应都是通过 MessageChannel 完成,使用统一的通信模型:

{
  postMessage: (...args) => {
    if (messageChannel) {
      return messageChannel.postMessage(...args);
    }
    return Promise.reject();
  },
  listenMessage: (...args) => {
    if (messageChannel) {
      messageChannel.listenMessage(...args);
    }
  },
  destroy,
}

由于信道已经做了隔离,所以保证了消息的互不干扰和可靠性。

4.4 销毁连接

在调用暴露的 destroy 接口时,client、server 会执行 messageChannel.destroy();, client 还会执行 serverObject.destroy();
另外,client 上有一个定时守护,检查 server 是否可用,在不可用时执行清除工作:

  function watch() {
    if (!server || server.closed) {
      clearInterval(watcher);
      if (messageChannel) {
        messageChannel.destroy();
      }
    }
  }
  watcher = setInterval(watch, 1000);

五、写在最后

我最初是在一个业务实现过程中要用到页面间通信,但发现开源的库不能很好地满足业务需求,最终只能自己写相关实现。后来我觉得这个库是能为社区服务的,所以依照原先的思路,在业余用了三个晚上,重新实现了一个纯 postMessage 库。原计划用 postmessagejs 作为名称,但后来发现会与一个名称近似的 npm 包冲突,所以改为 postmessage-promise。
欢迎大家使用、提出建议、贡献代码。还有,不要忘了加星


Fromin
15 声望1 粉丝