在平常工做中,消息通訊是一個很常見的場景。好比你們熟悉 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。