最近,相信大家一定被這麼個動效給刷屏了:
以至於,基於這個效果的二次創作層出不窮,眼花繚亂。
基於跨視窗通訊的彈彈球:
基於跨視窗通訊的 Flippy Bird:
我也嘗試製作了一個跨 Tab 視窗的 CSS 動畫聯動,效果如下:
程式碼不多,核心程式碼 200 行,感興趣的可以戳這裡:Github - broadcastAnimation
當然,本文的核心不是去一一剖析上面的效果具體的實現方式,而是講講其中比較關鍵的一個技術點:
而是應用如何在多視窗下進行互相通訊。
所謂多視窗下進行互相通訊,是指在瀏覽器中,不同視窗(包括不同分頁、不同瀏覽器視窗甚至不同瀏覽器範例)之間進行資料傳輸和通訊的能力。
當然,本文我們探討的是純前端的跨 Tab 頁面通訊,在非純前端的方式下,我們可以藉助諸如 Web Socket 等方式,藉由後端這個中間載體,進行跨頁面通訊。
因此,本文我們更多的重心將放在,如何基於純前端技術,實現多視窗下進行互相通訊。
為了實現跨視窗通訊,它應該需要具備以下能力:
Broadcast Channel 是一個較新的 Web API,用於在不同的瀏覽器視窗、分頁或框架之間實現跨視窗通訊。它基於釋出-訂閱模式,允許一個視窗傳送訊息,並由其他視窗接收。
其核心步驟如下:
同時,Broadcast Channel 遵循瀏覽器的同源策略。這意味著只有在同一個協定、主機和埠下的視窗才能正常進行通訊。如果視窗不滿足同源策略,將無法互相傳送和接收訊息。
因為有同源限制,我們需要起一個服務,這裡我基於 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()
函數用於建立一個 BroadcastChannel 物件,並設定訊息處理常式。sendMessage(data)
函數用於向 BroadcastChannel 傳送訊息。handleMessage(event)
函數用於處理接收到的訊息。resizeEventBind()
函數用於監聽視窗大小變化事件,並在事件發生時獲取當前元素的位置資訊,並通過 sendMessage()
函數傳送位置資訊到 BroadcastChannel。getCurPos()
函數用於計算當前元素相對於顯示器視窗右上角的距離。在 onMounted()
生命週期勾點中,呼叫了 createBroadcastChannel()
和 resizeEventBind()
函數,用於在元件掛載後執行相關的初始化操作。
這樣,當我們同時開啟兩個視窗,移動其中一個視窗,就可以向另外一個視窗發生當前視窗希望傳遞過去的資訊,在本例子中就是 #j-main
元素距離顯示器右上角的距離。
假設 #j-main
只是一個在瀏覽器正中心矩形,我們同時開啟兩邊的控制檯,看看會發生什麼:
可以看到,如果我們同時開啟兩個一個的頁面,當觸發右邊頁面的 Resize,左邊的頁面會收到基於 broadcastChannel.onmessage = handleMessage
接收到的資訊,反之同理。
而一個完整的 Event 資訊如下:
譬如,傳遞過來的資訊放在 data 屬性內、同時也可以獲取當前的的 Broadcast Name 等。
基於 BroadcastChannel,就可以實現每個 Tab 內的核心資訊互傳, 可以得知當前線上裝置數,再基於這些資訊去完成我們想要的動畫、互動等效果。
這裡的核心點,還是:
其本質就是一個資料共用池子。
好,介紹完 Broadcast Channel(),我們再來看看 SharedWorker API。
SharedWorker API 是 HTML5 中提供的一種多執行緒解決方案,它可以在多個瀏覽器 TAB 頁面之間共用一個後臺執行緒,從而實現跨頁面通訊。
與其他 Worker 不同的是,SharedWorker 可以被多個瀏覽器 TAB 頁面共用,且可以在同一域名下的不同頁面之間建立連線。這意味著,多個頁面可以通過 SharedWorker 範例之間的訊息傳遞,實現跨 TAB 頁面的通訊。
它的實現與上面的 Broadcast Channel 非常類似,我們來看一看實際的程式碼:
<template>
<div class="g-container" id="j-main">
// ...
</div>
</template>
<script>
import { onMounted } from 'vue';
export default {
setup() {
// 建立一個 SharedWorker 物件
let worker;
function initWorker() {
// 建立一個 SharedWorker 物件
worker = new SharedWorker('/shared-worker.js', 'tabWorker');
// 監聽訊息事件
worker.port.onmessage = function (event) {
console.log('接收到 event', event);
handleMessage(event);
};
}
function handleMessage(data) {
// TODO: 處理接收到資訊後的邏輯
}
function sendMessage(data) {
// 傳送訊息
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 頁通訊的核心在於資料向外的傳送與接收的能力:
initWorker()
方法中,使用 worker = new
SharedWorker
('/shared-worker.js', 'tabWorker')
建立了一個 SharedWorker
, 後面每一個被開啟的同域瀏覽器 TAB 頁面,都是共用這個 Worker 執行緒,從而實現跨頁面通訊worker.port.postMessage(data)
實現資料的傳輸worker.port.onmessage = function() {}
實現傳輸資料的監聽當然,上面有引入一個 /shared-worker.js
,這個是需要額外定義的,一個極簡版本的程式碼如下:
//shared-worker.js
const connections = [];
onconnect = function (event) {
var port = event.ports[0];
connections.push(port);
port.onmessage = function (event) {
// 接收到訊息時,向所有連線傳送該訊息
connections.forEach(function (conn) {
if (conn !== port) {
conn.postMessage(event.data);
}
});
};
port.start();
};
簡單解析一下,下面對其進行解析:
總而言之,shared-worker.js 指令碼建立了一個共用 Worker 範例,它可以接收來自不同頁面的連線請求,並將接收到的訊息傳送給其他連線的頁面。通過使用 SharedWorker API,實現跨 TAB 頁面之間的通訊和資料共用。
同理,我們來看看基於 Worker 的資料傳輸效果,同樣是簡化 DEMO,當 Resize 視窗時,向另外一個視窗傳送當前視窗下 #j-main
元素的座標:
可以看到,如果我們同時開啟兩個一個的頁面,當觸發右邊頁面的 Resize,左邊的頁面會利用 worker.port.onmessage = function() {}
收到基於 worker.port.postMessage(
data
)
傳送的資訊,反之同理。
而一個完整的 Event 資訊如下:
可以看到,在 SharedWorker 方式中,傳輸資料與 Broadcast Channel 是一樣的,都是利用 Message Event
。簡單對比一下:
shared-worker.js
;相容性方面,到今天(2023-11-26),broadcast Channel 看著是相容性更好的方式:
另外,需要注意的是,兩個方法都使用了 postMessage
方法。window.postMessage()
方法可以安全地實現跨源通訊。並且,本質上而言,單獨使用 postMessage
就可以實現跨 Tab 通訊。
但是,單獨使用 postMessage
適合簡單的對等通訊。在更復雜的場景中,Broadcast Channel 和 SharedWorker 提供更強大的機制,可簡化通訊邏輯,有更廣泛的通訊範圍和生命週期管理。Broadcast Channel 的通訊範圍是所有訂閱該頻道的視窗,而 SharedWorker 可在多個視窗之間共用狀態和通訊。
OK,最後一種跨 Tab 視窗通訊的方式是利用 localStorage
、sessionStorage
在地化儲存 API 以及的 storage
事件。
與上面 Broadcast Channel、SharedWorker 稍微不同的地方在於:
localStorage
方式,利用了本地瀏覽器儲存,實現了同域下的資料共用;localStorage
方式,基於 window.addEventListener('storage',
function
(
event
) {})
事件實現了 localStore 變化時候的資料監聽;簡單看看程式碼:
<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 中#j-main
的座標值,通過 ID 標識當 Key,存入 localStorage 中window.addEventListener('storage', (
event
)
=>
{})
監聽 localStorage 的變化互動傳輸結果,與上述兩個動圖是一致的,就不額外貼圖了,但是基於 storage
事件傳輸的值有點不一樣,我們展開看看:
我們通過 window.addEventListener('storage', (event)=>{})
,可以拿到此次變化的 localStorage key 是什麼,前值 oldValue
與 newValue
等等。
當然,由於 localStorage
儲存過程只能是字串,在讀取的時候需要利用 JSON.stringify
和 JSON.parse
額外處理一層,偵錯的時候需要注意。
雖然看起來這種方式最不優雅,但是結合相容性一起看, localstorage 反而是相容性最好的方式。在資料量較小的時候,效能相差不會太大,反而可能是更好的選擇。
我基於上面三種方式:Broadcast Channel、SharedWorker 與 localStorage,都實現了一遍下面這個跨 Tab 頁的 CSS 聯動動畫:
三種方式的程式碼都不多,感興趣的可以戳這裡:Github - broadcastAnimation
當然,上面的實現其實有很大一個瑕疵。
那就是我們只顧著實現通訊,沒有考慮實際應用中的一些實際問題:
基於實際應用,我們需要基於上述 3 種方式,進一步細化方案。
上面,為了方便演示,每次傳輸資料時,只傳輸動畫需要的資料。而實際應用,我們可以需要細化整個傳輸資料,設定合理的協定。譬如:
{
// 傳輸狀態:
// 1 - 首次傳輸
// 2 - 正常通訊
// 3 - 頁面關閉
status: 1 | 2 | 3,
data: {}
}
接收方需要基於收到資訊所展示的不同的狀態,做出不同的反饋。
當然,還有一個問題,我們如何知道頁面被關閉了?基於元件的 onUnmounted
傳送當前頁面關閉的資訊或者基於 window 物件的 beforeunload
事件傳送當前頁面關閉的資訊?
這些資訊都有可能因為 Tab 頁面失活,導致關閉的資訊無法正常被傳送出去。所以,實際應用中,我們經常用的一項技術是心跳上報/心跳廣播,一旦建立連線後,間隔 X 秒傳送一次心跳廣播,告訴其他接收端,我還線上。一旦超過某個時間閾值沒有收到心跳上報,各個訂閱方可以認為該裝置已經下線。
總而言之,跨 Tab 視窗通訊應用在實際應用的過程中,我們需要思考更多可能隱藏的問題。
當然,除了最近大火的跨 Tab 動畫應用場景,實際業務中,還有許多場景是它可以發揮作用的。這些場景利用了跨 Tab 通訊技術,增強了使用者體驗並提供了更豐富的功能。
以下是一些常見的應用場景:
譬如這個:
舉幾個實際的例子:
總之,跨 Tab 視窗通訊在實時共同作業、資料同步、通知提醒等方面都能發揮重要作用,為使用者提供更流暢、便捷的互動體驗。
本文只羅列了 3 種較為常見,適用性強的方式。除去本文羅列的方式,肯定還有其他方式能夠實現跨 Tab 通訊。
譬如,基於 Window: opener property 配合 postMessage
也可以實現跨 Tab 視窗通訊,但是這種通訊僅僅適用於當前視窗以及通過當前視窗新開的視窗之間的通訊。
更多有意思的方式,期待大家的補充與探索。
好了,本文到此結束,希望對你有幫助