一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情。
背景
用户在实际的操作场景中会打开多个 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 如何实现跨页面通信了。
- 收集对象
// 收集 window 对象:单个打开页面 const windowOpen = window.open('xxx'); // 收集 window 对象:多个打开页面,打开一个页面就需要将打开的 window 对象收集起来,以便于发布广播 const windowOpens = []; const windowOpen = window.open('xxx'); windowOpens.push(windowOpen); 复制代码
- 发送消息
// 发送消息:单个页面 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,主要考量的三个方面:
- 兼容性。浏览器支持度。
- 通用性。能否覆盖需求、是否具有拓展性。
- 便捷性。开发便捷程度。
其他方案在这三个方面来说都或多或少存在一些美中不足。
参考
- www.w3cschool.cn/fetch_api/f…
- developer.mozilla.org/zh-CN/docs/…
- juejin.cn/post/684490…
- zhuanlan.zhihu.com/p/81237384
- developer.mozilla.org/en-US/docs/…
- juejin.cn/post/684490…
- developer.mozilla.org/zh-CN/docs/…
- developer.mozilla.org/en-US/docs/…
- zhuanlan.zhihu.com/p/366736912
- blog.bhanuteja.dev/noopener-no…
- blog.csdn.net/huangpb123/…
- my.oschina.net/ahaoboy/blo…