使用 promise 實作同步版 postMessage
2023/09/10 更新改用 MessageChannel 替代 messageId 區分每筆溝通
前言:
postMessage 是一個可以跨頁面或是與各種 Worker, MessageChannel 等不在同一個頁面的 js 互相溝通的非同步 API,以下的文章將記錄透過 async/await 處理讓 postMessage 可以模擬成類同步 API,目的是簡化程式碼的架構。讓我們一步一步開始吧。
以下的範例都是用 main page 與 iframe page 溝通做說明。
第一步:讓 main 跟 iframe 互相收發訊息
// main page
window.addEventListener("message", (msgEvt)=>{
console.log(`[iframe-> main]: ${msgEvt.data}`);
});
const iframeWindow= document.querySelector("iframe").contentWindow;
iframeWindow.postMessage('hello iframe. Give me a number.');
// iframe page
window.addEventListener("message", (msgEvt)=>{
console.log(`[main -> iframe]: ${msgEvt.data}`);
msgEvt.postMessage(`Hi main, ${Date.now()}.`);
});
/*-------------------------------------*/
// console output
// [main -> iframe]: Hello iframe. Give me a number.
// [iframe -> main]: Hi main, 1694265150735.
在標準使用方式下,可以看到送訊息跟收訊息在程式碼的段落基本上是分開的,在程式碼的閱讀跟使用上都很不直覺。
假如 main 在短時間內送了兩次訊息, iframe 也會回傳兩次訊息但是無法分清楚是要針對那一個 main 訊息回傳的。
第二步:使用 MessageChannel 區分每次回傳的訊息
// main page
// 新增 messageId 區分訊息
let messageId = 0;
const iframeWindow = document.querySelector("iframe").contentWindow;
const msgChannel1 = new MessageChannel();
msgChannel1.port1.onmessage = (msgEvt) => {
const {message, messageId} = msgEvt.data;
console.log(`[iframe -> main][${messageId}]: ${message}`);
};
iframeWindow.postMessage({message:"Hello iframe. Give me a number.", messageId: `msg_${++messageId}`}, location.origin , [msgChannel1.port2]);
const msgChannel2 = new MessageChannel();
msgChannel2.port1.onmessage = (msgEvt) => {
const {message, messageId} = msgEvt.data;
console.log(`[iframe -> main][${messageId}]: ${message}`);
};
iframeWindow.postMessage({message:"Hello iframe. Give me a number.", messageId: `msg_${++messageId}`}, location.origin , [msgChannel2.port2]);
// iframe page
window.addEventListener("message", (msgEvt)=>{
const {message, messageId} = msgEvt.data;
console.log(`[main -> iframe][${messageId}]: ${message}`);
// 改用 message port 來回傳訊息,這點也比原本直接用 postMessage 方便。
const messagePort = msgEvt.ports?.[0];
messagePort?.postMessage({ message: `Hi main, ${Date.now()}.`, messageId });
});
/*-------------------------------------*/
// console output
// [main -> iframe][msg_1]: Hello iframe. Give me a number.
// [main -> iframe][msg_2]: Hello iframe. Give me a number.
// [iframe -> main][msg_1]: Hi main, It's 1694265150735.
// [iframe -> main][msg_2]: Hi main, It's 1694265150735.
原本是採用 messageId 區分每次的訊息來回,採用 MessageChannel 後實際上不再需要有 messageId,但是我還是保留在互相傳送的訊息中,單純為了是在驗證時比較方便。
另外是 messagePort 與 window 的 postMessage 參數不太相同,使用時需要注意一下。
註:後面的步驟 iframe 端就沒有變化所以就不再列出相關的程式碼
第三步,包裝 postMessage
在可以區分每次來回的 message 後,再做個幾個 function 把 postMessage 包裝一下,讓使用上簡單一些。
// main page
function genMessageChannel() {
return new MessageChannel();
}
function getMsgId(){
messageId += 1;
return "msg_" + messageId;
}
function postMessageWrap(msg){
const msgId = getMsgId();
const msgChannel = genMessageChannel();
msgChannel.port1.onmessage = (msgEvt) => {
const {message, messageId} = msgEvt.data;
console.log(`[iframe -> main][${messageId}]: ${message}`);
msgChannel.port1.close();
msgChannel.port2.close();
};
iframeWindow.postMessage({
message: msg,
messageId: msgId
},
location.origin,
[msgChannel.port2]
);
}
// 使用包裝過的 postMessage function
postMessageWrap("Hello iframe. Give me a number.");
第四步,讓 postMessageWrap 變成 async function
讓 postMessageWrap 回傳的一個 Promise,在 port1 收到訊息後再用 reslove 回傳 iframe 回傳的訊息,達成這次的主要目標將 postMessage 變成 可同步的 function。
// main page
const iframeWindow = document.querySelector("iframe").contentWindow;
// 新增 messageId 區分訊息
let messageId = 0;
function getMsgId(){
messageId += 1;
return "msg_" + messageId;
}
function genMessageChannel() {
return new MessageChannel();
}
// 把 function 加上 async 設定,以及回傳一個 promise
async function postMessageWrap(msg){
const msgId = getMsgId();
const msgChannel = genMessageChannel();
const p = new Promise((reslove) => {
msgChannel.port1.onmessage = (evt) => {
reslove(evt.data);
msgChannel.port1.close();
msgChannel.port2.close();
};
});
iframeWindow.postMessage(
{
type: "awaitppostmessage",
message: msg,
messageId: msgId,
},
location.origin
);
// 回傳 promise
return p;
}
// 使用包裝過的 postMessage function,加上 await 等待 callback 完成
const result1 = await postMessageWrap("Hello iframe. Give me a number.");
console.log(`[iframe-> main][${result1.messageId}]: ${result1.message}`);
const result2 = await postMessageWrap("Hello iframe. Give me a number.");
console.log(`[iframe-> main][${result2.messageId}]: ${result2.message}`);
/*-------------------------------------*/
// iframe console output
// [main -> iframe][msg_1]: Hello iframe. Give me a number.
// [iframe -> main][msg_1]: Hi main, 1694271556117.
// [main -> iframe][msg_2]: Hello iframe. Give me a number.
// [iframe -> main][msg_2]: Hi main, 1694271556118.
在最後的版本,已經將 postMessage 封裝成一個可以 await function,在程式碼的閱讀及使用上有比較友善的表現。
以上的程式碼有上傳到 github 做一個簡單的 Demo,提供大家參考及指教。
另外也打算做成更完整的 module,讓使用更模組化。
最後再次感謝 FB JavaScript.tw 社團中的 蔡牧村 提出改用 MessageChannel 的做法,讓程式可以更進一步的精簡及單純。
參考資料:
Window.postMessage
Worker
MessageChannel
MessageChannel.postMessage
Async
Await