Stories

Detail Return Return

JSON-RPC & postMessage 談談瀏覽器消息通信的封裝技巧 - Stories Detail

楔子

postMessage 常見於內嵌 iframe 或是 Web Workers 中,用於跨頁面(線程) 的消息通信,在一些其他開發環境中也能看到類似的影子,如 Chrome 插件環境、Electron 環境、figma 插件等。

最近的工作需要經常與 iframeWeb Workers 打交道,處理頁面與內嵌頁、主線程與 worker 通信,擼了個用於處理瀏覽器消息通信的處理的工具庫 rpc-shooter,涵蓋了瀏覽器主要的消息通信的接口支持:

  • Window
  • Worker
  • SharedWorker
  • ServiceWorker
  • MessageChannel
  • BroadcastChannel
  • MessagePort

在此分享一些開發過程中的經驗與技巧。

原教旨主義

先來看一個 iframe 父子級頁面通信的例子:

// parent.js
const childWindow = document.querySelector('iframe').contentWindow;
window.addEventListener('message', function (event) {
    const data = event.data;
    if (data.method === 'do_something') {
        // ... handle iframe data
        childWindow.postMessage({
            method: 're:do_something',
            data: 'some data',
        });
    }
});

// iframe.js
window.top.postMessage(
    {
        method: 'do_something',
        data: 'ifame data',
    },
    '*'
);
window.addEventListener('message', function (event) {
    const data = event.data;
    if (data.method === 're:do_something') {
        // ... handle parent data
    }
});

使用原教旨主義的寫法可以很容寫出上述代碼,處理簡單消息通信不會有什麼問題,但針對複雜場景下跨頁面(線程)通信則需要有個簡單有效機制來維護消息通信。

聰明的你一定想到了基於統一消息格式配合對應的消息處理策略來維護消息事件的方法調用,很簡單的機制,卻很好用:

const childWindow = document.querySelector('iframe').contentWindow;
const handlers = {
    add: (a: number, b: number) => a + b,
    subtract: (a: number, b: number) => a - b,
};
window.addEventListener('message', function (event) {
    const { method, args } = event.data;
    const result = handlers[method](...args);
    childWindow.postMessage({
        method: `re:${method}`,
        args: [result],
    });
});

使用上述的處理方式,消息通信的處理維護一份策略處理函數即可,接下來的工作也是建立在此基礎上的,加一點“細節”即可。

事件封裝

消息通信本身是事件的一種,所以不妨往事件封裝的方向靠,這時候就有很多可以借鑑的接口設計了,這裏可以借鑑 socket.io 的接口設計。相對與本地事件調用,消息通信則本質是監聽遠程服務所發出的事件,與 socket.io 類似:

// client
socket.emit('join-in', input.value);
// server
socket.on('join-in',(user) => {...});

面向接口

對於一個工具函數(庫)的封裝設計,最好是從接口開始,接口設計可以直接決定最終工具使用形式。這也是 Typescript 帶來的開發模式轉變,面向接口的設計,可以幫助我們更好組裝模塊以達到解耦的目的。

封裝的接口格式定義:

interface RPCHandler {
    (...args: any[]): any;
}

interface RPCEvent {
    emit(event: string, ...args: any[]): void;
    on(event: string, fn: RPCHandler): void;
    off(event: string, fn?: RPCHandler): void;
}

基於上述定義的接口,以 iframe 的父子通信為例做工具庫封裝:

interface RPCHandler {
    (...args: any[]): any;
}

interface RPCEvent {
    emit(event: string, ...args: any[]): void;
    on(event: string, fn: RPCHandler): void;
    off(event: string, fn?: RPCHandler): void;
}

interface RPCMessageDataFormat {
    event: string;
    args: any[];
}

interface RPCMessageEventOptions {
    currentEndpoint: Window;
    targetEndpoint: Window;
    targetOrigin: string;
}

class RPCMessageEvent implements RPCEvent {
    private _currentEndpoint: RPCMessageEventOptions['currentEndpoint'];
    private _targetEndpoint: RPCMessageEventOptions['targetEndpoint'];
    private _targetOrigin: RPCMessageEventOptions['targetOrigin'];
    private _events: Record<string, Array<RPCHandler>>;

    constructor(options: RPCMessageEventOptions) {
        this._events = {};
        this._currentEndpoint = options.currentEndpoint;
        this._targetEndpoint = options.targetEndpoint;
        this._targetOrigin = options.targetOrigin;
        // 監聽遠程消息事件
        const receiveMessage = (event: MessageEvent) => {
            const { data } = event;
            const eventHandlers = this._events[data.event] || [];
            if (eventHandlers.length) {
                eventHandlers.forEach((handler) => {
                    handler(...(data.args || []));
                });
                return;
            }
        };
        this._currentEndpoint.addEventListener(
            'message',
            receiveMessage as EventListenerOrEventListenerObject,
            false
        );
    }

    emit(event: string, ...args: any[]): void {
        const data: RPCMessageDataFormat = {
            event,
            args,
        };
        // postMessage
        this._targetEndpoint.postMessage(data, this._targetOrigin);
    }

    on(event: string, fn: RPCHandler): void {
        if (!this._events[event]) {
            this._events[event] = [];
        }
        this._events[event].push(fn);
    }

    off(event: string, fn?: RPCHandler): void {
        if (!this._events[event]) return;
        if (!fn) {
            this._events[event] = [];
            return;
        }
        const handlers = this._events[event] || [];
        this._events[event] = handlers.filter((handler) => handler !== fn);
    }
}

經典的事件實現,這裏不做贅述,使用方式如下:

// 父級頁面
const childWindow = document.querySelector('iframe').contentWindow;
const parentEvent: RPCEvent = new RPCMessageEvent({
    targetEndpoint: window,
    currentEndpoint: childWindow,
    targetOrigin: '*',
});
parentEvent.on('add', (a, b) => a + b);
parentEvent.emit('test');

// 子級頁面
const childEvent: RPCEvent = new RPCMessageEvent({
    targetEndpoint: window,
    currentEndpoint: window.top,
    targetOrigin: '',
});
childEvent.emit('add', 1, 2);
childEvent.on('test', () => {});
childEvent.on('max', (a, b) => Math.max(a, b));
childEvent.off('max');

思考一個問題,上述實現了父子級 window 對象的消息通信封裝,能否將其一般化支持到所有瀏覽器消息事件?

答案是肯定的,看一眼事件的 Window 封裝初始化選項:

interface RPCMessageEventOptions {
    currentEndpoint: Window;
    targetEndpoint: Window;
    targetOrigin: string;
}

這裏的事件接收與發送對象都是 Window,但實際上我們只是依賴了:

  • currentEndpoint 上的 message 事件
  • targetEndpoint 上的 postMessage 方法與其配置

換言之,只要瀏覽器中的其他對象支持 message 事件與 postMessage 方法即可實現同樣的封裝,即滿足接口即可

interface RPCMessageEventOptions {
    currentEndpoint: {
        addEventListener<K extends keyof MessagePortEventMap>(
            type: K,
            listener: (
                this: RPCMessageEventOptions['currentEndpoint'],
                ev: MessagePortEventMap[K]
            ) => any,
            options?: boolean | AddEventListenerOptions
        ): void;
    };
    targetEndpoint: {
        postMessage(message: any, ...args: any[]): void;
    };
}

瀏覽器中通信接口

以下為目前瀏覽器主要支持消息通信的對象,其都實現了類似消息事件接口:

interface MessagePort extends EventTarget {
    postMessage(message: any, transfer: Transferable[]): void;
    postMessage(message: any, options?: StructuredSerializeOptions): void;
    addEventListener<K extends keyof MessagePortEventMap>(
        type: K,
        listener: (this: MessagePort, ev: MessagePortEventMap[K]) => any,
        options?: boolean | AddEventListenerOptions
    ): void;
    addEventListener(
        type: string,
        listener: EventListenerOrEventListenerObject,
        options?: boolean | AddEventListenerOptions
    ): void;
    removeEventListener<K extends keyof MessagePortEventMap>(
        type: K,
        listener: (this: MessagePort, ev: MessagePortEventMap[K]) => any,
        options?: boolean | EventListenerOptions
    ): void;
    removeEventListener(
        type: string,
        listener: EventListenerOrEventListenerObject,
        options?: boolean | EventListenerOptions
    ): void;
}

image.png

image.png

image.png

image.png

有興趣的同學可以翻一翻 lib.dom.d.ts 接口定義,有時會比翻文檔來的清楚:

  • Window
  • Worker
  • ServiceWorker
  • BroadcastChannel
  • MessagePort

綜上我們可以整一個終極縫合怪來適配所有接口:

// 消息發送對象的接口定義
interface AbstractMessageSendEndpoint {
    // BroadcastChannel
    postMessage(message: any): void;
    // Wroker && ServiceWorker && MessagePort
    postMessage(message: any, transfer: Transferable[]): void;
    postMessage(message: any, options?: StructuredSerializeOptions): void;
    // window
    postMessage(message: any, options?: WindowPostMessageOptions): void;
    postMessage(message: any, targetOrigin: string, transfer?: Transferable[]): void;
}

// 消息接收對象的接口定義
interface AbstractMessageReceiveEndpoint extends EventTarget, AbstractMessageSendEndpoint {
    onmessage?: ((this: AbstractMessageReceiveEndpoint, ev: MessageEvent) => any) | null;
    onmessageerror?: ((this: AbstractMessageReceiveEndpoint, ev: MessageEvent) => any) | null;
    close?: () => void;
    start?: () => void;

    addEventListener<K extends keyof MessagePortEventMap>(
        type: K,
        listener: (this: AbstractMessageReceiveEndpoint, ev: MessagePortEventMap[K]) => any,
        options?: boolean | AddEventListenerOptions
    ): void;
    addEventListener(
        type: string,
        listener: EventListenerOrEventListenerObject,
        options?: boolean | AddEventListenerOptions
    ): void;
    removeEventListener<K extends keyof MessagePortEventMap>(
        type: K,
        listener: (this: AbstractMessageReceiveEndpoint, ev: MessagePortEventMap[K]) => any,
        options?: boolean | EventListenerOptions
    ): void;
    removeEventListener(
        type: string,
        listener: EventListenerOrEventListenerObject,
        options?: boolean | EventListenerOptions
    ): void;
}

需要注意 postMessage 接口定義,實際使用 WindowPostMessageOptions 接口即可涵蓋目前所有的消息通信,其包含了 targetOrigintransfer 配置。

interface StructuredSerializeOptions {
    transfer?: Transferable[];
}

interface WindowPostMessageOptions extends StructuredSerializeOptions {
    targetOrigin?: string;
}

interface AbstractMessageSendEndpoint {
    postMessage(message: any, options?: WindowPostMessageOptions): void;
}

最終的事件初始化選項接口如下,新增了一個 config 配置項用於給 postMessage 傳遞配置參數:

interface RPCMessageEventOptions {
    currentEndpoint: AbstractMessageReceiveEndpoint;
    targetEndpoint: AbstractMessageSendEndpoint;
    config?:
        | ((data: any, context: AbstractMessageSendEndpoint) => WindowPostMessageOptions)
        | WindowPostMessageOptions;
}

具體封裝實現可以戳這裏看 RPCMessageEvent 的實現,面向接口的設計可以很好將同一類問題歸一抽象,即使往後瀏覽器新增了新的通信機制,只要其還滿足這套接口配置,那我們的封裝就還是有效的。

遠程過程調用(RPC)

經過上面的封裝我們得到一個基於事件驅動的消息通信工具,但這還不夠,因為其使用還較為原子化(原始),處理消息回覆顯得繁瑣,舉個例子:

import { RPCMessageEvent } from 'rpc-shooter';
// main
const mainEvent = new RPCMessageEvent({
    currentEndpoint: window,
    targetEndpoint: iframe.contentWindow,
    config: {
        targetOrigin: '*',
    },
});
mainEvent.on('reply:max', (data) => {
    console.log('invoke max result:', data);
});
mainEvent.emit('max', 1, 2);

// child
const childEvent = new RPCMessageEvent({
    currentEndpoint: window,
    targetEndpoint: window.top,
});
childEvent.on('max', (a, b) => {
    const result = Math.max(a, b);
    childEvent.emit('reply:max', result);
});

main 中調用 childmax 方法時還需要監聽一個 child 中的回覆(reply:max)事件,child 接受消息調用方法成功後也需要 emit 一個 reply:max 事件。這一來一回並不優雅,眼不看為淨,還需要再做一層封裝包裝事件的觸發與響應。

promisify

異步事件自然使用 Promise 比較合理,封裝也比較簡單:

// child
function registerMethod(method: string, handler: RPCHandler) {
    const synEventName = `syn:${method}`;
    const ackEventName = `ack:${method}`;
    const synEventHandler = (data) => {
        Promise.resolve(handler(data.params)).then((result) => {
            this._event.emit(ackEventName, result);
        });
    };
    this._event.on(synEventName, synEventHandler);
}
registerMethod('max', ([a, b]) => Math.max(a, b));

// main
function invoke(method: string, params: any): Promise<any> {
    return new Promise((resolve) => {
        const synEventName = `syc:${method}`;
        const ackEventName = `ack:${method}`;
        this._event.emit(synEventName, params);
        this._event.on(ackEventName, (res) => {
            resolve(res);
        });
    });
}
invoke('max', [1, 2]).then((res) => {
    console.log(res);
});

調用方 emit 一個帶有 syc: 前綴的事件,被調用方註冊並監聽同名事件,消息調用成功後回覆一個帶 ack: 前綴事件,調用方監聽 ack: 事件標識一次消息相應成功,Promise.resolve。

promisify 簡單,但實際使用消息通信會遇到各種各樣的問題:

  • 遠程方法調用錯誤
  • 調用方法不存在
  • 連接超時
  • 數據格式錯誤(如 worker 中錯誤傳遞了無法序列化 dom 對象)
  • ......

針對通信過程各種情況我們需要將其描述出來。

實際上網頁消息通信過程與 RPC 調用十分類似,可類比於調用遠程服務的方法。而剛好有個 JSON-RPC 協議規範可以十分簡單清晰描述此過程,不妨借來用一用。

JSON-RPC

JSON-RPC 是一個無狀態且輕量級的遠程過程調用(RPC)協議。 本規範主要定義了一些數據結構及其相關的處理規則。它允許運行在基於 socket,http 等諸多不同消息傳輸環境的同一進程中。其使用JSON(RFC 4627)作為數據格式。

相對動則幾百頁 http 協議規範,JSON-RPC 的規範很簡單,只有一頁,有興趣的同學可以研究下 JSON-RPC 2.0 規範。

這裏主要看一下 JSON-RPC 定義請求與響應的數據格式:

// 錯誤對象
interface RPCError {
    code: number;
    message: string;
    data: any;
}

// RPC 請求對象
interface RPCSYNEvent {
    jsonrpc: '2.0';
    method: string;
    params: any;
    id?: string;
}

// RPC 響應
interface RPCSACKEvent {
    jsonrpc: '2.0';
    result?: any;
    error?: RPCError;
    id?: string;
}

帶索引數組參數的 rpc 調用:

--> {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}
<-- {"jsonrpc": "2.0", "result": 19, "id": 1}

通知:

--> {"jsonrpc": "2.0", "method": "update", "params": [1,2,3,4,5]}
--> {"jsonrpc": "2.0", "method": "foobar"}

不包含調用方法的 rpc 調用:

--> {"jsonrpc": "2.0", "method": "foobar", "id": "1"}
<-- {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "1"}

f29d024d03b32c0a94b8460f8bbe25f.jpg

規範中最重要的幾條規則如下:

id

已建立客户端的唯一標識 id,值必須包含一個字符串、數值或 NULL 空值。如果不包含該成員則被認定為是一個通知。該值一般不為 NULL[[1]](http://wiki.geekdream.com/Spe...),若為數值則不應該包含小數[[2]](http://wiki.geekdream.com/Spe...)。

每次調用需要有個唯一 id 標識此次調用,因為我們可能會多次調用同一個遠程服務,需要需要有個 id 來標識每次調用。如果沒有 id 則表示調用方並不關心調用結果(表示此次調用是一次通知)。

errorresult

響應對象必須包含 result 或 error 成員,但兩個成員必須不能同時包含。

調用失敗返回 error,result 為空,調用成功返回 result,error 為空,有 error 對象時則表示調用失敗。

JOSN-RPC 協議簡單明瞭描述數據請求與響應,我們只需要按照其要求封裝 Promise 調用,成功時 resolve 失敗時 reject 即可。

封裝實現

還是老規矩,先看一樣接口定義:

interface RPCHandler {
    (...args: any[]): any;
}

interface RPCEvent {
    emit(event: string, ...args: any[]): void;
    on(event: string, fn: RPCHandler): void;
    off(event: string, fn?: RPCHandler): void;
}

interface RPCInitOptions {
    event: RPCEvent;
    methods?: Record<string, RPCHandler>;
    timeout?: number;
}

interface RPCInvokeOptions {
    isNotify: boolean;
    timeout?: number;
}

declare class RPC {
    private _event;
    private _methods;
    static uuid(): string;
    constructor(options: RPCInitOptions);
    registerMethod(method: string, handler: RPCHandler): void;
    removeMethod(method: string): void;
    invoke(method: string, params: any, options?: RPCInvokeOptions): Promise<any>;
}

具體封裝可看 RPC 實現,最終 RPC 工具方式如下:

// main.ts
import { RPCMessageEvent, RPC } from 'rpc-shooter';

(async function () {
    const iframe = document.querySelector('iframe')!;
    const rpc = new RPC({
        event: new RPCMessageEvent({
            currentEndpoint: window,
            targetEndpoint: iframe.contentWindow!,
            config: { targetOrigin: '*' },
        }),
        // 初始化時註冊處理函數
        methods: {
            'Main.max': (a: number, b: number) => Math.max(a, b),
        },
    });
    // 動態註冊處理函數
    rpc.registerMethod('Main.min', (a: number, b: number) => {
        return Promise.resolve(Math.min(a, b));
    });

    // 調用 iframe 服務中的註冊方法
    const randomValue = await rpc.invoke('Child.random', null, { isNotify: false, timeout: 2000 });
    console.log(`Main invoke Child.random result: ${randomValue}`);
})();
// child.ts
import { RPCMessageEvent, RPC } from 'rpc-shooter';
(async function () {
    const rpc = new RPC({
        event: new RPCMessageEvent({
            currentEndpoint: window,
            targetEndpoint: window.top,
        }),
    });

    rpc.registerMethod('Child.random', () => Math.random());

    const max = await rpc.invoke('Main.max', [1, 2]);
    const min = await rpc.invoke('Main.min', [1, 2]);
    console.log({ max, min });
})();

有一點需要注意以下,在 RPC 初始化實際我們只依賴 RPCEvent 接口,瀏覽器的通信是由 RPCMessageEvent 模塊實現的,我們也可將其換成其他的業務實現,如使用 socket.io 來替代 RPCMessageEvent 以達到和服務端通信的目的,又一個面向接口開發的好處。

至此我們完成從基本消息通信到頁面 RPC 服務調用的封裝,對實現細節有興趣的同學可以戳:rpc-shooter 歡迎指教。

附註:Google 專業解決 worker 調用的工具庫 comlink,有生產需要同學可以試試。

其他

rpc-shooter 的開發過程學到不少東西,也是目前自己寫得比較上心的一個小工具,有膽大小夥伴不妨來試試。

個人感受是:

  • TS 真香
  • 接口優先、接口優先、還是接口優先

微信圖片_20220310023005.jpg
over~

Add a new Comments

Some HTML is okay.