postMessage()
是少數可以讓兩個不同頁面交換訊息的方式。如其名,傳遞訊息,postMessage()
接收一段文字訊息,將這個文字訊息傳遞給通知的對象。通知的對象可以監聽message
事件獲取訊息。
postMessage()
實際上現在瀏覽器網頁API上,存在的postMessage()
API不只一個。有window.postMessage()
、Worker.postMessage()
、BroadcastChannel.postMessage()
和Client.postMessage()
,它們有著類似的使用方式。除了不同頁面溝通外,對於建立的Web Worker執行緒也有相似方式傳遞訊息,除Worker.postMessage()
外,在Web Worker環境下還可以建立BroadcastChannel
物件,使用BroadcastChannel.postMessage()
方法。至於Client.postMessage()
是在Service Worker使用的,有在寫PWA(Progressive Web Application,漸進式網頁應用程式)才比較會用到。
本節主要討論的是最基本的window.postMessage()
。
基本上的用法可以傳遞一個字串作為訊息發送出去,像是window.postMessage("msg")
。然後監聽message
事件。所以我們可以做一個簡單的例子。
現在建立兩個頁面index.html
作為主畫面,sub.html
作為用iframe
嵌入在主畫面的子畫面。
主畫面內容如下,除了一個發送訊息的文字話框外,還有一個接收訊息的文字畫框,以及一個發送訊息的按鈕。
<!------ index.html ------>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>[DEMO] postMessage (Main)</title>
</head>
<body>
<form>
<textarea cols="30" id="msg" name="" rows="10"></textarea>
<br/>
<textarea cols="30" id="rev" name="" rows="10" disabled></textarea>
<br/>
<button>send</button>
</form>
<hr/>
<iframe frameborder="0" src="sub.html"></iframe>
<script type="text/javascript" src="main.js"></script>
</body>
</html>
相對來說子畫面就簡單一些,只有一個接受訊息的地方。它會將接受到的訊息原封不動的回傳回去。
<!------ sub.html ------>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>[DEMO] postMessage (Sub)</title>
</head>
<body>
<form>
<textarea cols="30" id="rev" name="" rows="10" disabled></textarea>
<br/>
</form>
<script type="text/javascript" src="sub.js"></script>
</body>
</html>
reciveMessage()
是最基本的訊息事件監聽處理器。它一接收到訊息,確定來源正確,除了將訊息打印在接收文字話框外,就又將訊息向來源發送回去。
function reciveMessage(event) {
let {data, origin, source} = event;
if(origin !== window.location.origin)
return;
rev.value += data;
rev.value += `
--------------
`
if(data.match(/^ECHO/))
return;
source.postMessage(`ECHO(Sub): ${data}`);
}
這麼一來,要建立子畫面的JavaScript腳本也就容易了,以下是sub.js
的部分內容,即監聽message
事件。
/*** sbu.js ****/
window.addEventListener('load', (event) => {
let rev = document.querySelector('#rev');
function reciveMessage(event) { /*** 中略 ***/ }
window.addEventListener('message', reciveMessage);
})
主畫面除了上面幾本內容外,還需要在點擊發送訊息按鈕後,將訊息傳遞給子畫面:
send.addEventListener('click', (event) => {
event.preventDefault();
let _msg = msg.value;
iframe.contentWindow.postMessage(_msg, window.location.origin);
})
所以下面是更完整的main.js
內容:
/*** main.js ****/
window.addEventListener('load', () => {
let msg = document.querySelector('#msg');
let rev = document.querySelector('#rev');
let send = document.querySelector('button');
let iframe = document.querySelector('iframe');
function reciveMessage(event) { /*** 中略 ***/ }
window.addEventListener('message', reciveMessage);
send.addEventListener('click', (event) => {
event.preventDefault();
let _msg = msg.value;
iframe.contentWindow.postMessage(_msg, window.location.origin);
})
})
window.postMessage()
的基本用法window.postMessage()
的第二個參數可以指名對象的origin
,避免將訊息傳遞到不該接受訊息的頁面,洩漏訊息。origin
包含協議(HTTP或HTTPs)、domain、port等部分,必須完全一致才會真的將訊息傳遞出去,否則將會報錯。如果並不在意訊息傳遞何處,允許任意頁面處理非敏感訊息,可以直接用萬用字元*
表示。
實際上第一個參數也不是只有接受字串類型,多數的JavaScript物件都可以,它會自動將物件透過結構化複製(The structured clone algorithm)序列化,然後在接收端反序列化為物件。
比如現在有一個bob
的簡單物件:
var bob = { name: "Bob" }
將接收端收到的訊息,設定到全域變數的data
上:
iframe.contentWindow.addEventListener('message',
(event) => globalThis.data = event.data);
然後將物件發送出去:
iframe.contentWindow.postMessage(bob);
現在globalThis
包含data
屬性,長的和bob
一模一樣:
console.log(globalThis.data); // {name: bob}
但是bob
和data
是不同物件:
data === bob; // false
要注意的是,多數JavaScript類型都可以,但有少部分類型無法進行結構化複製,像是ES6後的基本類型Symbol
,故使用時仍需多加留意。
message
事件message
事件除了包含傳遞進來的資料data
外,還有發送來源的origin
。可以透過檢查origin
是否為可信任的來源。除此之外,還有一個source
物件,用以回應訊息。
也就是說,在主頁面傳遞訊息給子頁面後,子頁面可以保留source
物件,建立雙向的溝通連線。在上例中只是簡單的將訊息重複而已,你可以建立更為複雜的溝通流程。
實際上postMessage
還有一個我非常少使用到而經常被忽略的參數transfer
。如上所說,多數資料(data
)傳遞方式是透過「複製」,「複製」存在一定的成本,尤其是針對非常大的物件的時候。如果可以直接轉移所有權,就可以減少複製的動作,提升效能、保護地球。
不過也不是所有物件都可以轉移的,必須是Transferable Objects,如:
ArrayBuffer
MessagePort
ReadableStream
WritableStream
TransformStream
AudioData
ImageBitmap
VideoFrame
OffscreenCanvas
RTCDataChannel
其中最簡單的就是ArrayBuffer
。以同樣上面的實驗環境,同樣將接受到的物件儲存到globalThis
:
iframe.contentWindow.addEventListener('message',
(event) => globalThis.data = event.data);
不同的是現在要傳遞的物件是一個ArrayBuffer
:
var buff = new ArrayBuffer(8);
console.log(buff.byteLength); // 8
然後用類似方式將物件發送出去,不同的是這次申明了轉移所有權:
iframe.contentWindow.postMessage(buff, "*", [buff]);
在最後的結果會發現buff
的byteLength
變成了0
:
console.log(globalThis.data.byteLength); // 8
console.log( buff.byteLength); // 0
MessagePort
是一個可轉移類型。並且不是只有window.postMessage()
,更有用的地方或許在Web Worker--Worker.postMessage()
。可以用於建立兩個worker之間的溝通。
// 以下參考自: http://xiefeng.tech/Web%20Worker.html#messagechannel
const channel = new MessageChannel();
const workerA = new Worker('./worker.js');
const workerB = new Worker('./worker.js');
workerA.postMessage('workerA', [channel.port1]);
workerB.postMessage('workerB', [channel.port2]);
儘管Worker.postMessage()
和window.postMessage()
的用法不全然相同。但透過轉移大資料,比如上傳的檔案、相機擷取到的影像等,如果需要進行大量運算,會交由Web Worker處理,減少UI的卡頓。但如果使用複製方式,資料量大的話一樣容易造成畫面UI卡頓問題。使用轉移方式可以降低成本、提升效率。
突然想到的。其實這有點像是Go語言的Channel:
// https://gobyexample.com/channels
package main
import "fmt"
func main() {
messages := make(chan string)
go func() { messages <- "ping" }()
msg := <-messages
fmt.Println(msg)
}
channel的這種設計方式,也可以算是一種設計模式。儘管Go語言中的主要是兩個gorounting,而不是傳統意義上的thread,但這種設計可以簡化異步(含不同threat之間)溝通的複雜情況。現在在C#也可以使用System.Threading.Channels
。
本文同時發表於我的隨筆