浏览器跨 Tab 窗口通信原理

发布时间 2023-12-05 22:54:51作者: CD、小月

浏览器跨 Tab 窗口通信原理

所谓多窗口下进行互相通信,是指在浏览器中,不同窗口(包括不同标签页、不同浏览器窗口甚至不同浏览器实例)之间进行数据传输和通信的能力

当然,本文我们探讨的是纯前端的跨 Tab 页面通信,在非纯前端的方式下,我们可以借助诸如 Web Socket 等方式,藉由后端这个中间载体,进行跨页面通信。

有多种方法可以实现跨页面通信,具体选择方法取决于需求和兼容性要求。以下是一些常见的前端跨页面通信方法:

  1. 「Broadcast Channel:」 使用 Broadcast Channel API,可以在同一浏览器中的不同页面之间实现实时通信。

  2. 「Window.postMessage():」 Window.postMessage() 方法允许您在不同窗口之间安全地发送消息。这个方法通过发送消息事件来触发消息接收,可以在不同域之间使用,只要目标窗口同意。

    // 发送消息
    window.postMessage('Hello from Page 1!', 'https://example.com');
    
    // 接收消息
    window.addEventListener('message', event => {
     console.log(event.data);
    });
    
  3. 「LocalStorage 或 SessionStorage:」 您可以使用 localStoragesessionStorage 存储数据,以便在不同页面之间共享信息。这种方式适用于需要持久性数据的情况,但只能存储字符串数据。

  4. 「Cookies:」 使用浏览器的 cookies 来在不同页面之间传递信息。这种方法适用于存储小量数据,但可能会受到一些安全和隐私限制。

  5. 「Web Sockets:」 使用 WebSocket 协议可以实现实时双向通信,适用于需要高度实时性的应用程序。WebSocket 允许不同页面之间建立持久性连接,以进行双向通信。

  6. 「Shared Workers:」 Shared Workers 是一种多个页面之间共享的 Web Worker,它可以在不同页面之间进行消息传递。这适用于高性能计算和共享数据的场景。

  7. 「服务端通信:」 如果需要在不同页面之间共享数据,可以通过与服务器进行通信,将数据存储在服务器上,然后在不同页面中从服务器获取数据。

  8. 「跨文档通信库:」 有一些开源 JavaScript 库和框架,如 localForagepostiseasyXDM 等,专门用于处理跨页面通信问题。这些库提供了更高级的抽象和易用性,使通信更容易管理。

为了实现跨窗口通信,它应该需要具备以下能力:

  1. 数据传输能力:能够将数据从一个窗口发送到另一个窗口,以及接收来自其他窗口的数据。
  2. 实时性:能够实现实时或近实时的数据传输,以便及时更新不同窗口的内容。
  3. 安全性:确保通信过程中的数据安全,防止恶意窃取或篡改通信数据。当然,这个不是本文讨论的重点,但是是实际应用中不应该忽视的一个重点。

Broadcast Channel

Broadcast Channel 是一个较新的 Web API,用于在不同的浏览器窗口、标签页或框架之间实现跨窗口通信。它基于发布-订阅模式,允许一个窗口发送消息,并由其他窗口接收。

其核心步骤如下:

  1. 创建一个 BroadcastChannel 对象:在发送和接收消息之前,首先需要在每个窗口中创建一个 BroadcastChannel 对象,使用相同的频道名称进行初始化。
  2. 发送消息:通过 BroadcastChannel 对象的 postMessage() 方法,可以向频道中的所有窗口发送消息。
  3. 接收消息:通过监听 BroadcastChannel 对象的 message 事件,可以在窗口中接收到来自其他窗口发送的消息。

同时,Broadcast Channel 遵循浏览器的同源策略。这意味着只有在同一个协议、主机和端口下的窗口才能正常进行通信。如果窗口不满足同源策略,将无法互相发送和接收消息。

因为有同源限制,我们需要起一个服务,这里我基于 Vite 快速起了一个 Vue 项目,简单的基于 .vue 文件下进行一个演示。

其核心代码非常简单:

<template>
  <div class="g-container" id="j-main">
    // ...  
  </div>
</template>

<script>
import { onMounted } from 'vue';

export default {
  setup() {
    function createBroadcastChannel() {
      broadcastChannel = new BroadcastChannel('broadcast');
      broadcastChannel.onmessage = handleMessage;
    }

    function sendMessage(data) {
      broadcastChannel.postMessage(data);
    }

    function handleMessage(event) {
        console.log('接收到 event', event);
        // TODO: 处理接收到信息后的逻辑
    }

    function resizeEventBind() {
      window.addEventListener('resize', () => {
         const pos = getCurPos();
         sendMessage(pos);
       });
    }

    // 计算当前元素距离显示器窗口右上角的距离
    function getCurPos() {
      const barHeight = window.outerHeight - window.innerHeight;
      const element = document.getElementById('j-main');
      const rect = element.getBoundingClientRect();

      // 获取元素相对于屏幕左上角的 X 和 Y 坐标
      const x = rect.left + window.screenX; // 元素左边缘相对于屏幕左边缘的距离
      const y = rect.top + window.screenY + barHeight;// 元素顶部边缘相对于屏幕顶部边缘的距离

      return [x, y];
    }
    
    onMounted(() => {
      createBroadcastChannel();
      resizeEventBind();
    });

    return {};
  }
};
</script>

<style lang="scss"></style>

这里,我们的核心逻辑在于:

  • createBroadcastChannel() 函数用于创建一个 BroadcastChannel 对象,并设置消息处理函数。
  • sendMessage(data) 函数用于向 BroadcastChannel 发送消息。
  • handleMessage(event) 函数用于处理接收到的消息。
  • resizeEventBind() 函数用于监听窗口大小变化事件,并在事件发生时获取当前元素的位置信息,并通过 sendMessage() 函数发送位置信息到 BroadcastChannel。
  • getCurPos() 函数用于计算当前元素相对于显示器窗口右上角的距离。

onMounted() 生命周期钩子中,调用了 createBroadcastChannel()resizeEventBind() 函数,用于在组件挂载后执行相关的初始化操作。

这里的核心点,还是:

  1. 数据向其他 Tab 页面传递的能力
  2. Tab 页面接受其他页面传递过来的数据的能力

其本质就是一个数据共享池子。

SharedWorker API

SharedWorker API 是 HTML5 中提供的一种多线程解决方案,它可以在多个浏览器 TAB 页面之间共享一个后台线程,从而实现跨页面通信。

与其他 Worker 不同的是,SharedWorker 可以被多个浏览器 TAB 页面共享,且可以在同一域名下的不同页面之间建立连接。这意味着,多个页面可以通过 SharedWorker 实例之间的消息传递,实现跨 TAB 页面的通信。

它的实现与上面的 Broadcast Channel 非常类似,我们来看一看实际的代码:

<template>
  <div class="g-container" id="j-main">
    // ...  
  </div>
</template>

<script>
import { onMounted } from 'vue';

export default {
  setup() {
    // 创建一个 SharedWorker 对象
    let worker;
    
    function initWorker() {
      // 创建一个 SharedWorker 对象
      worker = new SharedWorker('/shared-worker.js', 'tabWorker');

      // 监听消息事件
      worker.port.onmessage = function (event) {
        console.log('接收到 event', event);
        handleMessage(event);
      };
    }
    
    function handleMessage(data) {
      // TODO: 处理接收到信息后的逻辑
    }

    function sendMessage(data) {
      // 发送消息
      worker.port.postMessage(data);
    }

    function resizeEventBind() {
      window.addEventListener('resize', () => {
        const pos = getCurPos();
        sendMessage(pos);
      });
    }

    function getCurPos() {
      const barHeight = window.outerHeight - window.innerHeight;
      const element = document.getElementById('j-main');
      const rect = element.getBoundingClientRect();

      // 获取元素相对于屏幕左上角的 X 和 Y 坐标
      const x = rect.left + window.screenX; // 元素左边缘相对于屏幕左边缘的距离
      const y = rect.top + window.screenY + barHeight;// 元素顶部边缘相对于屏幕顶部边缘的距离

      return [x, y];
    }
    
    onMounted(() => {
      initWorker();
      resizeEventBind();
    });

    return {};
  }
};
</script>

<style lang="scss"></style>

简单描述一下,上面也说了,跨 Tab 页通信的核心在于数据向外的发送与接收的能力:

  1. initWorker() 方法中,使用 worker = new SharedWorker('/shared-worker.js', 'tabWorker') 创建了一个 SharedWorker 后面每一个被打开的同域浏览器 TAB 页面,都是共享这个 Worker 线程,从而实现跨页面通信
  2. 基于 worker.port.postMessage(data)实现数据的传输
  3. 基于 worker.port.onmessage = function() {} 实现传输数据的监听

当然,上面有引入一个 /shared-worker.js,这个是需要额外定义的,一个极简版本的代码如下:

//shared-worker.js
const connections = [];

onconnect = function (event) {
  var port = event.ports[0];
  connections.push(port);

  port.onmessage = function (event) {
    // 接收到消息时,向所有连接发送该消息
    connections.forEach(function (conn) {
      if (conn !== port) {
        conn.postMessage(event.data);
      }
    });
  };

  port.start();
};

简单解析一下,下面对其进行解析:

  1. 上面的代码中,定义了一个数组 connections,用于存储与 SharedWorker 建立连接的各个页面的端口对象;
  2. onconnect 是事件处理程序,当有新的连接建立时会触发该事件;
  3. 在 onconnect 函数中,通过 event.ports[0] 获取到与 SharedWorker 建立的连接的第一个端口对象,并将其添加到 connections 数组中,表示该页面与共享 Worker 建立了连接。
  4. 在连接建立后,为每个端口对象设置了 onmessage 事件处理程序。当端口对象接收到消息时,会触发该事件处理程序。
  5. 在 onmessage 事件处理程序中,通过遍历 connections 数组,将消息发送给除当前连接端口对象之外的所有连接。这样,消息就可以在不同的浏览器 TAB 页面之间传递。
  6. 最后,通过调用 port.start() 启动端口对象,使其开始接收消息。

总而言之,shared-worker.js 脚本创建了一个共享 Worker 实例,它可以接收来自不同页面的连接请求,并将接收到的消息发送给其他连接的页面。通过使用 SharedWorker API,实现跨 TAB 页面之间的通信和数据共享

可以看到,在 SharedWorker 方式中,传输数据与 Broadcast Channel 是一样的,都是利用 Message Event。简单对比一下:

  1. SharedWorker 通过在多个Tab页面之间共享相同的 Worker 实例,方便地共享数据和状态,SharedWorker 需要多定义一个 shared-worker.js;
  2. Broadcast Channel 通过向所有订阅同一频道的 Tab 页面广播消息,实现广播式的通信。

localStorage/sessionStorage

另一种跨 Tab 窗口通信的方式是利用 localStoragesessionStorage 本地化存储 API 以及的 storage 事件。

与上面 Broadcast Channel、SharedWorker 稍微不同的地方在于:

  1. localStorage 方式,利用了本地浏览器存储,实现了同域下的数据共享;
  2. localStorage 方式,基于 window.addEventListener('storage', function(event) {})事件实现了 localStore 变化时候的数据监听;
<template>
  <div class="g-container" id="j-main">
    // ...  
  </div>
</template>

<script>
import { ref, reactive, computed, onMounted } from 'vue';

export default {
  setup() {
    function initLocalStorage() {
      let tabArray = JSON.parse(localStorage.getItem('tab_array'));
      if (!tabArray) {
        const tabIndex = 1;
        id = tabIndex;
        localStorage.setItem('tab_array', JSON.stringify([tabIndex]));
      } else {
        const tabIndex = tabArray[tabArray.length - 1] + 1;
        id = tabIndex;
        const newTabArray = [...tabArray, tabIndex];
        localStorage.setItem('tab_array', JSON.stringify(newTabArray));
      }
    }

    function setLocalStorage(data) {
      localStorage.setItem(`tab_index_${id}`, JSON.stringify(data));
    }

    function handleMessage(data) {
      const rArray = JSON.parse(data);
      remoteX.value = rArray[0];
      remoteY.value = rArray[1];
    }

    function resizeEventBind() {
      window.addEventListener('resize', () => {
         const pos = getCurPos();
         setLocalStorage(pos);
      });
 
      window.addEventListener('storage', (event) => {
        console.log('localStorage 变化了!', event);
        console.log('键名:', event.key);
        console.log('变化前的值:', event.oldValue);
        console.log('变化后的值:', event.newValue);
        handleMessage(event.newValue);
      });
    }

    function getCurPos() {
      const barHeight = window.outerHeight - window.innerHeight;
      const element = document.getElementById('j-main');
      const rect = element.getBoundingClientRect();

      // 获取元素相对于屏幕左上角的 X 和 Y 坐标
      const x = rect.left + window.screenX; // 元素左边缘相对于屏幕左边缘的距离
      const y = rect.top + window.screenY + barHeight;// 元素顶部边缘相对于屏幕顶部边缘的距离

      return [x, y];
    }
    
    onMounted(() => {
      initLocalStorage();
      resizeEventBind();
    });

    return {};
  }
};
</script>

<style lang="scss"></style>

同样的简单解析一下:

  1. 每次页面初始化时,都会首先有一个 initLocalStorage 过程,用于给当前页面一个唯一 ID 标识,并且存入 localStorage 中
  2. 每次页面 resize,将当前页面元素 #j-main 的坐标值,通过 ID 标识当 Key,存入 localStorage 中
  3. 其他页面,通过 window.addEventListener('storage', (event)=> {}) 监听 localStorage 的变化

跨 Tab 窗口通信应用场景

当然,除了最近大火的跨 Tab 动画应用场景,实际业务中,还有许多场景是它可以发挥作用的。这些场景利用了跨 Tab 通信技术,增强了用户体验并提供了更丰富的功能。

以下是一些常见的应用场景:

  1. 实时协作:多个用户可以在不同的 Tab 页上进行实时协作,比如编辑文档、共享白板、协同编辑等。通过跨Tab通信,可以实现实时更新和同步操作,提高协作效率。

譬如这个:

  1. 多标签页数据同步:当用户在一个标签页上进行了操作,希望其他标签页上的数据也能实时更新时,可以使用跨 Tab 通信来实现数据同步,保持用户在不同标签页上看到的数据一致性。
  1. 跨标签页通知:在某些场景下,需要向用户发送通知或提醒,即使用户不在当前标签页上也能及时收到。通过跨 Tab 通信,可以实现跨页面的消息传递,向用户发送通知或提醒。
  1. 多标签页状态同步:有些应用可能需要在不同标签页之间同步用户的状态信息,例如登录状态、购物车内容等。通过跨 Tab 通信,可以确保用户在不同标签页上看到的状态信息保持一致。
  1. 页面间数据传输:有时候用户需要从一个页面跳转到另一个页面,并携带一些数据,通过跨Tab通信可以在页面之间传递数据,实现数据的共享和传递。

总之,跨 Tab 窗口通信在实时协作、数据同步、通知提醒等方面都能发挥重要作用,为用户提供更流畅、便捷的交互体验。