「本文已参与低调务实优秀中国好青年前端社群的写作活动」
✨示例代码仓库地址见文章末❤️
BroadcastChannel
BroadcastChannel 接口代理了一个命名频道,可以让指定 origin 下的任意 browsing context 来订阅它。它允许同源的不同浏览器窗口,Tab 页,frame 或者 iframe 下的不同文档之间相互通信。通过触发一个 message 事件,消息可以广播到所有监听了该频道的 BroadcastChannel 对象。
在 demo1.html 中:
const bc = new BroadcastChannel('Cellinlab-Channel');
bc.onmessage = function(event) {
const { msg, from } = event.data;
const text = `[recive] ${msg} -- tab ${from}`;
console.log('[BroadcastChannel] recive message: ', text);
};
bc.postMessage({
msg: 'Hello, world!',
from: 'demo1',
});
在 demo2.html 中:
const bc = new BroadcastChannel('Cellinlab-Channel');
bc.onmessage = function(event) {
const { msg, from } = event.data;
const text = `[recive] ${msg} -- tab ${from}`;
console.log('[BroadcastChannel] recive message: ', text);
};
bc.postMessage({
msg: 'Hello, world!',
from: 'demo2',
});
然后在控制台可以观察到:
Service Worker
Service workers 本质上充当 Web 应用程序、浏览器与网络(可用时)之间的代理服务器。这个 API 旨在创建有效的离线体验,它会拦截网络请求并根据网络是否可用来采取适当的动作、更新来自服务器的的资源。它还提供入口以推送通知和访问后台同步 API。
Service worker 是一个注册在指定源和路径下的事件驱动 worker。它采用 JavaScript 控制关联的页面或者网站,拦截并修改访问和资源请求,细粒度地缓存资源。你可以完全控制应用在特定情形(最常见的情形是网络不可用)下的表现。
Service worker 运行在 worker 上下文,因此它不能访问 DOM。相对于驱动应用的主 JavaScript 线程,它运行在其他线程中,所以不会造成阻塞。它设计为完全异步,同步 API(如 XHR 和 localStorage)不能在 service worker 中使用。
在 demo1.html 中 注册:
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('../Service-Worker/sw.js').then(function(registration) {
console.log('Service Worker Registered');
// 监听消息
navigator.serviceWorker.addEventListener('message', function(event) {
console.log('[Service Worker] recive message: ', event.data);
});
}).catch(function(err) {
console.log('Service Worker Failed', err);
});
}
其中, /Service-Worker/sw.js 是对应的 Service Worker 脚本。Service Worker 本身并不自动具备“广播通信”的功能,需要进行简单改造:
self.addEventListener('message', function (e) {
e.waitUntil(
self.clients.matchAll().then(function (clients) {
if (!clients || clients.length === 0) {
return;
}
clients.forEach(function (client) {
client.postMessage(e.data);
});
})
);
});
在 demo2.html 中 发消息:
if ('serviceWorker' in navigator) {
// 发送消息
navigator.serviceWorker.controller.postMessage('Hello, world! There is demo2');
}
然后在控制台可以观察到:
LocalStorage
当 LocalStorage 变化时,会触发 storage 事件,可以通过 event.key 和 event.newValue 获取变化的键值对。
在 demo1.html 中 监听事件:
window.addEventListener('storage', function(event) {
if (event.key === 'ls-msg') {
const data = JSON.parse(event.newValue);
const text = `[recive][${data.timestamp}] ${data.msg} -- tab ${data.from}`;
console.log('[LocalStorage] recive message: ', text);
}
});
在 demo2.html 中 测试发送消息:
const data = {
msg: 'Hello, world!',
timestamp: Date.now(),
from: 'demo2'
};
window.localStorage.setItem('ls-msg', JSON.stringify(data));
const newData = Object.assign({}, data);
newData.timestamp = 2022;
window.localStorage.setItem('ls-msg', JSON.stringify(newData));
window.localStorage.setItem('ls-msg', JSON.stringify(newData));
在控制台可以发现:
奇怪的是,当时间戳值不变时,虽然设置了两次,但是只会触发一次。这是因为,只有在值真正改变的时候,storage 才会触发。
Shared Worker
SharedWorker 接口代表一种特定类型的 worker,可以从几个浏览上下文中访问,例如几个窗口、iframe 或其他 worker。它们实现一个不同于普通 worker 的接口,具有不同的全局作用域, SharedWorkerGlobalScope。
SharedWorker 无法主动通知所有页面,需要我们使用轮询的方式,来拉取最新的数据。
在 demo1.html 中启动一个 SharedWorker,并轮询查看是否有新消息:
// 启动一个 SharedWorker
const sharedWorker = new SharedWorker('../SharedWorker/shared.js', 'cellinlab');
// 定时轮询
const timer = setInterval(() => {
// 发送消息,获取数据
sharedWorker.port.postMessage({
type: 'get',
message: 'Hello from SharedWorker Demo1',
from: 'demo1',
});
}, 1000);
// 接收消息
sharedWorker.port.addEventListener('message', (e) => {
const data = e.data;
const text = `[recive] ${data.message} -- From ${data.from}`;
console.log(text);
}, false);
sharedWorker.port.start();
其中 shared.js 中的代码如下:
let data = null;
self.addEventListener('connect', function (e) {
const port = e.ports[0];
port.addEventListener('message', function (event) {
if (event.data.type === 'get') {
data && port.postMessage(data);
data = null;
} else {
data = event.data;
}
});
port.start();
});
然后,就可以在 demo2.html 中发送消息:
// 启动一个 SharedWorker
const sharedWorker = new SharedWorker('../SharedWorker/shared.js', 'cellinlab');
// 发送消息
sharedWorker.port.postMessage({
type: 'post',
message: 'Hello from SharedWorker Demo2',
from: 'demo2',
});
在控制台可以观察到:
IndexedDB
还可以使用一些其他“全局性”(支持跨页面)的存储方案,例如 IndexedDB 和 Cookie。
思路是,消息发送者将消息存到 IndexedDB 中,接收方则通过轮询去拉取消息。
首先,对于 IndexedDB 的基础操作进行下简单封装:
samples/IndexedDB/util.js:
function connectDB (dbName = 'my_idb', version = 1, storeName = 'my_store') {
return new Promise(function (resolve, reject) {
if (!('indexedDB' in window)) {
reject(new Error('IndexedDB is not supported'))
}
const request = indexedDB.open(dbName, version);
request.onerror = reject;
request.onsuccess = function (event) {
resolve(event.target.result);
}
request.onupgradeneeded = function (event) {
const db = event.target.result;
if (event.oldVersion === 0
&& !db.objectStoreNames.contains(storeName)) {
const store = db.createObjectStore(storeName, { keyPath: 'mid' });
store.createIndex(`${storeName}_Index`, 'mid', { unique: true });
}
};
});
}
function saveData (db, store, key, data) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(store, 'readwrite');
const objectStore = transaction.objectStore(store);
const request = objectStore.put({ mid: key, data: data});
request.onsuccess = () => resolve(db);
request.onerror = reject;
})
}
function queryData (db, store, key) {
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction(store, 'readonly');
const objectStore = transaction.objectStore(store);
const request = objectStore.get(key);
request.onsuccess = (e) => resolve(e.target.result);
request.onerror = reject;
} catch (error) {
reject(error);
}
});
}
然后在 Demo1 中 轮询查看是否有新消息:
const STORE_NAME = 'my_store';
const DATA_KEY = 'my_key';
const data = {
msg: 'Hello, World!',
from: 'Demo1'
};
connectDB().then(db => {
saveData(db, STORE_NAME, DATA_KEY, null)
.then(db => {
setInterval(() => {
queryData(db, STORE_NAME, DATA_KEY)
.then(res =>{
if (!res || !res.data) {
return;
}
const data = res.data;
const text = `[recive] ${data.msg} -- tab ${data.from}`;
console.log(`[Sorage Demo1] recive message `, text);
});
}, 1000);
});
});
在 Demo2 中,将消息存入 IndexedDB:
const STORE_NAME = 'my_store';
const DATA_KEY = 'my_key';
connectDB().then(db => {
saveData(db, STORE_NAME, DATA_KEY, null)
.then(db => {
setInterval(() => {
queryData(db, STORE_NAME, DATA_KEY)
.then(res =>{
if (!res || !res.data) {
return;
}
const data = res.data;
const text = `[recive] ${data.msg} -- tab ${data.from}`;
console.log(`[Sorage Demo2] recive message `, text);
});
}, 1000);
});
const data = {
msg: 'Hello, World!',
from: 'Demo2'
};
saveData(db, STORE_NAME, DATA_KEY, data);
});
可以在 Demo1 的控制台中看到:
Cookie
使用 Cookie 实现思路和 IndexedDB 类似,这里不再赘述。
window.open & window.opener
在使用 window.open 打开新窗口时,会返回新窗口对象的引用 WindowObjectReference, 如果父子窗口满足“同源策略”,你可以通过这个引用访问新窗口的属性或方法。
被打开页面可以通过 window.opener,获取父窗口的引用。
在 demo1.html 中,添加创建子窗口和发送消息给子窗口的逻辑,并监听来自其他窗口的消息
// 创建和收集 子窗口
let childWins = [];
document.getElementById('btn-open-window').addEventListener('click', () => {
const childWin = window.open('demo2.html', '_blank');
childWins.push(childWin);
});
// 给子窗口发消息
document.getElementById('btn-send-msg').addEventListener('click', () => {
childWins = childWins.filter(win => !win.closed);
if (childWins.length > 0) {
childWins.forEach(win => {
win.postMessage({
type: 'message',
data: {
fromOpener: true,
msg: 'hello from parent'
}
}, '*');
});
}
});
// 接收来自父窗口或子窗口的消息,并发给非发送者的父窗口或子窗口
window.addEventListener('message', (e) => {
const { type, data } = e.data;
if (type == 'message') {
const text = `[recive] ${data.msg}`;
console.log(`[Window] recive message `, text);
// 避免向上回传
if (window.opener && !window.opener.closed && data.fromOpener) {
window.opener.postMessage(e.data, '*');
}
childWins = childWins.filter(win => !win.closed);
// 避免向下回传
if (childWins.length > 0 && !data.fromOpener) {
childWins.forEach(win => {
win.postMessage(e.data, '*');
});
}
}
});
在 demo2.html 中,添加给父窗口发送消息的逻辑,并监听来自其他窗口的消息:
let childWins = [];
document.getElementById("btn-send-msg").addEventListener("click", () => {
if (window.opener && !window.opener.closed) {
window.opener.postMessage({
type: "message",
data: {
fromOpener: false,
msg: "hello from child"
}
}, "*");
}
});
// 接收来自父窗口或子窗口的消息,并发给非发送者的父窗口或子窗口
window.addEventListener('message', (e) => {
const { type, data } = e.data;
if (type == 'message') {
const text = `[recive] ${data.msg}`;
console.log(`[Window] recive message `, text);
// 避免向上回传
if (window.opener && !window.opener.closed && data.fromOpener) {
window.opener.postMessage(e.data, '*');
}
childWins = childWins.filter(win => !win.closed);
// 避免向下回传
if (childWins.length > 0 && !data.fromOpener) {
childWins.forEach(win => {
win.postMessage(e.data, '*');
});
}
}
});
在 控制台 可以观察到:
iframe
在非同源的情况下,可以使用 iframe 作为“桥”,来实现跨页面通信。
在 demo1.html 中,创建 iframe,并发送消息给 iframe:
// 通过 iframe 给其他页面发送信息
document.getElementById("btn-send-msg").addEventListener("click", function() {
window.frames[0].postMessage({
type: 'msg',
msg: "hello from demo1",
time: new Date().getTime()
}, "*");
});
iframe 收到消息后,进行转发,传递给其他与 iframe 同域的 iframe:
const bc = new BroadcastChannel('cellinlab-iframe-channel');
// 收到来自页面的信息后,在 iframe 之间发送信息
window.addEventListener('message', function(e) {
bc.postMessage(e.data);
});
// 收到来自 iframe 之间的信息后,将消息发送给页面
bc.onmessage = function(e) {
window.parent.postMessage(e.data, '*');
};
位于其他跨域页面中的 iframe 接收来自同域 iframe 的消息后,再同步给其所在的页面:
// 监听来自 iframe 的信息
window.addEventListener('message', function(e) {
const {type, msg} = e.data;
if (type === 'msg') {
console.log('received message from iframe:', msg);
}
});
服务端推送
服务端推送是利用服务端作为中转站,去实现消息的互通,宏观上看,类似于 QQ、微信的消息通信机制。
从技术实现上讲,常见的方法有:
简单轮询
顾名思义,就是在前端创建定时器,定期查询服务端是否有新的消息。
COMET
- 基于 HTTP 的长轮询(long-polling)
- 基于 iframe 的长连接流(stream)模式
SSE (Server-Sent Events)
使用 server-sent 事件,服务器可以在任何时刻向我们的 Web 页面推送数据和信息。这些被推送进来的信息可以在这个页面上作为 Events + data 的形式来处理。
WebSocket
WebSocket 对象提供了用于创建和管理 WebSocket 连接,以及可以通过该连接发送和接收数据的 API。
示例代码
Github | Ways-to-Communicate-Across-Browser-Tabs
参考
- [1] 前端跨页面通信,你知道哪些方法?
- [2] 各类“服务器推”技术原理与实例