在日常工作中,訊息通訊是一個很常見的場景。比如大家熟悉 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.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)
}
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.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);
};
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.js
export class ParentAPI {
destroy() {
window.removeEventListener("message", this.listener, false);
this.frame.parentNode.removeChild(this.frame);
}
}
關注「全棧修仙之路」閱讀阿寶哥原創的 3 本免費電子書(累計下載近2萬)及 7 篇原始碼分析系列教程。
本文阿寶哥以 Postmate 這個庫為例,介紹瞭如何基於 postMessage 來實現父頁面和 iframe 子頁面之間優雅的訊息通訊。如果你還意猶未盡的話,可以閱讀阿寶哥之前寫的與通訊相關的文章:如何優雅的實現訊息通訊? 和 你不知道的 WebSocket。