在平常工做中,消息通讯是一个很常见的场景。好比你们熟悉 B/S 结构,在该结构下,浏览器与服务器之间是基于 HTTP 协议进行消息通讯:javascript
然而除了 HTTP 协议以外,在一些对数据实时性要求较高的场景下,咱们会使用 WebSocket 协议来完成消息通讯:html
对于这两种场景,相信你们都不会陌生。接下来,阿宝哥将介绍消息通讯的另一种场景,即父页面与 iframe 加载的子页面之间,如何进行消息通讯。java
为何会忽然写这个话题呢?实际上是由于在近期项目中,阿宝哥须要实现父页面与 iframe 加载的子页面之间的消息通讯。另外,恰好近期阿宝哥在写 源码分析 专题,因此就到 Github 上搜索 🔍 了一番,而后找到了一个不错的项目 —— Postmate。git
在阅读完 Postmate 源码以后,阿宝哥以为该项目的一些设计思想挺值得借鉴的,因此就写了这篇文章来跟你们分享一下。阅读完本文以后,你将学到如下知识:github
好的,废话很少说,咱们先来简单介绍一下 Postmate。算法
关注「全栈修仙之路」阅读阿宝哥原创的 3 本免费电子书(累计下载近2万)及 50 几篇 “重学TS” 教程。
Postmate 是一个强大,简单,基于 Promise 的 postMessage 库。它容许父页面以最小的成本与跨域的子 iframe
进行通讯。该库拥有如下特性:json
接下来阿宝哥将从如何进行握手、如何实现双向消息通讯和如何断开链接,这三个方面来分析一下 Postmate 这个库。另外,在此期间还会穿插介绍 Postmate 项目中一些好的设计思路。跨域
TCP 创建链接的时候,须要进行三次握手。一样,当父页面与子页面通讯的时候,Postmate 也是经过 “握手” 来确保双方能正常通讯。由于 Postmate 通讯的基础是基于 postMessage,因此在介绍如何握手以前,咱们先来简单了解一下 postMessage
API。promise
对于两个不一样页面的脚本,只有当执行它们的页面位于具备相同的协议、端口号以及主机时,这两个脚本才能相互通讯。window.postMessage()
方法提供了一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。浏览器
otherWindow.postMessage(message, targetOrigin, [transfer]);
发送方经过 postMessage API 来发送消息,而接收方能够经过监听 message
事件,来添加消息处理回调函数,具体使用方式以下:
window.addEventListener("message", receiveMessage, false); function receiveMessage(event) { let origin = event.origin || event.originalEvent.origin; if (origin !== "http://semlinker.com") return; }
在电信和微处理器系统中,术语握手(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.js Postmate.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.js class 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); } };
在主应用和子应用双方完成握手以后,就能够进行双向消息通讯了,下面咱们来了解一下如何实现双向消息通讯。
在调用 Postmate
和 Postmate.Model
构造函数以后,会返回一个 Promise 对象。而当 Promise 对象的状态从 pending
变为 resolved
以后,就会分别返回 ParentAPI
和 ChildAPI
对象:
Postmate
// src/postmate.js class 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.js Postmate.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) }
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 ); } }
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); } }
为了保证通讯的安全,在消息处理时,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" // 自定义属性 }
了解完子页面如何与父页面进行通讯及如何进行消息验证以后,下面咱们来看一下父页面如何与子页面进行消息通讯。
在页面中,经过 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
是如何实现这个功能。
若须要获取调用后的返回值,咱们须要调用 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.js export 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); };
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.js export class ParentAPI { destroy() { window.removeEventListener("message", this.listener, false); this.frame.parentNode.removeChild(this.frame); } }
关注「全栈修仙之路」阅读阿宝哥原创的 3 本免费电子书(累计下载近2万)及 7 篇源码分析系列教程。
本文阿宝哥以 Postmate 这个库为例,介绍了如何基于 postMessage 来实现父页面和 iframe 子页面之间优雅的消息通讯。若是你还意犹未尽的话,能够阅读阿宝哥以前写的与通讯相关的文章:如何优雅的实现消息通讯? 和 你不知道的 WebSocket。