浏览器跨 Tab 窗口通信原理及应用实践
最近,相信大家一定被這么個動效給刷屏了:
以至于,基于這個效果的二次創(chuàng)作層出不窮,眼花繚亂。
基于跨窗口通信的彈彈球:
基于跨窗口通信的 Flippy Bird:
我也嘗試制作了一個跨 Tab 窗口的 CSS 動畫聯(lián)動,效果如下:
代碼不多,核心代碼 200 行,感興趣的可以戳這里:Github - broadcastAnimation
當然,本文的核心不是去一一剖析上面的效果具體的實現(xiàn)方式,而是講講其中比較關鍵的一個技術點:
而是應用如何在多窗口下進行互相通信。
所謂多窗口下進行互相通信,是指在瀏覽器中,不同窗口(包括不同標簽頁、不同瀏覽器窗口甚至不同瀏覽器實例)之間進行數(shù)據(jù)傳輸和通信的能力。
當然,本文我們探討的是純前端的跨 Tab 頁面通信,在非純前端的方式下,我們可以借助諸如 Web Socket 等方式,藉由后端這個中間載體,進行跨頁面通信。
因此,本文我們更多的重心將放在,如何基于純前端技術,實現(xiàn)多窗口下進行互相通信。
為了實現(xiàn)跨窗口通信,它應該需要具備以下能力:
- 數(shù)據(jù)傳輸能力:能夠將數(shù)據(jù)從一個窗口發(fā)送到另一個窗口,以及接收來自其他窗口的數(shù)據(jù)。
- 實時性:能夠實現(xiàn)實時或近實時的數(shù)據(jù)傳輸,以便及時更新不同窗口的內容。
- 安全性:確保通信過程中的數(shù)據(jù)安全,防止惡意竊取或篡改通信數(shù)據(jù)。當然,這個不是本文討論的重點,但是是實際應用中不應該忽視的一個重點。
方式一:Broadcast Channel()
Broadcast Channel 是一個較新的 Web API,用于在不同的瀏覽器窗口、標簽頁或框架之間實現(xiàn)跨窗口通信。它基于發(fā)布-訂閱模式,允許一個窗口發(fā)送消息,并由其他窗口接收。
其核心步驟如下:
- 創(chuàng)建一個 BroadcastChannel 對象:在發(fā)送和接收消息之前,首先需要在每個窗口中創(chuàng)建一個 BroadcastChannel 對象,使用相同的頻道名稱進行初始化。
- 發(fā)送消息:通過 BroadcastChannel 對象的 postMessage() 方法,可以向頻道中的所有窗口發(fā)送消息。
- 接收消息:通過監(jiān)聽 BroadcastChannel 對象的 message 事件,可以在窗口中接收到來自其他窗口發(fā)送的消息。
同時,Broadcast Channel 遵循瀏覽器的同源策略。這意味著只有在同一個協(xié)議、主機和端口下的窗口才能正常進行通信。如果窗口不滿足同源策略,將無法互相發(fā)送和接收消息。
因為有同源限制,我們需要起一個服務,這里我基于 Vite 快速起了一個 Vue 項目,簡單的基于 .vue 文件下進行一個演示。
其核心代碼非常簡單:
<template>
<div class="g-container" id="j-main">
// ...
</div>
</template>
<script>
import { onMounted } from 'vue';
export default {
setup() {
function createBroadcastChannel() {
broadcastChannel = new BroadcastChannel('broadcast');
broadcastChannel.onmessage = handleMessage;
}
function sendMessage(data) {
broadcastChannel.postMessage(data);
}
function handleMessage(event) {
console.log('接收到 event', event);
// TODO: 處理接收到信息后的邏輯
}
function resizeEventBind() {
window.addEventListener('resize', () => {
const pos = getCurPos();
sendMessage(pos);
});
}
// 計算當前元素距離顯示器窗口右上角的距離
function getCurPos() {
const barHeight = window.outerHeight - window.innerHeight;
const element = document.getElementById('j-main');
const rect = element.getBoundingClientRect();
// 獲取元素相對于屏幕左上角的 X 和 Y 坐標
const x = rect.left + window.screenX; // 元素左邊緣相對于屏幕左邊緣的距離
const y = rect.top + window.screenY + barHeight;// 元素頂部邊緣相對于屏幕頂部邊緣的距離
return [x, y];
}
onMounted(() => {
createBroadcastChannel();
resizeEventBind();
});
return {};
}
};
</script>
<style lang="scss"></style>
這里,我們的核心邏輯在于:
-
createBroadcastChannel()函數(shù)用于創(chuàng)建一個 BroadcastChannel 對象,并設置消息處理函數(shù)。 -
sendMessage(data)函數(shù)用于向 BroadcastChannel 發(fā)送消息。 -
handleMessage(event)函數(shù)用于處理接收到的消息。 -
resizeEventBind()函數(shù)用于監(jiān)聽窗口大小變化事件,并在事件發(fā)生時獲取當前元素的位置信息,并通過sendMessage()函數(shù)發(fā)送位置信息到 BroadcastChannel。 -
getCurPos()函數(shù)用于計算當前元素相對于顯示器窗口右上角的距離。
在 onMounted() 生命周期鉤子中,調用了 createBroadcastChannel() 和 resizeEventBind() 函數(shù),用于在組件掛載后執(zhí)行相關的初始化操作。
這樣,當我們同時打開兩個窗口,移動其中一個窗口,就可以向另外一個窗口發(fā)生當前窗口希望傳遞過去的信息,在本例子中就是 #j-main 元素距離顯示器右上角的距離。
假設 #j-main 只是一個在瀏覽器正中心矩形,我們同時打開兩邊的控制臺,看看會發(fā)生什么:
可以看到,如果我們同時打開兩個一個的頁面,當觸發(fā)右邊頁面的 Resize,左邊的頁面會收到基于 broadcastChannel.onmessage = handleMessage 接收到的信息,反之同理。
而一個完整的 Event 信息如下:
譬如,傳遞過來的信息放在 data 屬性內、同時也可以獲取當前的的 Broadcast Name 等。
基于 BroadcastChannel,就可以實現(xiàn)每個 Tab 內的核心信息互傳, 可以得知當前在線設備數(shù),再基于這些信息去完成我們想要的動畫、交互等效果。
這里的核心點,還是:
- 數(shù)據(jù)向其他 Tab 頁面?zhèn)鬟f的能力
- Tab 頁面接受其他頁面?zhèn)鬟f過來的數(shù)據(jù)的能力
其本質就是一個數(shù)據(jù)共享池子。
方式二:SharedWorker API
好,介紹完 Broadcast Channel(),我們再來看看 SharedWorker API。
SharedWorker API 是 HTML5 中提供的一種多線程解決方案,它可以在多個瀏覽器 TAB 頁面之間共享一個后臺線程,從而實現(xiàn)跨頁面通信。
與其他 Worker 不同的是,SharedWorker 可以被多個瀏覽器 TAB 頁面共享,且可以在同一域名下的不同頁面之間建立連接。這意味著,多個頁面可以通過 SharedWorker 實例之間的消息傳遞,實現(xiàn)跨 TAB 頁面的通信。
它的實現(xiàn)與上面的 Broadcast Channel 非常類似,我們來看一看實際的代碼:
<template>
<div class="g-container" id="j-main">
// ...
</div>
</template>
<script>
import { onMounted } from 'vue';
export default {
setup() {
// 創(chuàng)建一個 SharedWorker 對象
let worker;
function initWorker() {
// 創(chuàng)建一個 SharedWorker 對象
worker = new SharedWorker('/shared-worker.js', 'tabWorker');
// 監(jiān)聽消息事件
worker.port.onmessage = function (event) {
console.log('接收到 event', event);
handleMessage(event);
};
}
function handleMessage(data) {
// TODO: 處理接收到信息后的邏輯
}
function sendMessage(data) {
// 發(fā)送消息
worker.port.postMessage(data);
}
function resizeEventBind() {
window.addEventListener('resize', () => {
const pos = getCurPos();
sendMessage(pos);
});
}
function getCurPos() {
const barHeight = window.outerHeight - window.innerHeight;
const element = document.getElementById('j-main');
const rect = element.getBoundingClientRect();
// 獲取元素相對于屏幕左上角的 X 和 Y 坐標
const x = rect.left + window.screenX; // 元素左邊緣相對于屏幕左邊緣的距離
const y = rect.top + window.screenY + barHeight;// 元素頂部邊緣相對于屏幕頂部邊緣的距離
return [x, y];
}
onMounted(() => {
initWorker();
resizeEventBind();
});
return {};
}
};
</script>
<style lang="scss"></style>
簡單描述一下,上面也說了,跨 Tab 頁通信的核心在于數(shù)據(jù)向外的發(fā)送與接收的能力:
-
initWorker()方法中,使用worker = newSharedWorker('/shared-worker.js', 'tabWorker')創(chuàng)建了一個SharedWorker, 后面每一個被打開的同域瀏覽器 TAB 頁面,都是共享這個 Worker 線程,從而實現(xiàn)跨頁面通信 - 基于
worker.port.postMessage(data)實現(xiàn)數(shù)據(jù)的傳輸 - 基于
worker.port.onmessage = function() {}實現(xiàn)傳輸數(shù)據(jù)的監(jiān)聽
當然,上面有引入一個 /shared-worker.js,這個是需要額外定義的,一個極簡版本的代碼如下:
//shared-worker.js
const connections = [];
onconnect = function (event) {
var port = event.ports[0];
connections.push(port);
port.onmessage = function (event) {
// 接收到消息時,向所有連接發(fā)送該消息
connections.forEach(function (conn) {
if (conn !== port) {
conn.postMessage(event.data);
}
});
};
port.start();
};
簡單解析一下,下面對其進行解析:
- 上面的代碼中,定義了一個數(shù)組 connections,用于存儲與 SharedWorker 建立連接的各個頁面的端口對象;
- onconnect 是事件處理程序,當有新的連接建立時會觸發(fā)該事件;
- 在 onconnect 函數(shù)中,通過 event.ports[0] 獲取到與 SharedWorker 建立的連接的第一個端口對象,并將其添加到 connections 數(shù)組中,表示該頁面與共享 Worker 建立了連接。
- 在連接建立后,為每個端口對象設置了 onmessage 事件處理程序。當端口對象接收到消息時,會觸發(fā)該事件處理程序。
- 在 onmessage 事件處理程序中,通過遍歷 connections 數(shù)組,將消息發(fā)送給除當前連接端口對象之外的所有連接。這樣,消息就可以在不同的瀏覽器 TAB 頁面之間傳遞。
- 最后,通過調用 port.start() 啟動端口對象,使其開始接收消息。
總而言之,shared-worker.js 腳本創(chuàng)建了一個共享 Worker 實例,它可以接收來自不同頁面的連接請求,并將接收到的消息發(fā)送給其他連接的頁面。通過使用 SharedWorker API,實現(xiàn)跨 TAB 頁面之間的通信和數(shù)據(jù)共享。
同理,我們來看看基于 Worker 的數(shù)據(jù)傳輸效果,同樣是簡化 DEMO,當 Resize 窗口時,向另外一個窗口發(fā)送當前窗口下 #j-main 元素的坐標:
可以看到,如果我們同時打開兩個一個的頁面,當觸發(fā)右邊頁面的 Resize,左邊的頁面會利用 worker.port.onmessage = function() {} 收到基于 worker.port.postMessage(data) 發(fā)送的信息,反之同理。
而一個完整的 Event 信息如下:
可以看到,在 SharedWorker 方式中,傳輸數(shù)據(jù)與 Broadcast Channel 是一樣的,都是利用 Message Event。簡單對比一下:
- SharedWorker 通過在多個Tab頁面之間共享相同的 Worker 實例,方便地共享數(shù)據(jù)和狀態(tài),SharedWorker 需要多定義一個
shared-worker.js; - Broadcast Channel 通過向所有訂閱同一頻道的 Tab 頁面廣播消息,實現(xiàn)廣播式的通信。
兼容性方面,到今天(2023-11-26),broadcast Channel 看著是兼容性更好的方式:
另外,需要注意的是,兩個方法都使用了 postMessage 方法。window.postMessage() 方法可以安全地實現(xiàn)跨源通信。并且,本質上而言,單獨使用 postMessage?就可以實現(xiàn)跨 Tab 通信。
但是,單獨使用 postMessage 適合簡單的點對點通信。在更復雜的場景中,Broadcast?Channel?和?SharedWorker?提供更強大的機制,可簡化通信邏輯,有更廣泛的通信范圍和生命周期管理。Broadcast?Channel?的通信范圍是所有訂閱該頻道的窗口,而 SharedWorker?可在多個窗口之間共享狀態(tài)和通信。
方式三:localStorage/sessionStorage
OK,最后一種跨 Tab 窗口通信的方式是利用 localStorage 、sessionStorage 本地化存儲 API 以及的 storage 事件。
與上面 Broadcast Channel、SharedWorker 稍微不同的地方在于:
-
localStorage方式,利用了本地瀏覽器存儲,實現(xiàn)了同域下的數(shù)據(jù)共享; -
localStorage方式,基于window.addEventListener('storage',function(event) {})事件實現(xiàn)了 localStore 變化時候的數(shù)據(jù)監(jiān)聽;
簡單看看代碼:
<template>
<div class="g-container" id="j-main">
// ...
</div>
</template>
<script>
import { ref, reactive, computed, onMounted } from 'vue';
export default {
setup() {
function initLocalStorage() {
let tabArray = JSON.parse(localStorage.getItem('tab_array'));
if (!tabArray) {
const tabIndex = 1;
id = tabIndex;
localStorage.setItem('tab_array', JSON.stringify([tabIndex]));
} else {
const tabIndex = tabArray[tabArray.length - 1] + 1;
id = tabIndex;
const newTabArray = [...tabArray, tabIndex];
localStorage.setItem('tab_array', JSON.stringify(newTabArray));
}
}
function setLocalStorage(data) {
localStorage.setItem(`tab_index_${id}`, JSON.stringify(data));
}
function handleMessage(data) {
const rArray = JSON.parse(data);
remoteX.value = rArray[0];
remoteY.value = rArray[1];
}
function resizeEventBind() {
window.addEventListener('resize', () => {
const pos = getCurPos();
setLocalStorage(pos);
});
window.addEventListener('storage', (event) => {
console.log('localStorage 變化了!', event);
console.log('鍵名:', event.key);
console.log('變化前的值:', event.oldValue);
console.log('變化后的值:', event.newValue);
handleMessage(event.newValue);
});
}
function getCurPos() {
const barHeight = window.outerHeight - window.innerHeight;
const element = document.getElementById('j-main');
const rect = element.getBoundingClientRect();
// 獲取元素相對于屏幕左上角的 X 和 Y 坐標
const x = rect.left + window.screenX; // 元素左邊緣相對于屏幕左邊緣的距離
const y = rect.top + window.screenY + barHeight;// 元素頂部邊緣相對于屏幕頂部邊緣的距離
return [x, y];
}
onMounted(() => {
initLocalStorage();
resizeEventBind();
});
return {};
}
};
</script>
<style lang="scss"></style>
同樣的簡單解析一下:
- 每次頁面初始化時,都會首先有一個
initLocalStorage過程,用于給當前頁面一個唯一 ID 標識,并且存入 localStorage 中 - 每次頁面 resize,將當前頁面元素
#j-main的坐標值,通過 ID 標識當 Key,存入 localStorage 中 - 其他頁面,通過
window.addEventListener('storage', (event)=>{})監(jiān)聽 localStorage 的變化
交互傳輸結果,與上述兩個動圖是一致的,就不額外貼圖了,但是基于 storage 事件傳輸?shù)闹涤悬c不一樣,我們展開看看:
我們通過 window.addEventListener('storage', (event)=>{}) ,可以拿到此次變化的 localStorage key 是什么,前值 oldValue 與 newValue 等等。
當然,由于 localStorage 存儲過程只能是字符串,在讀取的時候需要利用 JSON.stringify 和 JSON.parse 額外處理一層,調試的時候需要注意。
雖然看起來這種方式最不優(yōu)雅,但是結合兼容性一起看,?localstorage?反而是兼容性最好的方式。在數(shù)據(jù)量較小的時候,性能相差不會太大,反而可能是更好的選擇。
我基于上面三種方式:Broadcast Channel、SharedWorker 與 localStorage,都實現(xiàn)了一遍下面這個跨 Tab 頁的 CSS 聯(lián)動動畫:
三種方式的代碼都不多,感興趣的可以戳這里:Github - broadcastAnimation
實際應用思考
當然,上面的實現(xiàn)其實有很大一個瑕疵。
那就是我們只顧著實現(xiàn)通信,沒有考慮實際應用中的一些實際問題:
- 如何確定何時開始通信?
- Tab 頁頻繁的開關,如何知道當前還有多少頁面處于打開狀態(tài)?
基于實際應用,我們需要基于上述 3 種方式,進一步細化方案。
上面,為了方便演示,每次傳輸數(shù)據(jù)時,只傳輸動畫需要的數(shù)據(jù)。而實際應用,我們可以需要細化整個傳輸數(shù)據(jù),設定合理的協(xié)議。譬如:
{
// 傳輸狀態(tài):
// 1 - 首次傳輸
// 2 - 正常通信
// 3 - 頁面關閉
status: 1 | 2 | 3,
data: {}
}
接收方需要基于收到信息所展示的不同的狀態(tài),做出不同的反饋。
當然,還有一個問題,我們如何知道頁面被關閉了?基于組件的 onUnmounted 發(fā)送當前頁面關閉的信息或者基于 window 對象的 beforeunload 事件發(fā)送當前頁面關閉的信息?
這些信息都有可能因為 Tab 頁面失活,導致關閉的信息無法正常被發(fā)送出去。所以,實際應用中,我們經常用的一項技術是心跳上報/心跳廣播,一旦建立連接后,間隔 X 秒發(fā)送一次心跳廣播,告訴其他接收端,我還在線。一旦超過某個時間閾值沒有收到心跳上報,各個訂閱方可以認為該設備已經下線。
總而言之,跨 Tab 窗口通信應用在實際應用的過程中,我們需要思考更多可能隱藏的問題。
跨 Tab 窗口通信應用場景
當然,除了最近大火的跨 Tab 動畫應用場景,實際業(yè)務中,還有許多場景是它可以發(fā)揮作用的。這些場景利用了跨 Tab 通信技術,增強了用戶體驗并提供了更豐富的功能。
以下是一些常見的應用場景:
- 實時協(xié)作:多個用戶可以在不同的 Tab 頁上進行實時協(xié)作,比如編輯文檔、共享白板、協(xié)同編輯等。通過跨Tab通信,可以實現(xiàn)實時更新和同步操作,提高協(xié)作效率。
譬如這個:
- 多標簽頁數(shù)據(jù)同步:當用戶在一個標簽頁上進行了操作,希望其他標簽頁上的數(shù)據(jù)也能實時更新時,可以使用跨 Tab 通信來實現(xiàn)數(shù)據(jù)同步,保持用戶在不同標簽頁上看到的數(shù)據(jù)一致性。
- 跨標簽頁通知:在某些場景下,需要向用戶發(fā)送通知或提醒,即使用戶不在當前標簽頁上也能及時收到。通過跨 Tab 通信,可以實現(xiàn)跨頁面的消息傳遞,向用戶發(fā)送通知或提醒。
- 多標簽頁狀態(tài)同步:有些應用可能需要在不同標簽頁之間同步用戶的狀態(tài)信息,例如登錄狀態(tài)、購物車內容等。通過跨 Tab 通信,可以確保用戶在不同標簽頁上看到的狀態(tài)信息保持一致。
- 頁面間數(shù)據(jù)傳輸:有時候用戶需要從一個頁面跳轉到另一個頁面,并攜帶一些數(shù)據(jù),通過跨Tab通信可以在頁面之間傳遞數(shù)據(jù),實現(xiàn)數(shù)據(jù)的共享和傳遞。
舉幾個實際的例子:
- 某系統(tǒng)是一個國際化電商的倉庫管理系統(tǒng),系統(tǒng)能切換到全球各地不同的倉庫進行數(shù)據(jù)操作,當用戶打開了頁面后,又新開了一個 Tab 頁面,并且切換到另外一個倉庫進行操作。當用戶重新回到第一個打開的頁面時,為了防止用戶錯誤操作數(shù)據(jù)(前端界面是一致的,可能忘記了自己切換過倉庫),通過彈窗提醒用戶你已經切換過倉庫;
- 某音樂播放器 PC 頁面,在列表頁面進行歌曲播放點擊,如果當前沒有音樂播放詳情頁,則打開一個新的播放詳情頁。但是,如果頁面已經存在一個音樂播放詳情頁,則不會打開新的音樂播放詳情頁,而是直接使用已經存在的播放詳情頁面;
- 系統(tǒng)有與列表頁與內容頁,在內容頁點擊已閱,如果用戶同時打開了上級列表頁,要取消列表頁關于該內容頁的未讀的提示;
總之,跨 Tab 窗口通信在實時協(xié)作、數(shù)據(jù)同步、通知提醒等方面都能發(fā)揮重要作用,為用戶提供更流暢、便捷的交互體驗。
最后
本文只羅列了 3 種較為常見,適用性強的方式。除去本文羅列的方式,肯定還有其他方式能夠實現(xiàn)跨 Tab 通信。
譬如,基于 Window: opener property 配合 postMessage 也可以實現(xiàn)跨 Tab 窗口通信,但是這種通信僅僅適用于當前窗口以及通過當前窗口新開的窗口之間的通信。
更多有意思的方式,期待大家的補充與探索。
好了,本文到此結束,希望對你有幫助 ??
如果還有什么疑問或者建議,可以多多交流,原創(chuàng)文章,文筆有限,才疏學淺,文中若有不正之處,萬望告知。
總結
以上是生活随笔為你收集整理的浏览器跨 Tab 窗口通信原理及应用实践的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 梦见被雷劈什么意思
- 下一篇: 梦见吃冰激凌什么兆头