collins

V1

2022/04/12阅读:11主题:自定义主题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

  1. 该事件不在导致数据变化的当前页面触发(如果浏览器同时打开一个域名下面的多个页面,当其中的一个页面改变 数据时,其他所有页面的 storage 事件会被触发,而原始页面并不触发 storage 事件)。
  2. sessionStorage(❎)不能触发 storage 事件 , localStorage(✅)可以。
  3. 如果修改的值未发生改变,将不会触发 onstorage 事件。
  4. 优点:浏览器支持效果好、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>

111

Tips

  1. 监听消息除了 .onmessage 这种方式,还可以 使用addEventListener来添加'message'监听,
  2. 关闭除了使用 Broadcast Channel 实例为我们提供的 close 方法来关闭 Broadcast Channel。我们还可取消或者修改相应的'message'事件监听。两者是有区别的:取消'message'监听只是让页面不对广播消息进行响应,Broadcast Channel 仍然存在;而调用 close 方法会切断与 Broadcast Channel 的连接,浏览器才能够尝试回收该对象,因为此时浏览器才会知道用户已经不需要使用广播频道了。
  3. 兼容性:如果不使用 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)
      }
    })
  })
})

111

Tips

  1. Service workers 本质上充当 Web 应用程序、浏览器与网络(可用时)之间的代理服务器。所以本质上来说 Service Worker 并不自动具备“广播通信”的功能,需要改造 Service Worker 添加些代码,将其改造成消息中转站。在 Service Worker 中监听了message事件,获取页面发送的信息。然后通过 self.clients.matchAll() 获取当前注册了 Service Worker 的所有页面,通过调用每个的 postMessage 方法,向页面发送消息。这样就把从一处(某个Tab页面)收到的消息通知给了其他页面。
  2. 兼容性: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));
});

111

Tips

  1. 在收集到的 window 对象中,可能有的 Tab 窗口被关闭了,这种情况下的 Tab 不需要进行消息传递。
  2. 对于接受消息的一方来说,需要继续传递消息,但是这里存在一个问题就是消息回传,可能出现两者之间消息的死循环传递。
  3. 这种方式,类似击鼓传花,一个传一个,传递的消息从前往后,一条锁链。
  4. 但是如果页面不是通过一个页面打开的,而且直接打开的,或者从三方网站跳转的,那这条锁链将断开。所以这种方式基本只做了解,问题太多,可不做参考。

完善的代码如下:

<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);
};

111

Tips

  1. 如果要使 SharedWorker 连接到多个不同的页面,这些页面必须是同源的(相同的协议、host 以及端口)。

  2. Shared Worker 在实现跨页面通信时的,它无法主动通知所有页面,需要刷新页面或者是定时任务来检查是否有新的消息。在例子中我是手动刷新的,当然可以使用 setInterval 来定时刷新。

  3. 如果需要调试 SharedWorker,使用 chrome://inspect/#workers

  4. 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

分类:

前端

标签:

JavaScript

作者介绍

collins
V1