JSON-RPC & postMessage 談?wù)劄g覽器消息通信的封裝技巧
大廠技術(shù)??堅持周更??精選好文
楔子
postMessage 常見于內(nèi)嵌 iframe 或是 Web Workers 中,用于跨頁面(線程) 的消息通信,在一些其他開發(fā)環(huán)境中也能看到類似的影子,如 Chrome 插件環(huán)境、Electron 環(huán)境、figma 插件等。
最近的工作需要經(jīng)常與 iframe 與 Web Workers 打交道,處理頁面與內(nèi)嵌頁、主線程與 worker 通信,擼了個用于處理瀏覽器消息通信的處理的工具庫 rpc-shooter,涵蓋了瀏覽器主要的消息通信的接口支持:
Window Worker SharedWorker ServiceWorker MessageChannel BroadcastChannel MessagePort
在此分享一些開發(fā)過程中的經(jīng)驗與技巧。
原教旨主義
先來看一個 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
????}
});
使用原教旨主義的寫法可以很容寫出上述代碼,處理簡單消息通信不會有什么問題,但針對復(fù)雜場景下跨頁面(線程)通信則需要有個簡單有效機制來維護消息通信。
聰明的你一定想到了基于統(tǒng)一消息格式配合對應(yīng)的消息處理策略來維護消息事件的方法調(diào)用,很簡單的機制,卻很好用:
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],
????});
});
使用上述的處理方式,消息通信的處理維護一份策略處理函數(shù)即可,接下來的工作也是建立在此基礎(chǔ)上的,加一點“細節(jié)”即可。
事件封裝
消息通信本身是事件的一種,所以不妨往事件封裝的方向靠,這時候就有很多可以借鑒的接口設(shè)計了,這里可以借鑒 socket.io 的接口設(shè)計。相對與本地事件調(diào)用,消息通信則本質(zhì)是監(jiān)聽遠程服務(wù)所發(fā)出的事件,與 socket.io 類似:
//?client
socket.emit('join-in',?input.value);
//?server
socket.on('join-in',(user)?=>?{...});
面向接口
對于一個工具函數(shù)(庫)的封裝設(shè)計,最好是從接口開始,接口設(shè)計可以直接決定最終工具使用形式。這也是 Typescript 帶來的開發(fā)模式轉(zhuǎn)變,面向接口的設(shè)計,可以幫助我們更好組裝模塊以達到解耦的目的。
封裝的接口格式定義:
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>;
????constructor(options:?RPCMessageEventOptions)?{
????????this._events?=?{};
????????this._currentEndpoint?=?options.currentEndpoint;
????????this._targetEndpoint?=?options.targetEndpoint;
????????this._targetOrigin?=?options.targetOrigin;
????????//?監(jiān)聽遠程消息事件
????????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);
????}
}
經(jīng)典的事件實現(xiàn),這里不做贅述,使用方式如下:
//?父級頁面
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');
思考一個問題,上述實現(xiàn)了父子級 window 對象的消息通信封裝,能否將其一般化支持到所有瀏覽器消息事件?
答案是肯定的,看一眼事件的 Window 封裝初始化選項:
interface?RPCMessageEventOptions?{
????currentEndpoint:?Window;
????targetEndpoint:?Window;
????targetOrigin:?string;
}
這里的事件接收與發(fā)送對象都是 Window,但實際上我們只是依賴了:
currentEndpoint 上的 message事件targetEndpoint 上的 postMessage方法與其配置
換言之,只要瀏覽器中的其他對象支持 message 事件與 postMessage 方法即可實現(xiàn)同樣的封裝,即滿足接口即可。
interface?RPCMessageEventOptions?{
????currentEndpoint:?{
????????addEventListenerextends?keyof?MessagePortEventMap>(
????????????type:?K,
????????????listener:?(
????????????????this:?RPCMessageEventOptions['currentEndpoint'],
????????????????ev:?MessagePortEventMap[K]
????????????)?=>?any,
????????????options?:?boolean?|?AddEventListenerOptions
????????):?void;
????};
????targetEndpoint:?{
????????postMessage(message:?any,?...args:?any[]):?void;
????};
}
瀏覽器中通信接口
以下為目前瀏覽器主要支持消息通信的對象,其都實現(xiàn)了類似消息事件接口:
interface?MessagePort?extends?EventTarget?{
????postMessage(message:?any,?transfer:?Transferable[]):?void;
????postMessage(message:?any,?options?:?StructuredSerializeOptions):?void;
????addEventListenerextends?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;
}




有興趣的同學(xué)可以翻一翻 lib.dom.d.ts 接口定義,有時會比翻文檔來的清楚:
Window Worker ServiceWorker BroadcastChannel MessagePort
綜上我們可以整一個終極縫合怪來適配所有接口:
//?消息發(fā)送對象的接口定義
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;
????addEventListenerextends?keyof?MessagePortEventMap>(
????????type:?K,
????????listener:?(this:?AbstractMessageReceiveEndpoint,?ev:?MessagePortEventMap[K])?=>?any,
????????options?:?boolean?|?AddEventListenerOptions
????):?void;
????addEventListener(
????????type:?string,
????????listener:?EventListenerOrEventListenerObject,
????????options?:?boolean?|?AddEventListenerOptions
????):?void;
????removeEventListenerextends?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 接口即可涵蓋目前所有的消息通信,其包含了 targetOrigin 與 transfer 配置。
interface?StructuredSerializeOptions?{
????transfer?:?Transferable[];
}
interface?WindowPostMessageOptions?extends?StructuredSerializeOptions?{
????targetOrigin?:?string;
}
interface?AbstractMessageSendEndpoint?{
????postMessage(message:?any,?options?:?WindowPostMessageOptions):?void;
}
最終的事件初始化選項接口如下,新增了一個 config 配置項用于給 postMessage 傳遞配置參數(shù):
interface?RPCMessageEventOptions?{
????currentEndpoint:?AbstractMessageReceiveEndpoint;
????targetEndpoint:?AbstractMessageSendEndpoint;
????config?:
????????|?((data:?any,?context:?AbstractMessageSendEndpoint)?=>?WindowPostMessageOptions)
????????|?WindowPostMessageOptions;
}
具體封裝實現(xiàn)可以戳這里看 RPCMessageEvent 的實現(xiàn),面向接口的設(shè)計可以很好將同一類問題歸一抽象,即使往后瀏覽器新增了新的通信機制,只要其還滿足這套接口配置,那我們的封裝就還是有效的。
遠程過程調(diào)用(RPC)
經(jīng)過上面的封裝我們得到一個基于事件驅(qū)動的消息通信工具,但這還不夠,因為其使用還較為原子化(原始),處理消息回復(fù)顯得繁瑣,舉個例子:
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);
});
當(dāng) main 中調(diào)用 child 的 max 方法時還需要監(jiān)聽一個 child 中的回復(fù)(reply:max)事件,child 接受消息調(diào)用方法成功后也需要 emit 一個 reply:max 事件。這一來一回并不優(yōu)雅,眼不看為凈,還需要再做一層封裝包裝事件的觸發(fā)與響應(yīng)。
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);
});
調(diào)用方 emit 一個帶有 syc: 前綴的事件,被調(diào)用方注冊并監(jiān)聽同名事件,消息調(diào)用成功后回復(fù)一個帶 ack: 前綴事件,調(diào)用方監(jiān)聽 ack: 事件標(biāo)識一次消息相應(yīng)成功,Promise.resolve。
promisify 簡單,但實際使用消息通信會遇到各種各樣的問題:
遠程方法調(diào)用錯誤 調(diào)用方法不存在 連接超時 數(shù)據(jù)格式錯誤(如 worker 中錯誤傳遞了無法序列化 dom 對象) ......
針對通信過程各種情況我們需要將其描述出來。
實際上網(wǎng)頁消息通信過程與 RPC 調(diào)用十分類似,可類比于調(diào)用遠程服務(wù)的方法。而剛好有個 JSON-RPC 協(xié)議規(guī)范可以十分簡單清晰描述此過程,不妨借來用一用。
JSON-RPC
JSON-RPC是一個無狀態(tài)且輕量級的遠程過程調(diào)用(RPC)協(xié)議。本規(guī)范主要定義了一些數(shù)據(jù)結(jié)構(gòu)及其相關(guān)的處理規(guī)則。它允許運行在基于socket,http等諸多不同消息傳輸環(huán)境的同一進程中。其使用JSON(RFC 4627)作為數(shù)據(jù)格式。
相對動則幾百頁 http 協(xié)議規(guī)范,JSON-RPC 的規(guī)范很簡單,只有一頁,有興趣的同學(xué)可以研究下 JSON-RPC 2.0 規(guī)范。
這里主要看一下 JSON-RPC 定義請求與響應(yīng)的數(shù)據(jù)格式:
//?錯誤對象
interface?RPCError?{
????code:?number;
????message:?string;
????data:?any;
}
//?RPC?請求對象
interface?RPCSYNEvent?{
????jsonrpc:?'2.0';
????method:?string;
????params:?any;
????id?:?string;
}
//?RPC?響應(yīng)
interface?RPCSACKEvent?{
????jsonrpc:?'2.0';
????result?:?any;
????error?:?RPCError;
????id?:?string;
}
帶索引數(shù)組參數(shù)的 rpc 調(diào)用:
-->?{"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"}
不包含調(diào)用方法的rpc調(diào)用:
-->?{"jsonrpc":?"2.0",?"method":?"foobar",?"id":?"1"}
<--?{"jsonrpc":?"2.0",?"error":?{"code":?-32601,?"message":?"Method?not?found"},?"id":?"1"}

規(guī)范中最重要的幾條規(guī)則如下:
id
已建立客戶端的唯一標(biāo)識id,值必須包含一個字符串、數(shù)值或NULL空值。如果不包含該成員則被認定為是一個通知。該值一般不為NULL[1],若為數(shù)值則不應(yīng)該包含小數(shù)[2]。
每次調(diào)用需要有個唯一 id 標(biāo)識此次調(diào)用,因為我們可能會多次調(diào)用同一個遠程服務(wù),需要需要有個 id 來標(biāo)識每次調(diào)用。如果沒有 id 則表示調(diào)用方并不關(guān)心調(diào)用結(jié)果(表示此次調(diào)用是一次通知)。
error 和 result
響應(yīng)對象必須包含result或error成員,但兩個成員必須不能同時包含。
調(diào)用失敗返回 error,result 為空,調(diào)用成功返回 result,error 為空,有 error 對象時則表示調(diào)用失敗。
JOSN-RPC 協(xié)議簡單明了描述數(shù)據(jù)請求與響應(yīng),我們只需要按照其要求封裝 Promise 調(diào)用,成功時 resolve 失敗時 reject 即可。
封裝實現(xiàn)
還是老規(guī)矩,先看一樣接口定義:
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 實現(xiàn),最終 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:?'*'?},
????????}),
????????//?初始化時注冊處理函數(shù)
????????methods:?{
????????????'Main.max':?(a:?number,?b:?number)?=>?Math.max(a,?b),
????????},
????});
????//?動態(tài)注冊處理函數(shù)
????rpc.registerMethod('Main.min',?(a:?number,?b:?number)?=>?{
????????return?Promise.resolve(Math.min(a,?b));
????});
????//?調(diào)用?iframe?服務(wù)中的注冊方法
????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 模塊實現(xiàn)的,我們也可將其換成其他的業(yè)務(wù)實現(xiàn),如使用 socket.io 來替代 RPCMessageEvent 以達到和服務(wù)端通信的目的,又一個面向接口開發(fā)的好處。
至此我們完成從基本消息通信到頁面 RPC 服務(wù)調(diào)用的封裝,對實現(xiàn)細節(jié)有興趣的同學(xué)可以戳:rpc-shooter 歡迎指教。
附注:Google 專業(yè)解決 worker 調(diào)用的工具庫 comlink,有生產(chǎn)需要同學(xué)可以試試。
其他
rpc-shooter 的開發(fā)過程學(xué)到不少東西,也是目前自己寫得比較上心的一個小工具,有膽大小伙伴不妨來試試。
個人感受是:
TS 真香 接口優(yōu)先、接口優(yōu)先、還是接口優(yōu)先
???H5-Dooring,讓H5制作更簡單
???謝謝支持
以上便是本次分享的全部內(nèi)容,希望對你有所幫助^_^
喜歡的話別忘了?分享、點贊、收藏?三連哦~。
歡迎關(guān)注公眾號?趣談前端?收獲前端一手好文章~

