前端跨页面通讯终极指南⑦:ServiceWorker 用法全解析

前言

上一篇我们介绍了SharedWorker,今天要介绍一种与SharedWorker 的"页面存活依赖"不同,即便在所有页面关闭后仍可后台运行,凭借"后台常驻"特性,实现跨页面、跨会话的通讯。它就是ServiceWorker

本文就带你了解下ServiceWorker ,看看是如何进行跨页面通讯。

1. ServiceWorker 是什么?

在聊通讯之前,我们先了解ServiceWorker的核心定位------它是一种独立于页面主线程的后台线程,由浏览器管理,具备以下关键特性:

  • 独立线程:运行在与页面完全隔离的线程中,不阻塞页面渲染,可执行网络请求、缓存管理等操作。
  • 后台常驻:注册成功后会在后台持续运行,即使所有关联页面关闭,仍能响应事件(如推送通知、网络请求)。
  • 同源限制:仅能控制与其注册页面同源的页面,且协议必须为HTTPS(本地开发可使用localhost例外)。
  • 事件驱动 :通过监听installactivatemessage等事件实现功能逻辑,无DOM操作能力。

这些特性也是其实现跨页面通讯的基础,简单来说,ServiceWorker就像一个"驻留在浏览器中的微型服务端",多个页面可通过它建立通讯连接,实现数据共享与消息传递,甚至在页面离线时完成特定交互。

2. ServiceWorker 是如何进行跨页面通讯

2.1 ServiceWorker 生命周期

scss 复制代码
注册 (Register)
    ↓
安装 (Install)
    ↓
激活 (Activate)
    ↓
运行 (Active)
    ↓
终止 (Terminated)

ServiceWorker的跨页面通讯核心是"中心化消息枢纽"模式,依托其"单例运行+多页面连接管理"的特性实现,具体流程可分为三个阶段:

  1. 注册与激活 :首个页面通过navigator.serviceWorker.register()注册ServiceWorker脚本,浏览器启动后台线程并执行脚本,触发installactivate事件,此时ServiceWorker进入激活状态,具备通讯能力。
  2. 页面连接建立 :每个页面在ServiceWorker激活后,可通过navigator.serviceWorker.controller获取激活的实例,或监听controllerchange事件确认连接,进而通过postMessage()建立消息通道。
  3. 消息分发与传递 :ServiceWorker通过监听message事件接收任意页面的消息,可直接处理后反馈给发送页面,或通过clients.matchAll()获取所有连接的页面客户端,实现消息广播或点对点推送。

2.2 整体通讯架构

2.2.1 核心生命周期流程

2.2.2 关键流程详解

1. 页面连接注册流程
2. 消息路由分发流程
3. 广播与点对点消息流程

广播消息 :页面发送消息后,ServiceWorker向所有连接的客户端推送;点对点消息:精准定位目标页面ID,仅向指定客户端发送。

广播:

点对点:

4. 数据结构设计

使用Map存储客户端连接信息,确保页面ID与客户端实例的快速映射,支持高效的增删改查操作。

python 复制代码
┌─────────────────────────────────────────────────────────────────┐
│  connections: Map<string, ClientInfo>                           │
│  ──────────────────────────────────────                         │
│                                                                   │
│  结构示例:                                                       │
│  ┌───────────┬──────────────────────────────┐                   │
│  │    Key    │           Value              │                   │
│  ├───────────┼──────────────────────────────┤                   │
│  │ 'page-123'│ {                            │                   │
│  │           │   client: Client对象,        │                   │
│  │           │   id: 'client-abc123',       │                   │
│  │           │   pageId: 'page-123',        │                   │
│  │           │   lastActive: 1699999999999  │                   │
│  │           │ }                            │                   │
│  ├───────────┼──────────────────────────────┤                   │
│  │ 'page-456'│ {                            │                   │
│  │           │   client: Client对象,        │                   │
│  │           │   id: 'client-xyz789',       │                   │
│  │           │   pageId: 'page-456',        │                   │
│  │           │   lastActive: 1699999999999  │                   │
│  │           │ }                            │                   │
│  └───────────┴──────────────────────────────┘                   │
│                                                                   │
│  核心作用:保存client.id便于通过clients.matchAll()快速匹配目标    │
│                                                                   │
└─────────────────────────────────────────────────────────────────┘

3. 实践案例

ServiceWorker的通讯实现需区分"ServiceWorker脚本"(后台逻辑)和"页面脚本"(前端交互)两部分。

实现需求:同一域名下的pageA、pageB两个页面,通过ServiceWorker实现"点对点消息"和"全局广播消息"两种通讯模式。

3.1 步骤1:编写ServiceWorker核心脚本(sw.js)

该脚本负责监听页面连接、接收消息并实现分发逻辑,核心是通过clients对象管理所有连接的页面客户端。

整合注册管理、消息分发、连接清理等核心功能,支持注册、广播、点对点、心跳检测。

js 复制代码
// Service Worker 跨页面通信脚本

// 存储所有连接的页面客户端(使用 Map,key 是 pageId,value 是 client)
const connections = new Map();

console.log('Service Worker 已加载');

// 安装事件
self.addEventListener('install', (event) => {
    console.log('Service Worker 安装中...');
    // 跳过等待,立即激活
    self.skipWaiting();
});

// 激活事件
self.addEventListener('activate', (event) => {
    console.log('Service Worker 已激活');
    // 立即控制所有页面
    event.waitUntil(self.clients.claim());
});

// 监听来自页面的消息
self.addEventListener('message', async (event) => {
    console.log('Service Worker 收到消息:', event.data);

    const { type, pageId, target, data } = event.data;
    const client = event.source;

    // 0. 处理注册消息
    if (type === 'register') {
        // 保存客户端连接
        connections.set(pageId, {
            client: client,
            id: client.id,
            pageId: pageId
        });

        console.log(`页面 ${pageId} 已注册,当前在线:[${Array.from(connections.keys()).join(', ')}]`);

        // 发送注册成功消息
        client.postMessage({
            type: 'registered',
            from: 'ServiceWorker',
            data: `注册成功,当前在线 ${connections.size} 个页面`
        });
        return;
    }

    // 1. 处理广播消息
    if (type === 'broadcast') {
        console.log(`广播消息给 ${connections.size} 个连接`);

        // 获取所有客户端(包括未注册的)
        const allClients = await self.clients.matchAll({
            type: 'window',
            includeUncontrolled: true
        });

        // 遍历所有客户端发送消息
        allClients.forEach((client) => {
            try {
                client.postMessage({
                    type: 'broadcast',
                    from: 'ServiceWorker',
                    sender: pageId,
                    data: `广播消息:${data}`
                });
            } catch (e) {
                console.error('发送失败:', e);
            }
        });
    }

    // 2. 处理点对点消息
    if (type === 'private') {
        console.log(`私发消息给 ${target},当前连接:[${Array.from(connections.keys()).join(', ')}]`);

        if (connections.has(target)) {
            const targetConn = connections.get(target);

            try {
                // 通过 client ID 查找目标客户端
                const allClients = await self.clients.matchAll({
                    type: 'window',
                    includeUncontrolled: true
                });

                const targetClient = allClients.find(c => c.id === targetConn.id);

                if (targetClient) {
                    targetClient.postMessage({
                        type: 'private',
                        from: 'ServiceWorker',
                        sender: pageId,
                        data: `私发消息:${data}`
                    });
                } else {
                    console.log(`警告:客户端 ${target} 已断开`);
                    connections.delete(target);
                }
            } catch (e) {
                console.error('发送失败:', e);
                connections.delete(target);
            }
        } else {
            console.log(`警告:未找到目标页面 ${target}`);
        }
    }

    // 3. 处理心跳检测(用于清理断开的连接)
    if (type === 'heartbeat') {
        // 更新最后活跃时间
        if (connections.has(pageId)) {
            const conn = connections.get(pageId);
            conn.lastActive = Date.now();
            connections.set(pageId, conn);
        }
    }

    // 4. 处理断开连接
    if (type === 'disconnect') {
        connections.delete(pageId);
        console.log(`页面 ${pageId} 断开连接,当前在线:[${Array.from(connections.keys()).join(', ')}]`);
    }

    // 5. 获取在线列表
    if (type === 'get-online') {
        client.postMessage({
            type: 'online-list',
            from: 'ServiceWorker',
            data: Array.from(connections.keys())
        });
    }
});

// 定期清理断开的连接(每30秒检查一次)
setInterval(async () => {
    const allClients = await self.clients.matchAll({
        type: 'window',
        includeUncontrolled: true
    });

    const activeClientIds = new Set(allClients.map(c => c.id));

    // 清理已断开的连接
    for (const [pageId, conn] of connections.entries()) {
        if (!activeClientIds.has(conn.id)) {
            console.log(`清理断开的连接: ${pageId}`);
            connections.delete(pageId);
        }
    }
}, 30000);

3.2 步骤2:编写页面代码

pagesA页面支持ServiceWorker注册、广播消息、点对点通讯。

js 复制代码
<body>
    <h3>页面 A(标识:page-123)</h3>

    <div id="status" class="status inactive">Service Worker 未激活</div>

    <div>
        <input type="text" id="msgInput" placeholder="输入消息">
        <br>
        <button onclick="sendBroadcast()">广播消息</button>
        <button onclick="sendToPageB()">发给页面B</button>
        <button onclick="getOnlineList()">获取在线列表</button>
    </div>

    <div id="log"></div>

    <script>
        // 页面唯一标识
        const pageId = 'page-123';
        let serviceWorkerReady = false;

        // 日志输出函数
        function addLog(message) {
            const log = document.getElementById('log');
            const time = new Date().toLocaleTimeString();
            log.innerHTML += `<p>[${time}] ${message}</p>`;
            log.scrollTop = log.scrollHeight;
        }

        // 更新状态
        function updateStatus(active) {
            const statusEl = document.getElementById('status');
            if (active) {
                statusEl.textContent = 'Service Worker 已激活';
                statusEl.className = 'status active';
                serviceWorkerReady = true;
            } else {
                statusEl.textContent = 'Service Worker 未激活';
                statusEl.className = 'status inactive';
                serviceWorkerReady = false;
            }
        }

        // 注册 Service Worker
        async function registerServiceWorker() {
            if ('serviceWorker' in navigator) {
                try {
                    const registration = await navigator.serviceWorker.register('./service-worker.js');
                    console.log('Service Worker 注册成功:', registration);
                    addLog('Service Worker 注册成功');

                    // 等待 Service Worker 激活
                    await navigator.serviceWorker.ready;
                    updateStatus(true);
                    addLog('Service Worker 已激活');

                    // 发送注册消息
                    navigator.serviceWorker.controller.postMessage({
                        type: 'register',
                        pageId: pageId
                    });

                } catch (error) {
                    console.error('Service Worker 注册失败:', error);
                    addLog('Service Worker 注册失败: ' + error.message);
                }
            } else {
                addLog('浏览器不支持 Service Worker');
            }
        }

        // 监听来自 Service Worker 的消息
        navigator.serviceWorker.addEventListener('message', (event) => {
            console.log('页面A收到消息:', event.data);

            const { type, from, sender, data } = event.data;

            if (type === 'registered') {
                addLog(`✓ ${data}`);
            } else if (type === 'broadcast') {
                addLog(`📢 ${sender ? '来自 ' + sender + ': ' : ''}${data}`);
            } else if (type === 'private') {
                addLog(`📨 ${sender ? '来自 ' + sender + ': ' : ''}${data}`);
            } else if (type === 'online-list') {
                addLog(`👥 在线列表: [${data.join(', ')}]`);
            } else {
                addLog(`收到:${JSON.stringify(event.data)}`);
            }
        });

        // 发送广播消息
        function sendBroadcast() {
            if (!serviceWorkerReady) {
                addLog('⚠ Service Worker 未就绪');
                return;
            }

            const input = document.getElementById('msgInput');
            if (!input.value.trim()) {
                addLog('⚠ 请输入消息内容');
                return;
            }

            navigator.serviceWorker.controller.postMessage({
                type: 'broadcast',
                pageId: pageId,
                data: input.value
            });

            addLog(`📤 发送广播: ${input.value}`);
            input.value = '';
        }

        // 发送点对点消息给页面B
        function sendToPageB() {
            if (!serviceWorkerReady) {
                addLog('⚠ Service Worker 未就绪');
                return;
            }

            const input = document.getElementById('msgInput');
            if (!input.value.trim()) {
                addLog('⚠ 请输入消息内容');
                return;
            }

            navigator.serviceWorker.controller.postMessage({
                type: 'private',
                pageId: pageId,
                target: 'page-456',
                data: input.value
            });

            addLog(`📤 发送私信给 page-456: ${input.value}`);
            input.value = '';
        }

        // 获取在线列表
        function getOnlineList() {
            if (!serviceWorkerReady) {
                addLog('⚠ Service Worker 未就绪');
                return;
            }

            navigator.serviceWorker.controller.postMessage({
                type: 'get-online',
                pageId: pageId
            });
        }

        // 页面关闭时发送断开连接消息
        window.addEventListener('beforeunload', () => {
            if (serviceWorkerReady && navigator.serviceWorker.controller) {
                navigator.serviceWorker.controller.postMessage({
                    type: 'disconnect',
                    pageId: pageId
                });
            }
        });

        // 初始化
        registerServiceWorker();

        // 定期发送心跳(可选,用于检测连接状态)
        setInterval(() => {
            if (serviceWorkerReady && navigator.serviceWorker.controller) {
                navigator.serviceWorker.controller.postMessage({
                    type: 'heartbeat',
                    pageId: pageId
                });
            }
        }, 10000);
    </script>
</body>

3.3 步骤3:页面B

页面B与页面A结构相似,仅需修改页面标识和目标页面ID即可,核心适配点:

php 复制代码
// 1. 修改页面唯一标识为page-456
const pageId = 'page-456';

// 2. 调整发送私信按钮的目标页面ID
function sendToPageB() {
    // ...(逻辑与sendToPageA一致)
    navigator.serviceWorker.controller.postMessage({
        type: 'private',
        pageId: pageId,
        target: 'page-123', // 目标为页面B的标识
        data: content
    });
}

3.4 步骤4:运行与调试说明

  1. 环境准备:将sw.js和页面文件放在同一目录,通过HTTP服务启动(如live-server、http-server),本地开发可直接用localhost访问,避免file://协议问题。
  2. 调试工具 :在Chrome浏览器中直接访问地址:chrome://inspect/#workers;页面会列出当前浏览器中所有运行的Worker实例,找到目标ServiceWorker对应的"inspect"链接并点击,即可打开专属控制台。

3. 功能验证:同时打开页面A和页面B,点击"广播消息"可看到双方均收到;页面A发送"发给页面B"则仅页面B收到消息,实现点对点通讯。

4. ServiceWorker注意事项

4.1 协议与作用域限制(最常见坑)

ServiceWorker仅支持HTTPS协议(localhost和127.0.0.1为开发例外),若在HTTP环境下使用会直接报错。同时,其"作用域(scope)"决定了可控制的页面范围:

  • 默认scope为注册脚本所在目录,例如在/js/sw.js注册,默认仅控制/js/目录下的页面。
  • 若需控制整个网站,需将sw.js放在根目录,或注册时指定scope: '/',且服务器需配置Service-Worker-Allowed: /响应头。

4.2 脚本更新机制复杂,易导致通讯异常

ServiceWorker注册后会缓存脚本,若修改sw.js后直接刷新页面,新脚本不会立即生效,需通过以下方式触发更新:

  • 页面中调用registration.update()主动检查更新。
  • 修改sw.js的文件内容(哪怕是注释),浏览器会检测到文件哈希变化,触发install事件。
  • 更新后需通过self.skipWaiting()clients.claim()让新脚本立即接管所有页面,否则需关闭所有页面后重新打开才生效。

4.3 消息数据序列化限制

通过postMessage()传递的消息数据需支持"结构化克隆算法",无法传递函数、DOM元素、Blob等复杂类型。解决方案:

  • 简单数据:直接传递对象或数组。
  • 复杂数据:将Blob转为ArrayBuffer,将函数通过JSON.stringify序列化(需确保无循环引用)。

4.4 客户端管理需处理异常场景

ServiceWorker通过clients.matchAll()获取页面客户端时,需注意:

  • 部分页面可能处于"冻结状态"(如后台标签页),需通过client.focus()激活后再发送消息。
  • 客户端实例可能失效,发送消息前需通过client.urlclient.id验证有效性,避免报错。

4.5 兼容性与降级处理

ServiceWorker在IE浏览器中完全不支持,Safari在iOS 11.3以上才支持。实际项目中需做好降级:

javascript 复制代码
// 降级处理示例:不支持时使用localStorage+storage事件替代
if (!navigator.serviceWorker) {
  log('浏览器不支持ServiceWorker,启用localStorage降级方案');
  // 监听localStorage变化实现跨页面通讯
  window.addEventListener('storage', (e) => {
    if (e.key === 'userLoginState') {
      const state = JSON.parse(e.newValue);
      updateLoginStatus(state);
    }
  });
}

5. 总结:ServiceWorker 通讯的最佳实践

最后总结一下:ServiceWorker是前端在需要离线支持、跨会话同步及后台协同场景下的最优通讯方案,使用时需根据自己的适用场景使用。

如有错误,请指正O^O!

相关推荐
1024肥宅2 小时前
JavaScript 性能与优化:数据结构和算法
前端·数据结构·算法
沐风。562 小时前
TypeScript
前端·javascript·typescript
用户47949283569152 小时前
XSS、CSRF、CSP、HttpOnly 全扫盲:前端安全不只是后端的事
前端·后端·面试
O***p6042 小时前
当“前端虚拟化”成为可能:构建下一代 Web 应用的新范式
前端
孤酒独酌2 小时前
一次断网重连引发的「模块加载缓存」攻坚战
前端
jinzeming9992 小时前
Vue3 PDF 预览组件设计与实现分析
前端
编程小Y2 小时前
Vue 3 + Vite
前端·javascript·vue.js
GDAL3 小时前
前端保存用户登录信息 深入全面讲解
前端·状态模式
大菜菜3 小时前
Molecule Framework -EditorService API 详细文档
前端