# 跨标签页通信的方案与实践(上)

# 什么是跨标签页通信

现代浏览器基本都支持多标签页,我们可以在同一个浏览器中同时打开多个标签页,访问多个网站。

所谓跨标签页通信,就是说,一个标签页可以发送信息给另一个标签页。

本文介绍 9 中跨标签页通信的方式:

  • BroadCast Channel + onMessage(同源)
  • Service Worker + onMessage(同源)
  • SharedWorker + 轮询(同源)
  • Localstorage + onStore(同源)
  • IndexDB + 轮询(同源)
  • Cookie + 轮询(同源)
  • window.postMessage + onMessage(非同源
  • Webscoket(非同源
  • HTTP + 轮询(非同源

# BroadCast Channel + onMessage

BroadcastChannel - Web API | MDN (mozilla.org) (opens new window)

BroadcastChannel 接口代理了一个命名频道,可以让同一源下的所有标签页来订阅它。它允许同源的不同浏览器窗口,Tab 页,之间相互通信。通过触发一个 message (opens new window) 事件,消息可以广播到所有监听了该频道的 BroadcastChannel 对象。(BroadcastChannel - Web API | MDN (mozilla.org) (opens new window)

我们在 tab1 标签页中创建一个频道,并为频道命名。点击按钮之后,使用 postMessage(data) 来传递消息:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <input type="text" class="ipt">
    <button class="btn">发送</button>

    <script>
        const btn = document.querySelector('.btn')
        // 创建频道
        const broadCast = new BroadcastChannel('myChannel')
        btn.addEventListener('click', () => {
            const value = document.querySelector('.ipt').value
            // 发送消息
            broadCast.postMessage(value)
        })
    </script>
</body>

</html>

tab2 标签页中创建一个同名的频道,并监听 message 事件,当事件触发时,会传入事件对象,对象的 data 属性就是我们传递的消息参数。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <script>
        const broadCast = new BroadcastChannel('myChannel')
        broadCast.addEventListener('message', (e) => {
            alert(`收到消息:${e.data}`)
        })
    </script>
</body>

</html>

点击按钮,测试效果如下:

成功实现跨标签页通信

# Service Worker + onMessage

Service Worker API - Web API | MDN (mozilla.org) (opens new window)

Service Worker 是 web worker 的一种,是一个后台运行的脚本,其实就是浏览器和服务器之间的代理服务器,在页面中注册和安装成功之后,他可以长期运行在页面后台,不受刷新操作的影响。它的最常见用途就是拦截和处理网络请求

Service Worker 可以拦截并修改用户发出的请求,也可以直接向用户发出响应,而不用联系后端服务器。我们可以使用 Service Worker 来进行接口缓存或离线缓存。但是 Service Worker 本身是不会存储数据的,所以,为了在重新启动时仍然可以拿到数据,可以将数据保存在本地缓存中,一般来说是放在 indexDB 中的。

对于跨标签页通信,我们是将 Service Worker 作为了一个数据中转站,在 tab1 页面中向 Service Worker 发送消息和数据,Service Worker 接收到数据之后,再将数据转发给 tab2 页面。

tab1 中创建 Service Worker,并向 Service Worker 中发送消息:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <input type="text" class="ipt">
    <button class="btn">发送</button>

    <script>
        const btn = document.querySelector('.btn')
        const ipt = document.querySelector('.ipt')

        // 注册 Service Worker
        navigator.serviceWorker.register('sw.js')
            .then(() => {
                console.log('tab1 SW is ok');
            })
            .catch((err) => {
                console.log(err);
            })

        btn.addEventListener('click', function () {
            // 向 service worker 中发送消息
            navigator.serviceWorker.controller.postMessage(ipt.value)
        }) 
    </script>
</body>

</html>

Service Worker 接收到消息之后,将消息转发给其他客户端:

self.addEventListener('message', async (e) => {
    // 获取所有注册了 Service Worker 的客户端
    const clients = await self.clients.matchAll()
    // 循环每一个客户端
    clients.forEach((client) => {
        console.log(client);
        // 将消息转发给每个客户端
        client.postMessage(e.data)
    })
})

tab2 中监听 Service Worker 的 message 事件:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <script>
        navigator.serviceWorker.register('sw.js')
            .then(() => {
                console.log('tab2 SW is ok');
            })
        navigator.serviceWorker.addEventListener('message', (e) => {
            alert(`接收到了消息:${e.data}`)
        })
    </script>
</body>

</html>

测试效果:

踩坑

出于安全的考虑 Service Worker 必须运行在 HTTPS 协议下。为了方便开发,localhost127.0.0.1 这种非 HTTPS 协议也被浏览器认为是安全源。

我使用的是 VScode 的 live 插件和 Chrome 。但是 Chrome 浏览器默认没有将 127.0.0.1 认为是安全源,需要我们手动配置,否则无法注册 Service Worker。配置方法如下:

  • 在地址栏中输入: chrome://flags/#unsafely-treat-insecure-origin-as-secure
  • 配置安全源:127.0.0.1
  • 单击启用并单击重新启动按钮

# SharedWorker + 轮询

SharedWorker - Web API | MDN (mozilla.org) (opens new window)

SharedWorker 是一种可以共享的 web worker,可以从几个浏览上下文中访问,例如几个窗口、iframe 或其他 worker。

注意:如果要使 SharedWorker 连接到多个不同的页面,这些页面必须是同源的。

首先我们在 tab1 中创建一个 SharedWorker,并向 SharedWorker 中发送消息:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <input type="text" class="ipt">
    <button class="btn">发送</button>

    <script>
        const btn = document.querySelector('.btn')
        const ipt = document.querySelector('.ipt')

        const worker = new SharedWorker('sw.js')

        btn.addEventListener('click', function () {
            worker.port.postMessage(ipt.value)
        }) 
    </script>
</body>

</html>

SharedWorker 将 tab1 传入的数据保存起来,当 tab2 发送询问请求时,发送给 tab2

let data = null

// 当连接到 SharedWorker 时触发此函数
onconnect = function (e) {
    var port = e.ports[0];

    port.addEventListener("message", function (e) {
        if (e.data === 'getData') {
            // 发送给请求方
            port.postMessage(data);
            data = "";
        } else {
            // 保存数据
            data = e.data
        }
    });

    port.start();
};

由于tab2 没有给 SharedWorker 发送过消息,所以 SharedWorker 不能主动给 tab2 发消息,故让 tab2 轮询发送消息询问 SharedWorker :

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <script>
        // 创建相同的 SharedWorker
        let worker = new SharedWorker('sw.js');
        // 启动 SharedWorker
        worker.port.start()

        // 监听 SharedWorker 反馈回来的数据
        worker.port.addEventListener('message', (e) => {
            if (e.data) {
                alert(`接收到了消息:${e.data}`)
            }
        });

        setInterval(function () {
            // 向 SharedWorker 发送消息,询问是否有来自 tab1 的数据
            worker.port.postMessage('getData')
        }, 1000);
    </script>
</body>

</html>

# Localstorage + onStore

Window.localStorage - Web API | MDN (mozilla.org) (opens new window)

localstorage 中,每次将一个值存储到本地存储时,就会触发一个 storage 事件。

由事件监听器发送给回调函数的事件对象有几个自动填充的属性如下:

  • key:告诉我们被修改的条目的键。

  • newValue:告诉我们被修改后的新值。

  • oldValue:告诉我们修改前的值。

  • storageArea:指向事件监听对应的 Storage 对象。

  • url:原始触发 storage 事件的那个网页的地址。

需要注意的是,此方案下,两个标签页的 URL 必须同源。因为 localstorage 只在同源页面中共享。

首先在 tab1 中向 localstorage 中存储数据:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <input type="text" class="ipt">
    <button class="btn">发送</button>

    <script>
        const btn = document.querySelector('.btn')
        const ipt = document.querySelector('.ipt')

        btn.addEventListener('click', function () {
            localStorage.setItem('msg', ipt.value)
        }) 
    </script>
</body>

</html>

tab2 中监听 Storage 事件:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <script>
        window.addEventListener('storage', (e) => {
            alert(`收到消息:新值为${e.newValue},旧值为${e.oldValue}`)
        })
    </script>
</body>

</html>

测试效果: