postMessage 還能這樣玩

在平常工做中,消息通訊是一個很常見的場景。好比你們熟悉 B/S 結構,在該結構下,瀏覽器與服務器之間是基於 HTTP 協議進行消息通訊:javascript

然而除了 HTTP 協議以外,在一些對數據實時性要求較高的場景下,咱們會使用 WebSocket 協議來完成消息通訊:html

對於這兩種場景,相信你們都不會陌生。接下來,阿寶哥將介紹消息通訊的另一種場景,即父頁面與 iframe 加載的子頁面之間,如何進行消息通訊。java

爲何會忽然寫這個話題呢?實際上是由於在近期項目中,阿寶哥須要實現父頁面與 iframe 加載的子頁面之間的消息通訊。另外,恰好近期阿寶哥在寫 源碼分析 專題,因此就到 Github 上搜索 🔍 了一番,而後找到了一個不錯的項目 —— Postmategit

在閱讀完 Postmate 源碼以後,阿寶哥以爲該項目的一些設計思想挺值得借鑑的,因此就寫了這篇文章來跟你們分享一下。閱讀完本文以後,你將學到如下知識:github

  • 消息系統中握手的做用及如何實現握手;
  • 消息模型的設計及如何實現消息驗證來保證通訊安全;
  • postMessage 的使用及如何利用它實現父子頁面的消息通訊;
  • 消息通訊 API 的設計與實現。

好的,廢話很少說,咱們先來簡單介紹一下 Postmate算法

關注「全棧修仙之路」閱讀阿寶哥原創的 3 本免費電子書(累計下載近2萬)及 50 幾篇 「重學TS」 教程。

1、Postmate 簡介

Postmate 是一個強大,簡單,基於 Promise 的 postMessage 庫。它容許父頁面以最小的成本與跨域的子 iframe 進行通訊。該庫擁有如下特性:json

  • 基於 Promise 的 API,可實現優雅而簡單的通訊;
  • 使用 消息驗證 來保護雙向 父 <-> 子 消息通訊的安全;
  • 子對象公開父對象能夠訪問的可檢索的模型對象;
  • 子對象可派發父對象已監聽的事件;
  • 父對象能夠調用子對象中的函數;
  • 零依賴。若是須要能夠爲 Promise API 提供自定義 polyfill 或抽象;
  • 輕量,大小約 1.6 KB(minified & gzipped)。

接下來阿寶哥將從如何進行握手、如何實現雙向消息通訊和如何斷開鏈接,這三個方面來分析一下 Postmate 這個庫。另外,在此期間還會穿插介紹 Postmate 項目中一些好的設計思路。跨域

2、如何進行握手

TCP 創建鏈接的時候,須要進行三次握手。一樣,當父頁面與子頁面通訊的時候,Postmate 也是經過 「握手」 來確保雙方能正常通訊。由於 Postmate 通訊的基礎是基於 postMessage,因此在介紹如何握手以前,咱們先來簡單瞭解一下 postMessage API。promise

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);
  }
};

在主應用和子應用雙方完成握手以後,就能夠進行雙向消息通訊了,下面咱們來了解一下如何實現雙向消息通訊。

3、如何實現雙向消息通訊

在調用 PostmatePostmate.Model 構造函數以後,會返回一個 Promise 對象。而當 Promise 對象的狀態從 pending 變爲 resolved 以後,就會分別返回 ParentAPIChildAPI 對象:

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 如何進行握手及如何實現雙向消息通訊,最後咱們來介紹一下如何斷開鏈接。

4、如何斷開鏈接

當父頁面與子頁面完成消息通訊以後,咱們須要斷開鏈接。這時咱們能夠調用 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

5、參考資源

相關文章
相關標籤/搜索