浏览器跨tab通信的几种办法
跨tab通信需求常见于一些有大量内容需要浏览或操作(比如博客或者CMS系统),同时需要保持数据一致性的场景。这些场景大部分可以引入后端服务,通过轮询等方式保持多个tab间的数据一致。本文介绍的是在不引入后端服务的情况下,通过纯前端的方案,来进行跨tab通信
什么场景下需要跨 tab 通信?
有一些日常中很常见的场景,如果支持跨 tab 通信,可能能给用户更好的体验。
博客 Dark Mode 切换
想象你在浏览 Dan Abramov 的博客,你同时打开了好几个 tab 学习一系列知识。夜晚来临,你将一个页面切换成了 Dark Mode。当你阅读完这一篇去阅读下一篇时,页面却不是以 Dark Mode 呈现的。你不得不按下 F5 刷新页面来寻求一致的阅读体验。
需要频繁登录的 CMS 系统
想象你在网店管理系统中管理你网店中的商品,你打开了好几个 tab,分别管理商品类型,商品详情以及商品库存。你正在管理你的商品详情,突然你的登录 session 过期,你点击重新登录。但是当你切换到商品库存页面时,你发现你依旧无法操作,需要再次登录。你不得已点击了登录按钮,和你辛苦编辑了 10 分钟但没有保存的商品库存说了拜拜。
以上场景其实无伤大雅,你的系统依旧运转,核心功能依旧正常。但是借助跨 tab 通信,你能给用户提供更好更顺滑的体验。
Cookie & IndexedDB
要实现数据的一致,很容易想到的就是统一的数据存储。在有后端加入的时候,后端其实就是前端的统一数据存储。在纯前端的环境下,比较常见的数据存储包括
sessionStorage 由于只存活于单个 tab session 中,所以不能用于跨 tab 通信。localStorage 有其他的应用方式,下面会深入分析。所以在这个章节我们主要关注 cookie 和 IndexedDB。
实现思路
实现思路其实非常简单,就是通过轮询数据源,来确保每个 tab 的数据都是最新的。
// Cookie Way
// Producer Tab, 当然你也可以用js-cookie这种库更高效的操作cookie
document.cookie = 'blabla';
// Consumer Tab
setInterval(() => {
const cookie = document.cookie;
//Use Cookie to do something
doSomething(cookie);
}, 1000);
// IndexedDB way
// Producer Tab
const transaction = db.transaction(['customers'], 'readwrite');
const objectStore = transaction.objectStore('customers');
customerData.forEach(function (customer) {
const request = objectStore.add(customer);
request.onsuccess = function (event) {
// do some callback
};
});
// Consumer Tab
setInterval(() => {
const transaction = db.transaction(['customers']);
const objectStore = transaction.objectStore('customers');
const request = objectStore.get('444-44-4444');
request.onsuccess = function (event) {
doSomething(request.result.name);
};
}, 1000);
需要注意的是,cookie 的读写是同步的,在 cookie 比较大的时候可能会阻塞主线程造成页面卡顿。而 IndexedDB 的 API 都是异步的,所以不会有 cookie 同步读写的性能问题。
采用这种统一数据源的方式实现跨 tab 通信思路上比较简单,但是轮询的方式在数据变更不频繁的情况下效率是比较低下的(本质上是一种 consumer pull 的方式)。所以更理想的情况是,通过事件去驱动 consumer(producer push 的方式)做出对应的响应。
localStorage Event
localStorage 本质上也是一种数据源,但是不同于上面提到的两种数据源,当 localStorage 存储内容发生变更时,会发出对应的事件,同源下的所有 tab 都能接收到该事件。
// Producer Tab
window.localStorage.setItem('foo', 'bar');
// Consumer Tab
window.addEventListener('storage', (event) => {
if (event.storageArea != localStorage) return;
if (event.key === 'foo') {
// Do something with event.newValue
}
});
当然这种方式也有一些弊端,使用时需要注意
- 和 cookie 类似,localStorage 的读写也是同步的,所以当 storage 数据量很大的时候,读写可能本身就会阻塞主线程造成页面卡顿。
- localStorage 的 value 只支持 string,在传递复杂数据时稍显麻烦。
- 触发 setItem 的页面不会收到 storage event (FYI: https://developer.mozilla.org/en-US/docs/Web/API/StorageEvent)。
Broadcast Channel
Broadcast Channel 是一个 Web API,能够实现同源下跨 tab,windows,frames,iframes 甚至是 workers 的通信,同时不像 localStorage,发送的消息对象可以是任何 Object 类型,非常适合用于跨 tab 通信。
// Producer Tab
// init channel
const bc = new BroadcastChannel('test_channel');
// send message
bc.postMessage('This is a test message.');
// Consumer Tab
bc.onmessage = function (ev) {
doSomething(ev);
};
Broadcast Channel 的弊端在于其浏览器兼容性,直至本文发布,Safari 仍不支持 Broadcast Channel (FYI https://caniuse.com/?search=broadcast)。
ServiceWorker
ServiceWorker 更常见的场景可能是缓存数据和加载数据,提升网页加载速度。但是 ServiceWorker 本身也是支持发送消息的,因此也可以用于跨 tab 通信。
// Producer Tab
navigator.serviceWorker.controller.postMessage({
broadcast: data,
});
// Consumer Tab
addEventListener('message', async (event) => {
if ('boadcast' in event.data) {
const allClients = await clients.matchAll();
for (const client of allClients) {
client.postMessage(event.broadcast);
}
}
});
使用 ServiceWorker 做跨 tab 通信的弊端在于,你需要学习和了解 ServiceWorker,如果你的网站本来没有使用 ServiceWorker,仅仅为了跨 tab 通信就去使用 ServiceWorker,或许会有一点本末倒置。
window.postMessage
上面提到的几种方式,都只能在同源情况下进行跨 tab 通信,如果要不同源的 tab 也能通信,就只能借助 window.postMessage 了。
// Producer Tab
targetWindow.postMessage(message, targetOrigin);
// Consumer Tab
window.addEventListener(
'message',
(event) => {
if (event.origin !== targetOrigin) return;
// Do something
},
false
);
总结
- 使用 cookie 或者 IndexedDB 可以简单的实现跨 tab 通信,但是需要考虑轮询的性能问题
- localStorage Event 可以跨 tab 传输,但是也需要考虑性能以及一些边界场景
- 如果没有浏览器兼容性要求,Broadcast Channel 或许是最好的选择
- ServiceWorker 也可进行跨 tab 通信,但是你需要提前评估引入 ServiceWorker 的成本
- 如果需要跨域进行 tab 间通信,只能选择 window.postMessage