面试官:前端跨页面通讯,你知道哪些方法?

引言

在浏览器中,咱们能够同时打开多个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
相关文章
相关标签/搜索