面试装X:我知道的前端跨页面通信

23,613 阅读9分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 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>

onstorage.gif

onstorage1.gif

Tips

  1. 该事件不在导致数据变化的当前页面触发(如果浏览器同时打开一个域名下面的多个页面,当其中的一个页面改变 数据时,其他所有页面的 storage 事件会被触发,而原始页面并不触发 storage 事件)。
  2. sessionStorage(❎)不能触发 storage 事件 , localStorage(✅)可以。
  3. 如果修改的值未发生改变,将不会触发 onstorage 事件。
  4. 优点:浏览器支持效果好、API直观、操作简单。缺点:部分浏览器隐身模式下,无法设置 localStorage。如safari,这样也就导致 onstrage 事件无法使用。

除开少数情况,localStorage的兼容性不错,就当前国内的情况,已经基本没有问题了。localStorage 的原理很简单,浏览器为每个域名划出一块本地存储空间,用户网页可以通过 localStorage 命名空间进行读写。 onstorage兼容性.png

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>

Broadcast Channel.gif Broadcast Channel1.gif

Tips

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

BroadCast Channel兼容性.png

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

Service Worker.gif

Service Worker1.gif

Tips

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

Service Worker兼容性.png

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

  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>

image.png

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

image.png

Tips

  1. 如果要使 SharedWorker 连接到多个不同的页面,这些页面必须是同源的(相同的协议、host 以及端口)。
  2. Shared Worker 在实现跨页面通信时的,它无法主动通知所有页面,需要刷新页面或者是定时任务来检查是否有新的消息。在例子中我是手动刷新的,当然可以使用 setInterval 来定时刷新。
  3. 如果需要调试 SharedWorker,使用 chrome://inspect/#workers

image.png

image.png

  1. sharedWorker.js 不能使用 .addEventListener 来监听 message 事件,监听无效。 兼容性一般。

image.png

总结

在上面列举了五种前端跨页面通信的方式,当然对前端来说远远不止这五种方式,还有其他方案例如:使用 hashchange、cookie、Websocket、postMessage 都是可以的。文章中只是列举了部分。并且文章中的方案都是针对同源的 Tab。

文章的前三种解决方式不论是 Broadcast Channel,还是 Service Worker ,或是 storage 事件,其都是“广播模式”:一个页面将消息通知给一个“中央站”,再由“中央站”通知给各个页面。

而对于 open & opener 这种方式,类似击鼓传花,一个传一个,传递的消息从前往后,一条锁链。但是如果页面不是通过一个页面打开的,而且直接打开的,或者从三方网站跳转的,那这条锁链将断开。

Shared Worker 的最大问题在于实现跨页面通信时的,它无法主动通知所有页面,需要刷新页面或者是定时任务来检查是否有新的消息,也就是需要配合轮询来使用。

最终在我们团队对于前端跨页面通信最后选择的解决方案是使用 onstorage,主要考量的三个方面:

  • 兼容性。浏览器支持度。
  • 通用性。能否覆盖需求、是否具有拓展性。
  • 便捷性。开发便捷程度。

其他方案在这三个方面来说都或多或少存在一些美中不足。

参考