面試官:前端跨頁面通訊,你知道哪些方法?

引言

在瀏覽器中,咱們能夠同時打開多個Tab頁,每一個Tab頁能夠粗略理解爲一個「獨立」的運行環境,即便是全局對象也不會在多個Tab間共享。然而有些時候,咱們但願能在這些「獨立」的Tab頁面之間同步頁面的數據、信息或狀態。html

正以下面這個例子:我在列表頁點擊「收藏」後,對應的詳情頁按鈕會自動更新爲「已收藏」狀態;相似的,在詳情頁點擊「收藏」後,列表頁中按鈕也會更新。前端

跨頁面通訊實例

這就是咱們所說的前端跨頁面通訊。git

你知道哪些跨頁面通訊的方式呢?若是不清楚,下面我就帶你們來看看七種跨頁面通訊的方式。github


1、同源頁面間的跨頁面通訊

如下各類方式的 在線 Demo 能夠戳這裏 >>

瀏覽器的同源策略在下述的一些跨頁面通訊方法中依然存在限制。所以,咱們先來看看,在知足同源策略的狀況下,都有哪些技術能夠用來實現跨頁面通訊。數據庫

1. BroadCast Channel

BroadCast Channel 能夠幫咱們建立一個用於廣播的通訊頻道。當全部頁面都監聽同一頻道的消息時,其中某一個頁面經過它發送的消息就會被其餘全部頁面收到。它的API和用法都很是簡單。後端

下面的方式就能夠建立一個標識爲AlienZHOU的頻道:跨域

const bc = new BroadcastChannel('AlienZHOU');

各個頁面能夠經過onmessage來監聽被廣播的消息:瀏覽器

bc.onmessage = function (e) {
    const data = e.data;
    const text = '[receive] ' + data.msg + ' —— tab ' + data.from;
    console.log('[BroadcastChannel] receive message:', text);
};

要發送消息時只須要調用實例上的postMessage方法便可:服務器

bc.postMessage(mydata);
Broadcast Channel 的具體的使用方式能夠看這篇 《【3分鐘速覽】前端廣播式通訊:Broadcast Channel》

2. Service Worker

Service Worker 是一個能夠長期運行在後臺的 Worker,可以實現與頁面的雙向通訊。多頁面共享間的 Service Worker 能夠共享,將 Service Worker 做爲消息的處理中心(中央站)便可實現廣播效果。cookie

Service Worker 也是 PWA 中的核心技術之一,因爲本文重點不在 PWA ,所以若是想進一步瞭解 Service Worker,能夠閱讀我以前的文章 【PWA學習與實踐】(3) 讓你的WebApp離線可用

首先,須要在頁面註冊 Service Worker:

/* 頁面邏輯 */
navigator.serviceWorker.register('../util.sw.js').then(function () {
    console.log('Service Worker 註冊成功');
});

其中../util.sw.js是對應的 Service Worker 腳本。Service Worker 自己並不自動具有「廣播通訊」的功能,須要咱們添加些代碼,將其改形成消息中轉站:

/* ../util.sw.js Service Worker 邏輯 */
self.addEventListener('message', function (e) {
    console.log('service worker receive message', e.data);
    e.waitUntil(
        self.clients.matchAll().then(function (clients) {
            if (!clients || clients.length === 0) {
                return;
            }
            clients.forEach(function (client) {
                client.postMessage(e.data);
            });
        })
    );
});

咱們在 Service Worker 中監聽了message事件,獲取頁面(從 Service Worker 的角度叫 client)發送的信息。而後經過self.clients.matchAll()獲取當前註冊了該 Service Worker 的全部頁面,經過調用每一個client(即頁面)的postMessage方法,向頁面發送消息。這樣就把從一處(某個Tab頁面)收到的消息通知給了其餘頁面。

處理完 Service Worker,咱們須要在頁面監聽 Service Worker 發送來的消息:

/* 頁面邏輯 */
navigator.serviceWorker.addEventListener('message', function (e) {
    const data = e.data;
    const text = '[receive] ' + data.msg + ' —— tab ' + data.from;
    console.log('[Service Worker] receive message:', text);
});

最後,當須要同步消息時,能夠調用 Service Worker 的postMessage方法:

/* 頁面邏輯 */
navigator.serviceWorker.controller.postMessage(mydata);

3. LocalStorage

LocalStorage 做爲前端最經常使用的本地存儲,你們應該已經很是熟悉了;但StorageEvent這個與它相關的事件有些同窗可能會比較陌生。

當 LocalStorage 變化時,會觸發storage事件。利用這個特性,咱們能夠在發送消息時,把消息寫入到某個 LocalStorage 中;而後在各個頁面內,經過監聽storage事件便可收到通知。

window.addEventListener('storage', function (e) {
    if (e.key === 'ctc-msg') {
        const data = JSON.parse(e.newValue);
        const text = '[receive] ' + data.msg + ' —— tab ' + data.from;
        console.log('[Storage I] receive message:', text);
    }
});

在各個頁面添加如上的代碼,便可監聽到 LocalStorage 的變化。當某個頁面須要發送消息時,只須要使用咱們熟悉的setItem方法便可:

mydata.st = +(new Date);
window.localStorage.setItem('ctc-msg', JSON.stringify(mydata));

注意,這裏有一個細節:咱們在mydata上添加了一個取當前毫秒時間戳的.st屬性。這是由於,storage事件只有在值真正改變時纔會觸發。舉個例子:

window.localStorage.setItem('test', '123');
window.localStorage.setItem('test', '123');

因爲第二次的值'123'與第一次的值相同,因此以上的代碼只會在第一次setItem時觸發storage事件。所以咱們經過設置st來保證每次調用時必定會觸發storage事件。

小憩一下

上面咱們看到了三種實現跨頁面通訊的方式,不管是創建廣播頻道的 Broadcast Channel,仍是使用 Service Worker 的消息中轉站,抑或是些 tricky 的storage事件,其都是「廣播模式」:一個頁面將消息通知給一個「中央站」,再由「中央站」通知給各個頁面。

在上面的例子中,這個「中央站」能夠是一個 BroadCast Channel 實例、一個 Service Worker 或是 LocalStorage。

下面咱們會看到另外兩種跨頁面通訊方式,我把它稱爲「共享存儲+輪詢模式」。


4. Shared Worker

Shared Worker 是 Worker 家族的另外一個成員。普通的 Worker 之間是獨立運行、數據互不相通;而多個 Tab 註冊的 Shared Worker 則能夠實現數據共享。

Shared Worker 在實現跨頁面通訊時的問題在於,它沒法主動通知全部頁面,所以,咱們會使用輪詢的方式,來拉取最新的數據。思路以下:

讓 Shared Worker 支持兩種消息。一種是 post,Shared Worker 收到後會將該數據保存下來;另外一種是 get,Shared Worker 收到該消息後會將保存的數據經過postMessage傳給註冊它的頁面。也就是讓頁面經過 get 來主動獲取(同步)最新消息。具體實現以下:

首先,咱們會在頁面中啓動一個 Shared Worker,啓動方式很是簡單:

// 構造函數的第二個參數是 Shared Worker 名稱,也能夠留空
const sharedWorker = new SharedWorker('../util.shared.js', 'ctc');

而後,在該 Shared Worker 中支持 get 與 post 形式的消息:

/* ../util.shared.js: Shared Worker 代碼 */
let data = null;
self.addEventListener('connect', function (e) {
    const port = e.ports[0];
    port.addEventListener('message', function (event) {
        // get 指令則返回存儲的消息數據
        if (event.data.get) {
            data && port.postMessage(data);
        }
        // 非 get 指令則存儲該消息數據
        else {
            data = event.data;
        }
    });
    port.start();
});

以後,頁面定時發送 get 指令的消息給 Shared Worker,輪詢最新的消息數據,並在頁面監聽返回信息:

// 定時輪詢,發送 get 指令的消息
setInterval(function () {
    sharedWorker.port.postMessage({get: true});
}, 1000);

// 監聽 get 消息的返回數據
sharedWorker.port.addEventListener('message', (e) => {
    const data = e.data;
    const text = '[receive] ' + data.msg + ' —— tab ' + data.from;
    console.log('[Shared Worker] receive message:', text);
}, false);
sharedWorker.port.start();

最後,當要跨頁面通訊時,只需給 Shared Worker postMessage便可:

sharedWorker.port.postMessage(mydata);
注意,若是使用 addEventListener來添加 Shared Worker 的消息監聽,須要顯式調用 MessagePort.start方法,即上文中的 sharedWorker.port.start();若是使用 onmessage綁定監聽則不須要。

5. IndexedDB

除了能夠利用 Shared Worker 來共享存儲數據,還可使用其餘一些「全局性」(支持跨頁面)的存儲方案。例如 IndexedDB 或 cookie。

鑑於你們對 cookie 已經很熟悉,加之做爲「互聯網最先期的存儲方案之一」,cookie 已經在實際應用中承受了遠多於其設計之初的責任,咱們下面會使用 IndexedDB 來實現。

其思路很簡單:與 Shared Worker 方案相似,消息發送方將消息存至 IndexedDB 中;接收方(例如全部頁面)則經過輪詢去獲取最新的信息。在這以前,咱們先簡單封裝幾個 IndexedDB 的工具方法。

  • 打開數據庫鏈接:
function openStore() {
    const storeName = 'ctc_aleinzhou';
    return new Promise(function (resolve, reject) {
        if (!('indexedDB' in window)) {
            return reject('don\'t support indexedDB');
        }
        const request = indexedDB.open('CTC_DB', 1);
        request.onerror = reject;
        request.onsuccess =  e => resolve(e.target.result);
        request.onupgradeneeded = function (e) {
            const db = e.srcElement.result;
            if (e.oldVersion === 0 && !db.objectStoreNames.contains(storeName)) {
                const store = db.createObjectStore(storeName, {keyPath: 'tag'});
                store.createIndex(storeName + 'Index', 'tag', {unique: false});
            }
        }
    });
}
  • 存儲數據
function saveData(db, data) {
    return new Promise(function (resolve, reject) {
        const STORE_NAME = 'ctc_aleinzhou';
        const tx = db.transaction(STORE_NAME, 'readwrite');
        const store = tx.objectStore(STORE_NAME);
        const request = store.put({tag: 'ctc_data', data});
        request.onsuccess = () => resolve(db);
        request.onerror = reject;
    });
}
  • 查詢/讀取數據
function query(db) {
    const STORE_NAME = 'ctc_aleinzhou';
    return new Promise(function (resolve, reject) {
        try {
            const tx = db.transaction(STORE_NAME, 'readonly');
            const store = tx.objectStore(STORE_NAME);
            const dbRequest = store.get('ctc_data');
            dbRequest.onsuccess = e => resolve(e.target.result);
            dbRequest.onerror = reject;
        }
        catch (err) {
            reject(err);
        }
    });
}

剩下的工做就很是簡單了。首先打開數據鏈接,並初始化數據:

openStore().then(db => saveData(db, null))

對於消息讀取,能夠在鏈接與初始化後輪詢:

openStore().then(db => saveData(db, null)).then(function (db) {
    setInterval(function () {
        query(db).then(function (res) {
            if (!res || !res.data) {
                return;
            }
            const data = res.data;
            const text = '[receive] ' + data.msg + ' —— tab ' + data.from;
            console.log('[Storage I] receive message:', text);
        });
    }, 1000);
});

最後,要發送消息時,只需向 IndexedDB 存儲數據便可:

openStore().then(db => saveData(db, null)).then(function (db) {
    // …… 省略上面的輪詢代碼
    // 觸發 saveData 的方法能夠放在用戶操做的事件監聽內
    saveData(db, mydata);
});

小憩一下

在「廣播模式」外,咱們又瞭解了「共享存儲+長輪詢」這種模式。也許你會認爲長輪詢沒有監聽模式優雅,但實際上,有些時候使用「共享存儲」的形式時,不必定要搭配長輪詢。

例如,在多 Tab 場景下,咱們可能會離開 Tab A 到另外一個 Tab B 中操做;過了一會咱們從 Tab B 切換回 Tab A 時,但願將以前在 Tab B 中的操做的信息同步回來。這時候,其實只用在 Tab A 中監聽visibilitychange這樣的事件,來作一次信息同步便可。

下面,我會再介紹一種通訊方式,我把它稱爲「口口相傳」模式。


6. window.open + window.opener

當咱們使用window.open打開頁面時,方法會返回一個被打開頁面window的引用。而在未顯示指定noopener時,被打開的頁面能夠經過window.opener獲取到打開它的頁面的引用 —— 經過這種方式咱們就將這些頁面創建起了聯繫(一種樹形結構)。

首先,咱們把window.open打開的頁面的window對象收集起來:

let childWins = [];
document.getElementById('btn').addEventListener('click', function () {
    const win = window.open('./some/sample');
    childWins.push(win);
});

而後,當咱們須要發送消息的時候,做爲消息的發起方,一個頁面須要同時通知它打開的頁面與打開它的頁面:

// 過濾掉已經關閉的窗口
childWins = childWins.filter(w => !w.closed);
if (childWins.length > 0) {
    mydata.fromOpenner = false;
    childWins.forEach(w => w.postMessage(mydata));
}
if (window.opener && !window.opener.closed) {
    mydata.fromOpenner = true;
    window.opener.postMessage(mydata);
}

注意,我這裏先用.closed屬性過濾掉已經被關閉的 Tab 窗口。這樣,做爲消息發送方的任務就完成了。下面看看,做爲消息接收方,它須要作什麼。

此時,一個收到消息的頁面就不能那麼自私了,除了展現收到的消息,它還須要將消息再傳遞給它所「知道的人」(打開與被它打開的頁面):

須要注意的是,我這裏經過判斷消息來源,避免將消息回傳給發送方,防止消息在二者間死循環的傳遞。(該方案會有些其餘小問題,實際中能夠進一步優化)
window.addEventListener('message', function (e) {
    const data = e.data;
    const text = '[receive] ' + data.msg + ' —— tab ' + data.from;
    console.log('[Cross-document Messaging] receive message:', text);
    // 避免消息回傳
    if (window.opener && !window.opener.closed && data.fromOpenner) {
        window.opener.postMessage(data);
    }
    // 過濾掉已經關閉的窗口
    childWins = childWins.filter(w => !w.closed);
    // 避免消息回傳
    if (childWins && !data.fromOpenner) {
        childWins.forEach(w => w.postMessage(data));
    }
});

這樣,每一個節點(頁面)都肩負起了傳遞消息的責任,也就是我說的「口口相傳」,而消息就在這個樹狀結構中流轉了起來。

小憩一下

顯然,「口口相傳」的模式存在一個問題:若是頁面不是經過在另外一個頁面內的window.open打開的(例如直接在地址欄輸入,或從其餘網站連接過來),這個聯繫就被打破了。

除了上面這六個常見方法,其實還有一種(第七種)作法是經過 WebSocket 這類的「服務器推」技術來進行同步。這比如將咱們的「中央站」從前端移到了後端。

關於 WebSocket 與其餘「服務器推」技術,不瞭解的同窗能夠閱讀這篇《各種「服務器推」技術原理與實例(Polling/COMET/SSE/WebSocket)》

此外,我還針對以上各類方式寫了一個 在線演示的 Demo >>

Demo頁面

2、非同源頁面之間的通訊

上面咱們介紹了七種前端跨頁面通訊的方法,但它們大都受到同源策略的限制。然而有時候,咱們有兩個不一樣域名的產品線,也但願它們下面的全部頁面之間能無障礙地通訊。那該怎麼辦呢?

要實現該功能,可使用一個用戶不可見的 iframe 做爲「橋」。因爲 iframe 與父頁面間能夠經過指定origin來忽略同源限制,所以能夠在每一個頁面中嵌入一個 iframe (例如:http://sample.com/bridge.html),而這些 iframe 因爲使用的是一個 url,所以屬於同源頁面,其通訊方式能夠複用上面第一部分提到的各類方式。

頁面與 iframe 通訊很是簡單,首先須要在頁面中監聽 iframe 發來的消息,作相應的業務處理:

/* 業務頁面代碼 */
window.addEventListener('message', function (e) {
    // …… do something
});

而後,當頁面要與其餘的同源或非同源頁面通訊時,會先給 iframe 發送消息:

/* 業務頁面代碼 */
window.frames[0].window.postMessage(mydata, '*');

其中爲了簡便此處將postMessage的第二個參數設爲了'*',你也能夠設爲 iframe 的 URL。iframe 收到消息後,會使用某種跨頁面消息通訊技術在全部 iframe 間同步消息,例以下面使用的 Broadcast Channel:

/* iframe 內代碼 */
const bc = new BroadcastChannel('AlienZHOU');
// 收到來自頁面的消息後,在 iframe 間進行廣播
window.addEventListener('message', function (e) {
    bc.postMessage(e.data);
});

其餘 iframe 收到通知後,則會將該消息同步給所屬的頁面:

/* iframe 內代碼 */
// 對於收到的(iframe)廣播消息,通知給所屬的業務頁面
bc.onmessage = function (e) {
    window.parent.postMessage(e.data, '*');
};

下圖就是使用 iframe 做爲「橋」的非同源頁面間通訊模式圖。

其中「同源跨域通訊方案」可使用文章第一部分提到的某種技術。


總結

今天和你們分享了一下跨頁面通訊的各類方式。

對於同源頁面,常見的方式包括:

  • 廣播模式:Broadcast Channe / Service Worker / LocalStorage + StorageEvent
  • 共享存儲模式:Shared Worker / IndexedDB / cookie
  • 口口相傳模式:window.open + window.opener
  • 基於服務端:Websocket / Comet / SSE 等

而對於非同源頁面,則能夠經過嵌入同源 iframe 做爲「橋」,將非同源頁面通訊轉換爲同源頁面通訊。

本文在分享的同時,也是爲了拋轉引玉。若是你有什麼其餘想法,歡迎一塊兒討論,提出你的看法和想法~

對文章感興趣的同窗歡迎關注 個人博客 >> https://github.com/alienzhou/blog
相關文章
相關標籤/搜索