在日常工作中,音讯通信是一个很常见的场景。比方大家相熟 B/S 构造,在该构造下,浏览器与服务器之间是基于 HTTP 协定进行音讯通信:

然而除了 HTTP 协定之外,在一些对数据实时性要求较高的场景下,咱们会应用 WebSocket 协定来实现音讯通信:

对于这两种场景,置信大家都不会生疏。接下来,阿宝哥将介绍音讯通信的另外一种场景,即父页面与 iframe 加载的子页面之间,如何进行音讯通信。

为什么会忽然写这个话题呢?其实是因为在近期我的项目中,阿宝哥须要实现父页面与 iframe 加载的子页面之间的音讯通信。另外,刚好近期阿宝哥在写 源码剖析 专题,所以就到 Github 上搜寻 ???? 了一番,而后找到了一个不错的我的项目 —— Postmate。

在浏览完 Postmate 源码之后,阿宝哥感觉该项目标一些设计思维挺值得借鉴的,所以就写了这篇文章来跟大家分享一下。浏览完本文之后,你将学到以下常识:

  • 音讯零碎中握手的作用及如何实现握手;
  • 音讯模型的设计及如何实现音讯验证来保障通信安全;
  • postMessage 的应用及如何利用它实现父子页面的音讯通信;
  • 音讯通信 API 的设计与实现。

好的,废话不多说,咱们先来简略介绍一下 Postmate。

关注「全栈修仙之路」浏览阿宝哥原创的 3 本收费电子书(累计下载近2万)及 50 几篇 “重学TS” 教程。

一、Postmate 简介

Postmate 是一个弱小,简略,基于 Promise 的 postMessage 库。它容许父页面以最小的老本与跨域的子 iframe 进行通信。该库领有以下个性:

  • 基于 Promise 的 API,可实现优雅而简略的通信;
  • 应用 音讯验证 来爱护双向 父 <-> 子 音讯通信的平安;
  • 子对象公开父对象能够拜访的可检索的模型对象;
  • 子对象可派发父对象已监听的事件;
  • 父对象能够调用子对象中的函数;
  • 零依赖。如果须要能够为 Promise API 提供自定义 polyfill 或形象;
  • 轻量,大小约 1.6 KB(minified & gzipped)。

接下来阿宝哥将从如何进行握手、如何实现双向音讯通信和如何断开连接,这三个方面来剖析一下 Postmate 这个库。另外,在此期间还会交叉介绍 Postmate 我的项目中一些好的设计思路。

二、如何进行握手

TCP 建设连贯的时候,须要进行三次握手。同样,当父页面与子页面通信的时候,Postmate 也是通过 “握手” 来确保单方能失常通信。因为 Postmate 通信的根底是基于 postMessage,所以在介绍如何握手之前,咱们先来简略理解一下 postMessage API。

2.1 postMessage 简介

对于两个不同页面的脚本,只有当执行它们的页面位于具备雷同的协定、端口号以及主机时,这两个脚本能力互相通信。window.postMessage() 办法提供了一种受控机制来躲避此限度,只有正确的应用,这种办法就很平安。

2.1.1 postMessage() 语法
otherWindow.postMessage(message, targetOrigin, [transfer]);
  • otherWindow:其余窗口的一个援用,比方 iframe 的 contentWindow 属性、执行 window.open 返回的窗口对象等。
  • message:将要发送到其余 window 的数据,它将会被结构化克隆算法序列化。
  • targetOrigin:通过窗口的 origin 属性来指定哪些窗口能接管到音讯事件,其值能够是字符串 "*"(示意无限度)或者一个 URI。
  • transfer(可选):是一串和 message 同时传递的 Transferable 对象。这些对象的所有权将被转移给音讯的接管方,而发送一方将不再保有所有权。

发送方通过 postMessage API 来发送音讯,而接管方能够通过监听 message 事件,来增加音讯解决回调函数,具体应用形式如下:

window.addEventListener("message", receiveMessage, false);function receiveMessage(event) {  let origin = event.origin || event.originalEvent.origin;   if (origin !== "http://semlinker.com") return;}

2.2 Postmate 握手的实现

在电信和微处理器零碎中,术语握手(Handshake,亦称为交握)具备以下含意:

  • 在数据通信中,由硬件或软件治理的事件序列,在进行信息替换之前,须要对操作模式的状态相互达成协定。
  • 在接收站和发送站之间建设通信参数的过程。

对于通信零碎来说,握手是在通信电路建设之后,信息传输开始之前。 握手用于达成参数,如信息传输率,字母表,奇偶校验, 中断过程,和其余协定个性

而对于 Postmate 这个库来说,握手是为了确保父页面与 iframe 子页面之间能够失常的通信,对应的握手流程如下所示:

在 Postmate 中,握手音讯是由父页面发动的,在父页面中要发动握手信息,首先须要创立 Postmate 对象:

const postmate = new Postmate({  container: document.getElementById('some-div'), // iframe的容器  url: 'http://child.com/page.html', // 蕴含postmate.js的iframe子页面地址  name: 'my-iframe-name' // 用于设置iframe元素的name属性});

在以上代码中,咱们通过调用 Postmate 构造函数来创立 postmate 对象,在 Postmate 构造函数外部含有两个次要步骤:设置 Postmate 对象的外部属性和发送握手音讯:

以上流程图对应的代码绝对比较简单,这里阿宝哥就不贴具体的代码了。感兴趣的小伙伴能够浏览 src/postmate.js 文件中的相干内容。为了可能响应父页面的握手信息,咱们须要在子页面中创立一个 Model 对象:

const model = new Postmate.Model({  // Expose your model to the Parent. Property values may be functions, promises, or regular values  height: () => document.height || document.body.offsetHeight});

其中 Postmate.Model 构造函数的定义如下:

// src/postmate.jsPostmate.Model = class Model {  constructor(model) {    this.child = window;    this.model = model;    this.parent = this.child.parent;    return this.sendHandshakeReply();  }}

在 Model 构造函数中,咱们能够很分明地看到调用 sendHandshakeReply 这个办法,这里咱们只看外围的代码:

当初咱们来总结一下父页面和子页面之间的握手流程:当子页面加载实现后,父页面会通过 postMessage API 向子页面发送 handshake 握手音讯。在子页面接管到 handshake 握手音讯之后,同样也会应用 postMessage API 往父页面回复 handshake-reply 音讯。

另外,须要留神的是,为了保障子页面能收到 handshake 握手音讯,在 sendHandshake 办法外部会启动一个定时器来执行发送操作:

// src/postmate.jsclass Postmate {  sendHandshake(url) {    return new Postmate.Promise((resolve, reject) => {      const loaded = () => {        doSend();        responseInterval = setInterval(doSend, 500);      };      if (this.frame.attachEvent) {        this.frame.attachEvent("onload", loaded);      } else {        this.frame.addEventListener("load", loaded);      }            this.frame.src = url;    });  }}

当然为了防止发送过多有效的握手信息,在 doSend 办法外部会限度最大的握手次数:

const doSend = () => {  attempt++;  this.child.postMessage(    {      postmate: "handshake",      type: messageType,      model: this.model,    },    childOrigin  );  // const maxHandshakeRequests = 5;  if (attempt === maxHandshakeRequests) {     clearInterval(responseInterval);  }};

在主利用和子利用单方实现握手之后,就能够进行双向音讯通信了,上面咱们来理解一下如何实现双向音讯通信。

三、如何实现双向音讯通信

在调用 PostmatePostmate.Model 构造函数之后,会返回一个 Promise 对象。而当 Promise 对象的状态从 pending 变为 resolved 之后,就会别离返回 ParentAPIChildAPI 对象:

Postmate

// src/postmate.jsclass Postmate {  constructor({    container = typeof container !== "undefined" ? container : document.body,    model, url, name, classListArray = [],  }) {    // 省略设置 Postmate 对象的外部属性    return this.sendHandshake(url);  }    sendHandshake(url) {    // 省略局部代码    return new Postmate.Promise((resolve, reject) => {      const reply = (e) => {        if (!sanitize(e, childOrigin)) return false;        if (e.data.postmate === "handshake-reply") {          return resolve(new ParentAPI(this));        }        return reject("Failed handshake");      };    });  }}

ParentAPI

class ParentAPI{  +get(property: any) // 获取子页面中Model对象上的property属性上的值  +call(property: any, data: any) // 调用子页面中Model对象上的办法  +on(eventName: any, callback: any) // 监听子页面派发的事件  +destroy() // 移除事件监听并删除iframe}

Postmate.Model

// src/postmate.jsPostmate.Model = class Model {  constructor(model) {    this.child = window;    this.model = model;    this.parent = this.child.parent;    return this.sendHandshakeReply();  }  sendHandshakeReply() {    // 省略局部代码    return new Postmate.Promise((resolve, reject) => {      const shake = (e) => {        if (e.data.postmate === "handshake") {          this.child.removeEventListener("message", shake, false);          return resolve(new ChildAPI(this));        }        return reject("Handshake Reply Failed");      };      this.child.addEventListener("message", shake, false);    });  }};

ChildAPI

class ChildAPI{  +emit(name: any, data: any)}

3.1 子页面 -> 父页面

3.1.1 子页面发送音讯
const model = new Postmate.Model({  // Expose your model to the Parent. Property values may be functions, promises, or regular values  height: () => document.height || document.body.offsetHeight});model.then(childAPI => {  childAPI.emit('some-event', 'Hello, World!');});

在以上代码中,子页面能够通过 ChildAPI 对象提供的 emit 办法来发送音讯,该办法的定义如下:

export class ChildAPI {  emit(name, data) {    this.parent.postMessage(      {        postmate: "emit",        type: messageType,        value: {          name,          data,        },      },      this.parentOrigin    );  }}
3.1.2 父页面监听音讯
const postmate = new Postmate({  container: document.getElementById('some-div'), // iframe的容器  url: 'http://child.com/page.html', // 蕴含postmate.js的iframe子页面地址  name: 'my-iframe-name' // 用于设置iframe元素的name属性});postmate.then(parentAPI => {  parentAPI.on('some-event', data => console.log(data)); // Logs "Hello, World!"});

在以上代码中,父页面能够通过 ParentAPI 对象提供的 on 办法来注册事件处理器,该办法的定义如下:

export class ParentAPI {  constructor(info) {    this.parent = info.parent;    this.frame = info.frame;    this.child = info.child;    this.events = {};    this.listener = (e) => {      if (!sanitize(e, this.childOrigin)) return false;            // 省略局部代码      if (e.data.postmate === "emit") {        if (name in this.events) {          this.events[name].forEach((callback) => {            callback.call(this, data);          });        }      }    };    this.parent.addEventListener("message", this.listener, false);  }  on(eventName, callback) {    if (!this.events[eventName]) {      this.events[eventName] = [];    }    this.events[eventName].push(callback);  }}

3.2 音讯验证

为了保障通信的平安,在音讯解决时,Postmate 会对音讯进行验证,对应的验证逻辑被封装到 sanitize 办法中:

const sanitize = (message, allowedOrigin) => {  if (typeof allowedOrigin === "string" && message.origin !== allowedOrigin)    return false;  if (!message.data) return false;  if (typeof message.data === "object" && !("postmate" in message.data))    return false;  if (message.data.type !== messageType) return false;  if (!messageTypes[message.data.postmate]) return false;  return true;};

对应的验证规定如下:

  • 验证音讯的起源是否非法;
  • 验证是否含有音讯体;
  • 验证音讯体中是否含有 postmate 属性;
  • 验证音讯的类型是否为 "application/x-postmate-v1+json"
  • 验证音讯体中的 postmate 对应的音讯类型是否非法;

以下是 Postmate 反对的音讯类型:

const messageTypes = {  handshake: 1,   "handshake-reply": 1,   call: 1,  emit: 1,   reply: 1,   request: 1,};

其实要实现音讯验证的提前,咱们还须要定义规范的音讯体模型:

{   postmate: "emit", // 必填:"request" | "call" 等等   type: messageType, // 必填:"application/x-postmate-v1+json"   // 自定义属性}

理解完子页面如何与父页面进行通信及如何进行音讯验证之后,上面咱们来看一下父页面如何与子页面进行音讯通信。

3.3 父页面 -> 子页面

3.3.1 调用子页面模型对象上的办法

在页面中,通过 ParentAPI 对象提供的 call 办法,咱们就能够调用子页面模型对象上的办法:

export class ParentAPI {    call(property, data) {    this.child.postMessage(      {        postmate: "call",        type: messageType,        property,        data,      },      this.childOrigin    );  }}

ChildAPI 对象中,会对 call 音讯类型进行对应的解决,相应的解决逻辑如下所示:

export class ChildAPI {  constructor(info) {        // 省略局部代码    this.child.addEventListener("message", (e) => {      if (!sanitize(e, this.parentOrigin)) return;      const { property, uid, data } = e.data;            // 响应父页面发送的call音讯类型,用于调用Model对象上的对应办法      if (e.data.postmate === "call") {        if (          property in this.model &&          typeof this.model[property] === "function"        ) {          this.model[property](data);        }        return;      }    });  }}

通过以上代码咱们可知,call 音讯只能用来调用子页面 Model 对象上的办法并不能获取办法调用的返回值。然而在一些场景下,咱们是须要获取办法调用的返回值,接下来咱们来看一下 ParentAPI 是如何实现这个性能。

3.3.2 调用子页面模型对象上的办法并获取返回值

若须要获取调用后的返回值,咱们须要调用 ParentAPI 对象上提供的 get 办法:

export class ParentAPI {    get(property) {    return new Postmate.Promise((resolve) => {      // 从响应中获取数据并移除监听      const uid = generateNewMessageId();      const transact = (e) => {        if (e.data.uid === uid && e.data.postmate === "reply") {          this.parent.removeEventListener("message", transact, false);          resolve(e.data.value);        }      };            // 监听来自子页面的响应音讯      this.parent.addEventListener("message", transact, false);      // 向子页面发送申请      this.child.postMessage(        {          postmate: "request",          type: messageType,          property,          uid,        },        this.childOrigin      );    });  }}

对于父页面发送的 request 音讯,在子页面中会通过 resolveValue 办法来获取返回后果,而后通过 postMessage 来返回后果:

// src/postmate.jsexport class ChildAPI {  constructor(info) {    this.child.addEventListener("message", (e) => {      if (!sanitize(e, this.parentOrigin)) return;      const { property, uid, data } = e.data;            // 响应父页面发送的request音讯      resolveValue(this.model, property).then((value) =>        e.source.postMessage(          {            property,            postmate: "reply",            type: messageType,            uid,            value,          },          e.origin        )      );    });  }}

以上代码中的 resolveValue 办法实现也很简略:

const resolveValue = (model, property) => {  const unwrappedContext =    typeof model[property] === "function" ? model[property]() : model[property];  return Postmate.Promise.resolve(unwrappedContext);};

3.4 模型扩大机制

Postmate 提供了非常灵活的模型扩大机制,让开发者能够依据需要,扩大子页面的 Model 对象:

对应的扩大机制实现起来并不简单,具体的实现如下所示:

Postmate.Model = class Model {  constructor(model) {    // 省略局部代码    return this.sendHandshakeReply();  }  sendHandshakeReply() {    return new Postmate.Promise((resolve, reject) => {      const shake = (e) => {        // 省略局部代码        if (e.data.postmate === "handshake") {          // 应用父页面提供的模型对象来扩大子页面已有的模型对象          const defaults = e.data.model;          if (defaults) {            Object.keys(defaults).forEach((key) => {              this.model[key] = defaults[key];            });          }          return resolve(new ChildAPI(this));        }      };    });  }};

此时,咱们曾经介绍了 Postmate 如何进行握手及如何实现双向音讯通信,最初咱们来介绍一下如何断开连接。

四、如何断开连接

当父页面与子页面实现音讯通信之后,咱们须要断开连接。这时咱们能够调用 ParentAPI 对象上的 destroy 办法来断开连接。

// src/postmate.jsexport class ParentAPI {    destroy() {    window.removeEventListener("message", this.listener, false);    this.frame.parentNode.removeChild(this.frame);  }}
关注「全栈修仙之路」浏览阿宝哥原创的 3 本收费电子书(累计下载近2万)及 7 篇源码剖析系列教程。

本文阿宝哥以 Postmate 这个库为例,介绍了如何基于 postMessage 来实现父页面和 iframe 子页面之间优雅的音讯通信。如果你还意犹未尽的话,能够浏览阿宝哥之前写的与通信相干的文章:如何优雅的实现音讯通信? 和 你不晓得的 WebSocket。

五、参考资源

  • MDN - postMessage
  • Github - postmate