浏览器-跨页面通信
在浏览器中, 我们可以打开多个Tab页面, 每个Tab页面可以粗略的理解为一个独立的原型环境, 就算是全局对象也不会在多个tab之间进行共享. 但有时候我们又希望不同的tab能共享数据, 信息或者状态.
所以就出现了所谓的前端跨段通信方案.
一. 同源跨页面通信方案
浏览器的同源策略在下述一些跨页面通信方法中依然存在限制. 因此, 我们首先看看在满足同源策略的情况下, 有什么方法可以实现跨页面通信.
1. BroadCast Channel
BroadcastChannel可以帮我们创建一个用于广播的通信频道, 当所有页面都监听同一个频道的消息时, 其中某一个页面通过它发送的消息就会被其他页面收到. 用法如下:
- 创建一个带标识的频道:
const bc = new BroadcastChannel('SignID');
- 发送消息就只要调用实例上的
postMessage即可
bc.postMessage(mydata);
- 各个页面通过
onmessage来监听被广播的消息:
bc.onmessage = function (e) {
const data = e.data;
const text = '[receive] ' + data.msg + ' —— tab ' + data.from;
console.log('[BroadcastChannel] receive message:', text);
};
2. Service Worker
Service Worker是一个可以长期运行在后台的Worker, 能够实现与页面的双向通信. 多页面共享Service Worker. 所以我们就可以把Service Workder作为消息的处理中心, 来实现广播效果.
具体做法如下:
- 在页面中注册
Service Worker.
/* 页面逻辑 */
navigator.serviceWorker.register('../util.sw.js').then(function () {
console.log('Service Worker 注册成功');
});
- 改造sw脚本, 其中的
util.sw.js是对应的Service Worker脚本, sw本身不具备广播通信的功能, 需要我们添加一些代码, 将其改造成消息中转站.
/* ../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);
});
})
);
});
我们在sw中监听了message事件, 获取页面发送的信息, 然后通过self.clients.matchAll()方法获取当前住了sw的所有页面, 通过调用每一个client的postMessage方法, 向页面发送消息.
- 在页面中添加监听, 以获得sw发送的消息
/* 页面逻辑 */
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);
});
- 当需要发送消息的时候, 就可以调用sw的
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');
比如在这种情况下, 由于第二次的值与第一次相同, 所以上面的代码只会在第一次setItem的时候触发storage事件. 所以增加了一个时间戳来保证每次一定触发storage事件.
这三种方法都是一种广播模式, 下面来看看一些"共享存储 + 轮询模式"
4. Shared Worker
Shared Worker 是Worker家族的另一个成员. 普通的worker是独立运行, 数据互不相同的. 而多个Tab注册的Shared Workker则可以实现数据共享.
Shared Worker在实现跨页面通信的问题在于, 它无法主动的通知所有页面, 所以我们需要使用轮询的方式来拉取最新的数据. 思路如下:
让Shared Worker支持两种消息, 一种是post, Shared Worker收到后会将该数据保存下来, 另一种是get, Shared Worker收到消息以后会把保存的数据通过postMessage传递给注册它的页面, 也就是让页面通过get主动获取(同步)最新的消息. 具体的做法如下:
- 启动一个shared worker, 如下:
// 构造函数的第二个参数是 Shared Worker 名称,也可以留空
const sharedWorker = new SharedWorker('../util.shared.js', 'ctc');
- 在sharedworker中支持
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来共享的存储数据, 还可以使用其他一些"全局性"的存储方案, 例如IndexdDB或者Cookie.
思路也很简单, 类似于Shared Worker, 消息发送方吧消息存到IndexedDB中, 接受方通过轮询去获取最新的信息.
在处理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);
});
除了"共享存储 + 长轮询"这种模式, 还有另外的方法.
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));
}
});
这种模式的问题在于, 如果不是通过另一个页面打开的页面就无法建立间隙.
二. 非同源页面的通信
前面几种跨段通信方法, 都是同源的情况下的解决方案. 那么在不同域名的系统中如何进行跨段通信呢?
大概思路是: 使用一个用户看不见的iframe作为"桥". 由于iframe可以与父页面间通过指定origin来忽略同源限制, 因此可以在每个页面中嵌入一个iframe, 而这些iframe用的是同一个url, 因此属于同源页面, 其通信方式可以复用上面第一节中的各种方式.
简单的处理如下:
- 在页面中监听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, '*');
};
大致的架构如下:
还有基于服务端的消息推送, 这里暂不表述: Websocket / Comet / SSE , 可以自己了解.