背景
用户在实际的操作场景中会打开多个 Tab 页面A、B、C、D、E…。当用户在 E Tab 页退出登录,并且登录到新的账号,然后用户切换到非 E 的 Tab 时,发现登录信息没有刷新, 并且由于登录信息没有刷新,会出现操作异常。这个问题简单来说就是多个 Tab 信息没有同步。问题的关键在于一个 Tab 退出重新登录,需要通知到其他的 Tab 刷新到最新的信息。本质问题就是解决前端跨页面通信。
onstorage
WindowEventHandlers.onstorage 属性包含一个在 storage 事件触发时运行的事件处理程序。当更改存储时会触发事件处理程序。
语法
window.onstorage = function(){...};
window.onstorage = function(e) {
console.log(`The ${e.key} key has been changed from ${e.oldValue} to ${e.newValue} .`);
};
<div id="app"></div>
<button id="tab">新开 Tab</button>
<button id="l-btn">触发 LocalStorage 更新</button>
<button id="s-btn">触发 SessionStorage 更新</button>
<script>
window.onstorage = function(e) {
console.log(`The ${e.key} key has been changed from ${e.oldValue} to ${e.newValue} .`);
};
document.getElementById('tab').onclick = function () {
window.open('xxx');
}
document.getElementById('l-btn').onclick = function () {
localStorage.setItem('storage1', Date.now())
}
document.getElementById('s-btn').onclick = function () {
sessionStorage.setItem('storage1', Date.now())
}
</script>
Tips
- 该事件不在导致数据变化的当前页面触发(如果浏览器同时打开一个域名下面的多个页面,当其中的一个页面改变 数据时,其他所有页面的 storage 事件会被触发,而原始页面并不触发 storage 事件)。
- sessionStorage(❎)不能触发 storage 事件 , localStorage(✅)可以。
- 如果修改的值未发生改变,将不会触发 onstorage 事件。
- 优点:浏览器支持效果好、API直观、操作简单。缺点:部分浏览器隐身模式下,无法设置 localStorage。如safari,这样也就导致 onstrage 事件无法使用。
除开少数情况,localStorage的兼容性不错,就当前国内的情况,已经基本没有问题了。localStorage 的原理很简单,浏览器为每个域名划出一块本地存储空间,用户网页可以通过 localStorage 命名空间进行读写。
BroadCast Channel
BroadcastChannel 接口代理了一个命名频道,可以让指定 origin 下的任意 browsing context 来订阅它。它允许同源的不同浏览器窗口,Tab页,frame或者 iframe 下的不同文档之间相互通信。通过触发一个 message 事件,消息可以广播到所有监听了该频道的 BroadcastChannel 对象。
说到 BroadCast Channel 不得不说一下 postMessage,他们二者的最大区别就在于 postMessage 更像是点对点的通信,而 BroadCast Channel 是广播的方式,点到面。
语法
// 创建
const broadcastChannel = new BroadcastChannel('channelName');
// 监听消息
broadcastChannel.onmessage = function(e) {
console.log('监听消息:', e.data);
};
// 发送消息
broadcastChannel.postMessage('测试:传送消息');
// 关闭
broadcastChannel.close();
<div id="app"></div>
<button id="tab">新开 Tab</button>
<button id="l-btn">发送消息</button>
<button id="s-btn">关闭</button>
<script>
// 创建
const broadcastChannel = new BroadcastChannel('channelName');
// 监听消息
broadcastChannel.onmessage = function(e) {
console.log('监听消息:', e.data);
};
document.getElementById('tab').onclick = function () {
window.open('xxx');
}
document.getElementById('l-btn').onclick = function () {
// 发送消息
broadcastChannel.postMessage('测试,传送消息,我发送消息啦。。。');
}
document.getElementById('s-btn').onclick = function () {
// 关闭
broadcastChannel.close();
}
</script>
Tips
- 监听消息除了 .onmessage 这种方式,还可以 使用addEventListener来添加’message’监听,
- 关闭除了使用 Broadcast Channel 实例为我们提供的 close 方法来关闭 Broadcast Channel。我们还可取消或者修改相应的’message’事件监听。两者是有区别的:取消’message’监听只是让页面不对广播消息进行响应,Broadcast Channel 仍然存在;而调用close方法这会切断与 Broadcast Channel 的连接,浏览器才能够尝试回收该对象,因为此时浏览器才会知道用户已经不需要使用广播频道了。
- 兼容性:如果不使用 IE 和 sf on iOS 浏览器,兼容性还是可以的。
Service Worker
Service Worker 是一个可以长期运行在后台的 Worker,能够实现与页面的双向通信。多页面共享间的 Service Worker 可以共享,将 Service Worker 作为消息的处理中心(中央站)即可实现广播效果。
语法
<div id="app"></div>
<button id="tab">新开 Tab</button>
<button id="l-btn">发送消息</button>
<script>
/* 判断当前浏览器是否支持serviceWorker */
if ('serviceWorker' in navigator) {
/* 当页面加载完成就创建一个serviceWorker */
window.addEventListener('load', function () {
/* 创建并指定对应的执行内容 */
/* scope 参数是可选的,可以用来指定你想让 service worker 控制的内容的子目录。 在这个例子里,我们指定了 '/',表示 根网域下的所有内容。这也是默认值。 */
navigator.serviceWorker.register('./serviceWorker.js', { scope: './' })
.then(function (registration) {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
})
.catch(function (err) {
console.log('ServiceWorker registration failed: ', err);
});
});
// 监听消息
navigator.serviceWorker.addEventListener('message', function (e) {
const data = e.data;
console.log('我接受到消息了:', data);
});
document.getElementById('l-btn').onclick = function () {
navigator.serviceWorker.controller && navigator.serviceWorker.controller.postMessage('测试,传送消息,我发送消息啦。。。');
};
}
</script>
/* 监听安装事件,install 事件一般是被用来设置你的浏览器的离线缓存逻辑 */
this.addEventListener('install', function (event) {
/* 通过这个方法可以防止缓存未完成,就关闭serviceWorker */
event.waitUntil(
/* 创建一个名叫V1的缓存版本 */
caches.open('v1').then(function (cache) {
/* 指定要缓存的内容,地址为相对于跟域名的访问路径 */
return cache.addAll(['./index.html']);
})
);
});
/* 注册fetch事件,拦截全站的请求 */
this.addEventListener('fetch', function (event) {
event.respondWith(
// magic goes here
/* 在缓存中匹配对应请求资源直接返回 */
caches.match(event.request)
);
});
/* 监听消息,通知其他 Tab 页面 */
this.addEventListener('message', function(event) {
this.clients.matchAll().then(function(clients) {
clients.forEach(function(client) {
// 这里的判断目的是过滤掉当前 Tab 页面,也可以使用 visibilityState 的状态来判断
if(!client.focused) {
client.postMessage(event.data)
}
})
})
})
Tips
- Service workers 本质上充当 Web 应用程序、浏览器与网络(可用时)之间的代理服务器。所以本质上来说 Service Worker 并不自动具备“广播通信”的功能,需要改造 Service Worker 添加些代码,将其改造成消息中转站。在 Service Worker 中监听了message事件,获取页面发送的信息。然后通过 self.clients.matchAll() 获取当前注册了 Service Worker 的所有页面,通过调用每个的 postMessage 方法,向页面发送消息。这样就把从一处(某个Tab页面)收到的消息通知给了其他页面。
- 兼容性:IE 全军覆没,其他浏览器还行,整体来说一般。
open & opener
当我们 系统中通过 window.open 打开一个新页面时,window.open 方法会返回一个被打开页面的引用,而被打开页面则可以通过 window.opener 获取到打开它的页面的引用(当然这是在没有指定noopener的情况下)。
番外关于 noopener
<a href="https://google.com" target="_blank">Google</a>
我们在系统中经常会这样使用 a 标签跳转到第三方网站,有时,当您单击网站上的链接时,该链接将在新选项卡中打开,但旧选项卡也会被重定向到其他网络钓鱼网站,它会要求您登录或开始将一些恶意软件下载到您的设备。这样存在一定的安全隐患,此时在新打开的页面中可通过 window.opener 获取到源页面的 window 对象, 这就埋下了安全隐患。
比如:
- 你自己的网站 A,点击如上链接打开了第三方网站 B。
- 此时网站 B 可以通过 window.opener 获取到 A 网站的 window 对象。
- 然后通过 window.opener.location.href = ‘www.baidu.com’ 这种形式跳转到一个钓鱼网站,泄露用户信息。
为了避免这样的问题,可以添加引入了 rel=”noopener” 属性, 这样新打开的页面便获取不到来源页面的 window 对象了, 此时 window.opener 的值是 null。
<a href="https://google.com" rel="noopener" target="_blank">Google</a>
但是由于一些老的浏览器并不支持 noopener ,通常 noopener 和 noreferrer 会同时设置, rel=”noopener noreferrer”。
语法
回到主题,使用 window.opener 如何实现跨页面通信了。
- 收集对象 ```javascript // 收集 window 对象:单个打开页面 const windowOpen = window.open(‘xxx’);
// 收集 window 对象:多个打开页面,打开一个页面就需要将打开的 window 对象收集起来,以便于发布广播 const windowOpens = []; const windowOpen = window.open(‘xxx’); windowOpens.push(windowOpen);
- 发送消息
```javascript
// 发送消息:单个页面
windowOpen.postMessage(data);
// 发送消息:多个页面
windowOpens.forEach((window) => window.postMessage(data));
- 接受消息,对于接受消息来说,可能只是接受消息,但是可能接受消息的页面也打开了页面,这种情况需要将消息继续传递下去
window.addEventListener('message', function (e) {
const data = e.data;
console.log(data);
windowOpens.forEach((window) => window.postMessage(data));
});
Tips
- 在收集到的 window 对象中,可能有的 Tab 窗口被关闭了,这种情况下的 Tab 不需要进行消息传递。
- 对于接受消息的一方来说,需要继续传递消息,但是这里存在一个问题就是消息回传,可能出现两者之间消息的死循环传递。
- 这种方式,类似击鼓传花,一个传一个,传递的消息从前往后,一条锁链。
- 但是如果页面不是通过一个页面打开的,而且直接打开的,或者从三方网站跳转的,那这条锁链将断开。所以这种方式基本只做了解,问题太多,可不做参考。
完善的代码如下:
<button id="tab">新开 Tab</button>
<button id="l-btn">发送消息</button>
<script>
// 收集 window 对象:多个打开页面,打开一个页面就需要将打开的 window 对象收集起来,以便于发布广播
let windowOpens = [];
document.getElementById('tab').onclick = function () {
// IP 地址为本地起的服务
const windowOpen = window.open('http://127.0.0.1:5500/CrossPageCommunication/open&opener.html');
windowOpens.push(windowOpen);
}
document.getElementById('l-btn').onclick = function () {
const data = {};
console.log(windowOpens);
// 发送消息之前,先进行已关闭 Tab 的过滤
windowOpens = windowOpens.filter((window) => !window.closed);
if (windowOpens.length > 0) {
// 数据打一个标记
data.tag = false;
data.message = '测试,传送消息,我发送消息啦。。。'
windowOpens.forEach((window) => window.postMessage(data));
}
if (window.opener && !window.opener.closed) {
data.tag = true;
window.opener.postMessage(data);
}
}
window.addEventListener('message', function (e) {
const data = e.data;
console.log('我接受到消息了:', data.message);
// 避免消息回传
if (window.opener && !window.opener.closed && data.tag) {
window.opener.postMessage(data);
}
// 过滤掉已经关闭的 Tab
windowOpens = windowOpens.filter((window) => !window.closed);
// 避免消息回传
if (windowOpens && !data.tag) {
windowOpens.forEach((window) => window.postMessage(data));
}
});
</script>
SharedWorker
SharedWorker 接口代表一种特定类型的 worker,可以从几个浏览上下文中访问,例如几个窗口、iframe 或其他 worker。它们实现一个不同于普通 worker 的接口,具有不同的全局作用域, SharedWorkerGlobalScope。
语法
// 创建共享线程对象
let worker = new SharedWorker("./sharedWorker.js");
// 手动启动端口
worker.port.start();
// 处理从 worker 返回的消息
worker.port.onmessage = function (val) {
...
};
<button id="tab">新开 Tab</button>
<button id="l-btn">点赞</button>
<p><span id="likedCount">还没有人点赞</span></span>👍</p>
<script>
let likedCountEl = document.querySelector("#likedCount");
let worker = new SharedWorker("./sharedWorker.js");
console.log('worker.port', worker.port);
worker.port.start();
// 监听消息
worker.port.onmessage = function (val) {
likedCountEl.innerHTML = val.data;
};
document.getElementById('tab').onclick = function () {
// IP 地址为本地起的服务
const windowOpen = window.open('http://127.0.0.1:5500/CrossPageCommunication/sharedWorker/index.html');
}
document.getElementById('l-btn').onclick = function () {
worker.port.postMessage('点赞了');
};
</script>
// ./sharedWorker.js
let a = 666;
console.log('shared-worker');
onconnect = function (e) {
const port = e.ports[0];
console.log('shared-worker connect');
// 不能使用这种方式监听事件
// port.addEventListener('message', () => {
// port.postMessage(++a);
// });
port.postMessage(a);
port.onmessage = () => {
port.postMessage(++a);
};
console.log('当前点赞次数:', a);
};
Tips
- 如果要使 SharedWorker 连接到多个不同的页面,这些页面必须是同源的(相同的协议、host 以及端口)。
- Shared Worker 在实现跨页面通信时的,它无法主动通知所有页面,需要刷新页面或者是定时任务来检查是否有新的消息。在例子中我是手动刷新的,当然可以使用 setInterval 来定时刷新。
- 如果需要调试 SharedWorker,使用 chrome://inspect/#workers
- sharedWorker.js 中不能使用 .addEventListener 来监听 message 事件,监听无效。
- 兼容性一般。
总结
在上面列举了五种前端跨页面通信的方式,当然对前端来说远远不止这五种方式,还有其他方案例如:使用 hashchange、cookie、Websocket、postMessage 都是可以的。文章中只是列举了部分。并且文章中的方案都是针对同源的 Tab。
文章的前三种解决方式不论是 Broadcast Channel,还是 Service Worker ,或是 storage 事件,其都是“广播模式”:一个页面将消息通知给一个“中央站”,再由“中央站”通知给各个页面。
而对于 open & opener 这种方式,类似击鼓传花,一个传一个,传递的消息从前往后,一条锁链。但是如果页面不是通过一个页面打开的,而且直接打开的,或者从三方网站跳转的,那这条锁链将断开。
Shared Worker 的最大问题在于再实现跨页面通信时的,它无法主动通知所有页面,需要刷新页面或者是定时任务来检查是否有新的消息,也就是需要配合轮询来使用。
最终在我们团队对于期待男跨页面通信最后选择的解决方案是使用 onstorage,主要考量的三个方面:
- 兼容性。浏览器支持度。
- 通用性。能否覆盖需求、是否具有拓展性。
- 便捷性。开发便捷程度。
参考
- https://www.w3cschool.cn/fetch_api/fetch_api-xsc32qgb.html
- https://developer.mozilla.org/zh-CN/docs/Web/API/WindowEventHandlers/onstorage#compat-mobile
- https://juejin.cn/post/6844903811232825357#heading-4
- https://zhuanlan.zhihu.com/p/81237384
- https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel
- https://juejin.cn/post/6844903811228663815
- https://developer.mozilla.org/zh-CN/docs/Web/API/Service_Worker_API
- https://developer.mozilla.org/en-US/docs/Web/API/SharedWorker
- https://zhuanlan.zhihu.com/p/366736912
- https://blog.bhanuteja.dev/noopener-noreferrer-and-nofollow-when-to-use-them-how-can-these-prevent-phishing-attacks
- https://blog.csdn.net/huangpb123/article/details/89498418
- https://my.oschina.net/ahaoboy/blog/4321769