PostMessage 还能这样玩

网络 通信技术
本文阿宝哥以 Postmate 这个库为例,介绍了如何基于 postMessage 来实现父页面和 iframe 子页面之间优雅的消息通信。

 在日常工作中,消息通信是一个很常见的场景。比如大家熟悉 B/S 结构,在该结构下,浏览器与服务器之间是基于 HTTP 协议进行消息通信:


然而除了 HTTP 协议之外,在一些对数据实时性要求较高的场景下,我们会使用 WebSocket 协议来完成消息通信:


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

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

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

  • 消息系统中握手的作用及如何实现握手;
  • 消息模型的设计及如何实现消息验证来保证通信安全;
  • postMessage 的使用及如何利用它实现父子页面的消息通信;
  • 消息通信 API 的设计与实现。

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

一、Postmate 简介

[[352432]]

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() 语法

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

发送方通过 postMessage API 来发送消息,而接收方可以通过监听 message 事件,来添加消息处理回调函数,具体使用方式如下:

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

2.2 Postmate 握手的实现

在电信和微处理器系统中,术语握手(Handshake,亦称为交握)具有以下含义:

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

对于通信系统来说,握手是在通信电路建立之后,信息传输开始之前。握手用于达成参数,如信息传输率,字母表,奇偶校验, 中断过程,和其他协议特性。

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

 在 Postmate 中,握手消息是由父页面发起的,在父页面中要发起握手信息,首先需要创建 Postmate 对象:

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

在以上代码中,我们通过调用 Postmate 构造函数来创建 postmate 对象,在 Postmate 构造函数内部含有两个主要步骤:设置 Postmate 对象的内部属性和发送握手消息: 


以上流程图对应的代码相对比较简单,这里阿宝哥就不贴详细的代码了。感兴趣的小伙伴可以阅读 src/postmate.js 文件中的相关内容。为了能够响应父页面的握手信息,我们需要在子页面中创建一个 Model 对象:

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

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

  1. // src/postmate.js 
  2. Postmate.Model = class Model { 
  3.   constructor(model) { 
  4.     this.child = window; 
  5.     this.model = model; 
  6.     this.parent = this.child.parent; 
  7.     return this.sendHandshakeReply(); 
  8.   } 

在 Model 构造函数中,我们可以很清楚地看到调用 sendHandshakeReply 这个方法,这里我们只看核心的代码:

 

现在我们来总结一下父页面和子页面之间的握手流程:当子页面加载完成后,父页面会通过 postMessage API 向子页面发送 handshake 握手消息。在子页面接收到 handshake握手消息之后,同样也会使用 postMessage API 往父页面回复 handshake-reply 消息。

另外,需要注意的是,为了保证子页面能收到 handshake 握手消息,在 sendHandshake方法内部会启动一个定时器来执行发送操作:

  1. // src/postmate.js 
  2. class Postmate { 
  3.   sendHandshake(url) { 
  4.     return new Postmate.Promise((resolve, reject) => { 
  5.       const loaded = () => { 
  6.         doSend(); 
  7.         responseInterval = setInterval(doSend, 500); 
  8.       }; 
  9.  
  10.       if (this.frame.attachEvent) { 
  11.         this.frame.attachEvent("onload", loaded); 
  12.       } else { 
  13.         this.frame.addEventListener("load", loaded); 
  14.       } 
  15.        
  16.       this.frame.src = url; 
  17.     }); 
  18.   } 

当然为了避免发送过多无效的握手信息,在 doSend 方法内部会限制最大的握手次数:

  1. const doSend = () => { 
  2.   attempt++; 
  3.   this.child.postMessage( 
  4.     { 
  5.       postmate: "handshake"
  6.       type: messageType, 
  7.       model: this.model, 
  8.     }, 
  9.     childOrigin 
  10.   ); 
  11.   // const maxHandshakeRequests = 5; 
  12.   if (attempt === maxHandshakeRequests) { 
  13.      clearInterval(responseInterval); 
  14.   } 
  15. }; 

在主应用和子应用双方完成握手之后,就可以进行双向消息通信了,下面我们来了解一下如何实现双向消息通信。

三、如何实现双向消息通信

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

Postmate

  1. // src/postmate.js 
  2. class Postmate { 
  3.   constructor({ 
  4.     container = typeof container !== "undefined" ? container : document.body, 
  5.     model, url, name, classListArray = [], 
  6.   }) { 
  7.     // 省略设置 Postmate 对象的内部属性 
  8.     return this.sendHandshake(url); 
  9.   } 
  10.    
  11.   sendHandshake(url) { 
  12.     // 省略部分代码 
  13.     return new Postmate.Promise((resolve, reject) => { 
  14.       const reply = (e) => { 
  15.         if (!sanitize(e, childOrigin)) return false
  16.         if (e.data.postmate === "handshake-reply") { 
  17.           return resolve(new ParentAPI(this)); 
  18.         } 
  19.         return reject("Failed handshake"); 
  20.       }; 
  21.     }); 
  22.   } 

ParentAPI

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

Postmate.Model

  1. // src/postmate.js 
  2. Postmate.Model = class Model { 
  3.   constructor(model) { 
  4.     this.child = window; 
  5.     this.model = model; 
  6.     this.parent = this.child.parent; 
  7.     return this.sendHandshakeReply(); 
  8.   } 
  9.  
  10.   sendHandshakeReply() { 
  11.     // 省略部分代码 
  12.     return new Postmate.Promise((resolve, reject) => { 
  13.       const shake = (e) => { 
  14.         if (e.data.postmate === "handshake") { 
  15.           this.child.removeEventListener("message", shake, false); 
  16.           return resolve(new ChildAPI(this)); 
  17.         } 
  18.         return reject("Handshake Reply Failed"); 
  19.       }; 
  20.       this.child.addEventListener("message", shake, false); 
  21.     }); 
  22.   } 
  23. }; 

ChildAPI

  1. class ChildAPI{ 
  2.   +emit(nameany, data: any

3.1 子页面 -> 父页面

3.1.1 子页面发送消息

  1. const model = new Postmate.Model({ 
  2.   // Expose your model to the Parent. Property values may be functions, promises, or regular values 
  3.   height: () => document.height || document.body.offsetHeight 
  4. }); 
  5.  
  6. model.then(childAPI => { 
  7.   childAPI.emit('some-event''Hello, World!'); 
  8. }); 

在以上代码中,子页面可以通过 ChildAPI 对象提供的 emit 方法来发送消息,该方法的定义如下:

  1. export class ChildAPI { 
  2.   emit(name, data) { 
  3.     this.parent.postMessage( 
  4.       { 
  5.         postmate: "emit"
  6.         type: messageType, 
  7.         value: { 
  8.           name
  9.           data, 
  10.         }, 
  11.       }, 
  12.       this.parentOrigin 
  13.     ); 
  14.   } 

3.1.2 父页面监听消息

  1. const postmate = new Postmate({ 
  2.   container: document.getElementById('some-div'), // iframe的容器 
  3.   url: 'http://child.com/page.html', // 包含postmate.js的iframe子页面地址 
  4.   name'my-iframe-name' // 用于设置iframe元素的name属性 
  5. }); 
  6.  
  7. postmate.then(parentAPI => { 
  8.   parentAPI.on('some-event', data => console.log(data)); // Logs "Hello, World!" 
  9. }); 

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

  1. export class ParentAPI { 
  2.   constructor(info) { 
  3.     this.parent = info.parent; 
  4.     this.frame = info.frame; 
  5.     this.child = info.child; 
  6.  
  7.     this.events = {}; 
  8.  
  9.     this.listener = (e) => { 
  10.       if (!sanitize(e, this.childOrigin)) return false
  11.    // 省略部分代码 
  12.       if (e.data.postmate === "emit") { 
  13.         if (name in this.events) { 
  14.           this.events[name].forEach((callback) => { 
  15.             callback.call(this, data); 
  16.           }); 
  17.         } 
  18.       } 
  19.     }; 
  20.  
  21.     this.parent.addEventListener("message", this.listener, false); 
  22.   } 
  23.  
  24.   on(eventName, callback) { 
  25.     if (!this.events[eventName]) { 
  26.       this.events[eventName] = []; 
  27.     } 
  28.     this.events[eventName].push(callback); 
  29.   } 

3.2 消息验证

为了保证通信的安全,在消息处理时,Postmate 会对消息进行验证,对应的验证逻辑被封装到 sanitize 方法中:

  1. const sanitize = (message, allowedOrigin) => { 
  2.   if (typeof allowedOrigin === "string" && message.origin !== allowedOrigin) 
  3.     return false
  4.   if (!message.data) return false
  5.   if (typeof message.data === "object" && !("postmate" in message.data)) 
  6.     return false
  7.   if (message.data.type !== messageType) return false
  8.   if (!messageTypes[message.data.postmate]) return false
  9.   return true
  10. }; 

对应的验证规则如下:

  • 验证消息的来源是否合法;
  • 验证是否含有消息体;
  • 验证消息体中是否含有 postmate 属性;
  • 验证消息的类型是否为 "application/x-postmate-v1+json";
  • 验证消息体中的 postmate 对应的消息类型是否合法;

以下是 Postmate 支持的消息类型:

  1. const messageTypes = { 
  2.   handshake: 1,  
  3.   "handshake-reply": 1,  
  4.   call: 1, 
  5.   emit: 1,  
  6.   reply: 1,  
  7.   request: 1, 
  8. }; 

其实要实现消息验证的提前,我们还需要定义标准的消息体模型:

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

了解完子页面如何与父页面进行通信及如何进行消息验证之后,下面我们来看一下父页面如何与子页面进行消息通信。

3.3 父页面 -> 子页面

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


在页面中,通过 ParentAPI 对象提供的 call 方法,我们就可以调用子页面模型对象上的方法:

  1. export class ParentAPI { 
  2.  call(property, data) { 
  3.     this.child.postMessage( 
  4.       { 
  5.         postmate: "call"
  6.         type: messageType, 
  7.         property, 
  8.         data, 
  9.       }, 
  10.       this.childOrigin 
  11.     ); 
  12.   } 

在 ChildAPI 对象中,会对 call 消息类型进行对应的处理,相应的处理逻辑如下所示:

  1. export class ChildAPI { 
  2.   constructor(info) { 
  3.   // 省略部分代码 
  4.     this.child.addEventListener("message", (e) => { 
  5.       if (!sanitize(e, this.parentOrigin)) return
  6.       const { property, uid, data } = e.data; 
  7.        
  8.       // 响应父页面发送的call消息类型,用于调用Model对象上的对应方法 
  9.       if (e.data.postmate === "call") { 
  10.         if ( 
  11.           property in this.model && 
  12.           typeof this.model[property] === "function" 
  13.         ) { 
  14.           this.model[property](data); 
  15.         } 
  16.         return
  17.       } 
  18.     }); 
  19.   } 

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

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

 若需要获取调用后的返回值,我们需要调用 ParentAPI 对象上提供的 get 方法:

  1. export class ParentAPI { 
  2.  get(property) { 
  3.     return new Postmate.Promise((resolve) => { 
  4.       // 从响应中获取数据并移除监听 
  5.       const uid = generateNewMessageId(); 
  6.       const transact = (e) => { 
  7.         if (e.data.uid === uid && e.data.postmate === "reply") { 
  8.           this.parent.removeEventListener("message", transact, false); 
  9.           resolve(e.data.value); 
  10.         } 
  11.       }; 
  12.        
  13.       // 监听来自子页面的响应消息 
  14.       this.parent.addEventListener("message", transact, false); 
  15.  
  16.       // 向子页面发送请求 
  17.       this.child.postMessage( 
  18.         { 
  19.           postmate: "request"
  20.           type: messageType, 
  21.           property, 
  22.           uid, 
  23.         }, 
  24.         this.childOrigin 
  25.       ); 
  26.     }); 
  27.   } 

对于父页面发送的 request 消息,在子页面中会通过 resolveValue 方法来获取返回结果,然后通过 postMessage 来返回结果:

  1. // src/postmate.js 
  2. export class ChildAPI { 
  3.   constructor(info) { 
  4.     this.child.addEventListener("message", (e) => { 
  5.       if (!sanitize(e, this.parentOrigin)) return
  6.       const { property, uid, data } = e.data; 
  7.        
  8.       // 响应父页面发送的request消息 
  9.       resolveValue(this.model, property).then((value) => 
  10.         e.source.postMessage( 
  11.           { 
  12.             property, 
  13.             postmate: "reply"
  14.             type: messageType, 
  15.             uid, 
  16.             value, 
  17.           }, 
  18.           e.origin 
  19.         ) 
  20.       ); 
  21.     }); 
  22.   } 

以上代码中的 resolveValue 方法实现也很简单:

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

此时,我们已经介绍了 Postmate 如何进行握手及如何实现双向消息通信,最后我们来介绍一下如何断开连接。

四、如何断开连接

当父页面与子页面完成消息通信之后,我们需要断开连接。这时我们可以调用 ParentAPI对象上的 destroy 方法来断开连接。

  1. // src/postmate.js 
  2. export class ParentAPI { 
  3.  destroy() { 
  4.     window.removeEventListener("message", this.listener, false); 
  5.     this.frame.parentNode.removeChild(this.frame); 
  6.   } 

本文阿宝哥以 Postmate 这个库为例,介绍了如何基于 postMessage 来实现父页面和 iframe 子页面之间优雅的消息通信。如果你还意犹未尽的话,可以阅读阿宝哥之前写的与通信相关的文章:如何优雅的实现消息通信? 和 你不知道的 WebSocket。

五、参考资源

  • MDN - postMessage
  • Github - postmate【编辑推荐】

 

责任编辑:姜华 来源: 全栈修仙之路
相关推荐

2021-07-28 06:10:47

拖拽设计器 transmat

2021-09-05 07:55:37

前端Emoji 表情

2018-12-12 11:30:54

JavaString字符串

2021-04-09 08:23:30

Css前端加载动画

2024-01-30 09:21:29

CSS文字效果文字装饰

2023-02-26 00:00:02

字符串分割String

2023-01-30 08:46:20

GoGo1兼容性

2020-05-22 10:00:08

数据库数据库设计软件设计

2019-11-08 10:48:07

Windows操作系统微信Windows 10

2021-01-30 07:51:59

微信微信8.0腾讯

2022-10-31 08:47:21

人脸识别按键键盘

2016-09-23 15:36:53

Windows10开始菜单程序

2020-05-09 16:45:56

ping命令Linux

2020-09-14 11:26:54

BinlogCanal数据库

2024-03-25 08:03:32

技术面试ShowMeBug协同编程

2021-10-29 07:49:22

Spring事务管理

2012-07-13 11:32:16

网络出口

2013-08-22 10:28:50

.NET MVC.NETRazor

2023-11-30 22:54:15

2020-08-14 08:19:25

Shell命令行数据
点赞
收藏

51CTO技术栈公众号