Stories

Detail Return Return

全新的postmessage庫特性介紹與源碼解析 - Stories Detail

眾所周知,postMessage 是在不同頁面間進行通信的一種常用方式:

window.postMessage() 方法可以安全地實現跨源通信。通常,對於兩個不同頁面的腳本,只有當執行它們的頁面位於具有相同的協議(通常為https),端口號(443為https的默認值),以及主機 (兩個頁面的模數 Document.domain設置為相同的值) 時,這兩個腳本才能相互通信。window.postMessage() 方法提供了一種受控機制來規避此限制,只要正確的使用,這種方法就很安全。

postMessage 提供了底層通信能力,有不少優秀的開源庫在此基礎上進行了封裝,以供開發者更便捷地使用。比如1.4k start 的 postmate 就提供了父頁面與 iframe 子頁面間基於 Promise 的 postmessage 通信能力封裝。

而這篇文章要介紹的是另一個新開源的 postmessage 庫:postmessagejs ( npm 包名:postmessage-promise,後文都用此名)。

有人可能會問:用 postmate 不就行了嗎,怎麼又造一個輪子?看官勿急,且聽我細細道來。

一、postMessage

postMessage 分為消息發送方和消息接收方。發送方用如下方式:

otherWindow.postMessage(message, targetOrigin, [transfer]);

其中相關的對象和參數説明如下,

  • otherWindow:其他窗口的一個引用,比如 iframe 的 contentWindow 屬性、執行window.open 返回的窗口對象、或者是命名過或數值索引的 window.frames。
  • targetOrigin:通過窗口的 origin 屬性來指定哪些窗口能接收到消息事件,其值可以是字符串"*"(表示無限制)或者一個 URI。
  • message:將要發送到其他 window 的數據。它將會被結構化克隆算法序列化。這意味着你可以不受什麼限制的將數據對象安全的傳送給目標窗口而無需自己序列化。

接收方使用 window.addEventListener 方法接收消息:

window.addEventListener("message", receiveMessage, false);
function receiveMessage(event)
{
  var origin = event.origin
  if (origin !== "http://example.org:8080")
    return;
  // ...
}

二、postmessage-promise 緣起

2.1 postMessage 有哪些不便捷的地方?

  1. postMessage 是一次性單向事件,也就是説,發消息的動作做完就不管了,也沒有消息響應的概念;
  2. postMessage 未對事件進行分類與管理。這點作為基礎底層機制是完全沒問題的,只是在業務使用上會有一些不便捷和可能帶來副作用。

2.2 postmate 等開源庫解決了哪些問題?

postmate 官方描述是:

A powerful, simple, promise-based postMessage iFrame communication library.

它具有以下特點:

  1. 是一個解決父頁面與 iFrame 子頁面通信的庫。
  2. 其中, promise-base ,一方面是指,handshake(連接成功) 後可以使用 then 方法得到暴露的 parent/child 接口;另一方面是指,父頁面調用 child.get 方法可以用 then 獲取返回的數據。
  3. 內部對事件進行了分類與管理:

    this.parent.postMessage({
      postmate: 'emit',
      type: messageType,
      ...

2.3 postmate 庫有哪些不足?

儘管有了 postmate 庫,但其中有一些問題未能完全解決:

  1. postmate 只支持 iframe 頁面
  2. postmate 只有 child.get 方法支持消息響應(then),並且對 get 的數據內容需要在初始化時就定義好
  3. postmate 建立連接是在 iframe.onload 時開始進行的,進行每500ms一次、最多5次的連接嘗試,這在頁面邏輯單元準備完成有延遲(如 React/Vue 組件實例化等)的情況下是有問題的。

     if (this.frame.attachEvent) {
      this.frame.attachEvent('onload', loaded)
    } else {
      this.frame.addEventListener('load', loaded)
    }
    1. 消息事件的分類與管理是非特化的,雖然有 postmate 屬性進行分隔,但在 postmate 頁面間是通用的,所有消息是互通的。這在一些場景下可能是有利的,但在另一些場景下,這可能帶來非預期的結果,如消息干擾等。

### 2.4 為何需要 postmessage-promise?

注:postmessage-promise 將消息發起方稱為 client(客户端),將消息監聽方稱為 server(服務端)
  1. 有時候,server 頁面的邏輯單元並不是在 Document 加載完成後就能就緒的,所以當邏輯單元就緒時,我們需要一個方法去啓動一個監聽
  2. 有時候,我們需要等待消息的響應後才能發送下一個消息

三、postmessage-promise 基礎

postmessage-promise is a client-server like, WebSocket like, full Promise syntax supported postMessage library.

3.1 特性

  • 支持 iframe 和 window.open 打開的窗口

    • postMessage 本身是支持 iframe 與 window.open 窗口的。postmessage-promise 將窗口對象與功能進行了解耦,稱為 serverObject
  • 類 client-server 模式、類 WebSocket 模式

    • postmessage-promise 將消息發起方稱為 client(客户端),將消息監聽方稱為 server(服務端)。連接成功後,client 可以向 server 端發送消息並等待消息響應。同時,server 端也可以主動發消息給 client 端並等待消息響應。
  • client 端

    • 使用 callServer 方法創建一個 server (創建一個iframe或打開一個新窗口),然後嘗試連接 server 直到超時。如果需要,你可以用同一個 serverObject 來創建新的 server-caller.
  • server 端

    • 使用 startListening 方法開啓一個監聽,一個監聽只能與一個 client 建立連接。如果需要,你也可以開啓多個監聽。
  • 全 Promise 支持,ES2017 async await 語法支持

    • 全 Promise 支持指連接成功時和每一條消息發出後,都能用 then 等待消息響應。自然地,client 與 server 都可以使用 async 語法

3.2 如何使用

client (iframe case)

import { callServer, utils } from "postmessage-promise";
const { getIframeServer } = utils;
// 載入 iframe 並返回 serverObject
const iframeRoot = document.getElementById("iframe-root");
const serverObject = getIframeServer(iframeRoot, "/targetUrl", "iname", ['iframe-style']);
// 發起連接
const connection = callServer(serverObject, {});
connection.then(e => {
  // 向 server 發消息
  e.postMessage('getInfo', 'any payload').then(e => {
    console.log("response from server: ", e);
  });
  // 監聽來自 server 的消息
  e.listenMessage((method, payload, response) => {
    // 響應 server 的消息
    response('any response to server');
  });
});

async 寫法

const asyncCaller = async function () {
  const { postMessage, listenMessage, destroy } = await callServer(serverObject, options);
  const info = await postMessage('getAnyInfo');
  const secondResult = await postMessage('secondPost');
};
asyncCaller();

client (window.open case)

import { callServer, utils } from "postmessage-promise";
const { getOpenedServer } = utils;
// 改為 getOpenedServer 獲取
const serverObject = getOpenedServer("/targetUrl");
const options = {}; 
const connection = callServer(serverObject, {});
// ...

server

import { startListening } from "postmessage-promise";
// 開啓一個監聽
const listening = startListening({});
listening.then(e => {
  // 監聽來自 client 的消息
  e.listenMessage((method, payload, response) => {
      // 響應 client 的消息
      response('any response to client');
  });
  // 向 client 發消息
  e.postMessage('toClient', 'any payload').then(e => {
    console.log("response from client: ", e);
  });
});

async 寫法

const asyncListening = async function () {
  const { postMessage, listenMessage, destroy } = await startListening(options);
  listenMessage((method, payload, response) => {
    response('anyInfo')
  });
};
asyncListening();

四、postmessage-promise 實現原理與源碼解析

下面開始講實現原理與源碼解析,如果你看到了這裏,請給 postmessagejs 加星吧。

4.1 建立連接

  • serverObject

    • utils 中內置了 getOpenedServer, getIframeServer 兩個輔助方法,返回 serverObject。iframe 的嵌入和新窗口的打開其實都是在這個方法內進行的。
    • serverObject 包含 server, origin, destroy。server 可以是 iframe 的 contentWindow 屬性、執行 window.open 返回的窗口對象、或者是命名過或數值索引的 window.frames。
  • client

    • client 在調用 callServer 方法後,先會創建一個 MessageProxy(見下文)。
    • 然後,client 開啓一個定時器,每隔100ms發出一次 hand-shake 消息進行連接嘗試,直到超時(默認20 * 1000ms)。
    • 當 server 響應併發來 hand-shake 消息時,認為連接成功,並創建一個 MessageChannel(見下文)。
  • server

    • server 在調用 startListening 後才開始監聽消息。
    • 在收到第一個 postmessage-promise 的 hand-shake 後,創建一個 MessageProxy 和 一個 MessageChannel。
    • 將此消息的源識別為此信道的 client,並響應一個 hand-shake 消息(由 MessageProxy 發出)。

4.2 消息代理和信道

  • 消息代理(MessageProxy)

MessageProxy 是代理進行收發消息。其中 channelId 是連接成功後的信道 id,雙方都在此信道上通信,避免了消息干擾。

  1. 在收消息時,對消息事件進行過濾:
    const listener = function listener(event) {
      if (event.origin !== _this.origin
        || event.source !== _this.source
        || !event.data
        || event.data[IDENTITY_KEY] !== identityMap[_this.type].accept
        || event.data.channelId !== _this.channelId
        || !_this.eventFilter(event)
        || !event.data.method) {
        return;
      }
      const { eventId, method, payload } = event.data;
      fn(method, eventId, payload);
    };
  1. 在發消息時,將信道信息注入:
    this.source.postMessage({
      [IDENTITY_KEY]: identityMap[this.type].key,
      channelId: this.channelId,
      eventId,
      method,
      payload
    }, this.origin);
  • 信道(MessageChannel)

MessageChannel 提供:

  1. postMessage 方法,此方法包含了消息響應的承載(messageResponse):
  postMessage = (method, payload) => {
    return new Promise((resolve, reject) => {
      let ctimer = null;
      const reswrap = value => {
        clearTimeout(ctimer);
        resolve(value);
      };
      const eventId = Math.random().toString().substr(3, 10);
      this.doPost({
        resolve: reswrap, reject, eventId
      }, method, payload);
      ctimer = setTimeout(() => {
        delete this.messageResponse[eventId];
        reject(new Error('postMessage timeout'));
      }, this.timeout || (20 * 1000));
    });
  }
  1. listenMessage 方法,最終在 receiveMessage 中使用 (listener), 主要是區分消息響應和普通消息:
  receiveMessage = (method, eventId, payload) => {
    if (method === responseMap[this.type].receive) {
      if (eventId && this.messageResponse[eventId]) {
        const response = this.messageResponse[eventId];
        delete this.messageResponse[eventId];
        response(payload);
      }
    } else {
        const response = pload => {
        this.messageProxy.request(responseMap[this.type].post, eventId, pload);
        };
       this.listener(method, payload, response);
    }
  }

值得特別説明的是,對消息響應的承載會在 timeout 時清除,這是為了防止 server 異常時出現內存泄露。
特別地,當調用多個 callServer 和 startListening 時,將創建多個消息代理與信道,互不干擾。

4.3. 消息發送、接收與消息響應

消息發送、接收與消息響應都是通過 MessageChannel 完成,使用統一的通信模型:

{
  postMessage: (...args) => {
    if (messageChannel) {
      return messageChannel.postMessage(...args);
    }
    return Promise.reject();
  },
  listenMessage: (...args) => {
    if (messageChannel) {
      messageChannel.listenMessage(...args);
    }
  },
  destroy,
}

由於信道已經做了隔離,所以保證了消息的互不干擾和可靠性。

4.4 銷燬連接

在調用暴露的 destroy 接口時,client、server 會執行 messageChannel.destroy();, client 還會執行 serverObject.destroy();
另外,client 上有一個定時守護,檢查 server 是否可用,在不可用時執行清除工作:

  function watch() {
    if (!server || server.closed) {
      clearInterval(watcher);
      if (messageChannel) {
        messageChannel.destroy();
      }
    }
  }
  watcher = setInterval(watch, 1000);

五、寫在最後

我最初是在一個業務實現過程中要用到頁面間通信,但發現開源的庫不能很好地滿足業務需求,最終只能自己寫相關實現。後來我覺得這個庫是能為社區服務的,所以依照原先的思路,在業餘用了三個晚上,重新實現了一個純 postMessage 庫。原計劃用 postmessagejs 作為名稱,但後來發現會與一個名稱近似的 npm 包衝突,所以改為 postmessage-promise。
歡迎大家使用、提出建議、貢獻代碼。還有,不要忘了加星 。

Add a new Comments

Some HTML is okay.