WEBKT

不用BroadcastChannel,如何用Service Worker实现跨窗口状态同步

2 0 0 0

在多标签页(Tab)或多窗口的 Web 应用中,保持各窗口间的状态同步是一个经典的架构问题。例如:用户在 A 窗口切换了夜间模式,B 窗口需要实时响应;或者在 A 窗口将商品加入了购物车,B 窗口的导航栏红点需要立刻更新。

通常,大家首先会想到 BroadcastChannelLocalStoragestorage 事件,或者 SharedWorker。但是,如果你的应用已经注册了 Service Worker,或者你希望在页面完全关闭后依然保留某种后台同步和离线缓存能力,那么利用 Service Worker 作为“中央调度中心”是一套极具扩展性的架构方案。

下面我们不依赖 BroadcastChannel,通过 Service Worker 的 Clients APIpostMessage 以及 MessageChannel,构建一套高可用的跨窗口状态同步方案。


为什么选择 Service Worker?

相比于其他的跨窗口通信方案,Service Worker 拥有独特的优势:

  1. 天然的中心化节点:它独立于所有的物理窗口,充当着类似“本地代理服务器”的角色。
  2. 生命周期独立:即使所有物理窗口关闭,Service Worker 仍可能在后台存活一段时间,适合处理未完成的后台任务。
  3. 离线处理与持久化:可以完美配合 IndexedDB 或 Cache API,将同步的状态进行持久化,保证新开标签页能够瞬间拿到“最新状态”,而不需要等待其他窗口响应。

架构设计

整套同步机制由三部分组成:

  1. 客户端(Page):状态的使用者和发起者。
  2. 中心网关(Service Worker):负责接收某个窗口的状态变更,并将其“广播”给其他所有同源窗口。
  3. 单播通道(MessageChannel):用于新开窗口时,向 Service Worker 索要当前最新的初始化状态。
  +------------+             (1) Update State             +----------------+
  |  Window A  | ---------------------------------------> |                |
  +------------+                                          |                |
                                                          | Service Worker |
  +------------+   (3) Broadcast Update (Exclude Sender)   |                |
  |  Window B  | <--------------------------------------- |                |
  +------------+                                          +----------------+
                                                                  |
  +------------+            (2) Request Init State                | (Persist)
  |  New Tab   | <================================================+
  +------------+          (Via MessageChannel Port)               |
                                                            [ IndexedDB ]

核心实现

1. 编写 Service Worker 逻辑 (sw.js)

在 Service Worker 中,我们需要处理两件事:

  • 监听来自各窗口的状态更新消息,更新内存/缓存,并广播给其他活跃窗口。
  • 接收新窗口的初始化请求,通过 MessageChannel 单播回传当前最新状态。
// sw.js

// 内存中临时持有的状态(注意:SW 可能会休眠,生产环境需结合 IndexedDB)
let globalState = {
  theme: 'light',
  cartCount: 0,
  lastUpdated: Date.now()
};

self.addEventListener('install', (event) => {
  // 强制跳过等待,激活新的 SW
  self.skipWaiting();
});

self.addEventListener('activate', (event) => {
  // 激活后立即接管所有受控客户端
  event.waitUntil(self.clients.claim());
});

// 监听客户端发送的消息
self.addEventListener('message', async (event) => {
  const { type, payload } = event.data || {};
  const senderId = event.source.id; // 发送消息的窗口 Client ID

  switch (type) {
    case 'STATE_UPDATE':
      // 1. 更新 SW 侧的状态
      globalState = {
        ...globalState,
        ...payload,
        lastUpdated: Date.now()
      };
      
      // 2. 异步将状态持久化到数据库(如 IndexedDB),防止 SW 线程休眠丢失数据
      await saveStateToStorage(globalState);

      // 3. 广播给其他所有窗口(排除发送者本身)
      await broadcastToClients(
        { type: 'STATE_CHANGED', payload: globalState },
        senderId
      );
      break;

    case 'GET_INITIAL_STATE':
      // 针对新开窗口,使用单播通道(MessageChannel)返回当前状态
      if (event.ports && event.ports[0]) {
        const persistedState = await getStateFromStorage();
        event.ports[0].postMessage({
          type: 'INITIAL_STATE_RESPONSE',
          payload: persistedState || globalState
        });
      }
      break;

    default:
      break;
  }
});

/**
 * 广播消息给所有活跃的受控窗口
 * @param {Object} message 消息体
 * @param {string} excludeClientId 需要排除的窗口 ID
 */
async function broadcastToClients(message, excludeClientId) {
  // 获取所有类型为 window 的客户端
  const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
  
  clients.forEach((client) => {
    // 排除发送消息的源窗口,避免循环触发
    if (client.id !== excludeClientId) {
      client.postMessage(message);
    }
  });
}

// 模拟简易的持久化存储,实际项目中建议使用 idb 库操作 IndexedDB
async function saveStateToStorage(state) {
  try {
    const cache = await caches.open('app-state-v1');
    await cache.put('/current-state', new Response(JSON.stringify(state)));
  } catch (err) {
    console.error('状态持久化失败', err);
  }
}

async function getStateFromStorage() {
  try {
    const cache = await caches.open('app-state-v1');
    const response = await cache.match('/current-state');
    if (response) {
      return await response.json();
    }
  } catch (err) {
    console.error('读取持久化状态失败', err);
  }
  return null;
}

2. 编写客户端调用逻辑 (app.js)

在页面的主 JS 逻辑中,我们需要:

  • 注册 Service Worker。
  • 页面加载时,利用 MessageChannel 向 Service Worker 主动索要最新状态,完成初始化渲染。
  • 监听来自 Service Worker 的 message 事件,当其他窗口改变状态时,本窗口自动更新。
  • 当本窗口发生交互(如用户点击切换主题)时,向 Service Worker 派发更新。
// app.js

// 注册 Service Worker
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js');
      console.log('Service Worker 注册成功:', registration.scope);
      
      // 确保 Service Worker 处于激活并控制页面的状态
      if (navigator.serviceWorker.controller) {
        initApp();
      } else {
        navigator.serviceWorker.oncontrollerchange = () => {
          initApp();
        };
      }
    } catch (error) {
      console.error('Service Worker 注册失败:', error);
    }
  });
}

function initApp() {
  // 1. 获取最新初始状态
  fetchInitialState().then((state) => {
    console.log('获取到初始状态并渲染 UI:', state);
    renderUI(state);
  });

  // 2. 监听来自 Service Worker 的广播
  navigator.serviceWorker.addEventListener('message', (event) => {
    const { type, payload } = event.data || {};
    if (type === 'STATE_CHANGED') {
      console.log('收到来自其他窗口的状态更新通知:', payload);
      renderUI(payload);
    }
  });
}

/**
 * 利用 MessageChannel 实现 Request-Response 模式,向 SW 索要数据
 */
function fetchInitialState() {
  return new Promise((resolve) => {
    if (!navigator.serviceWorker.controller) {
      resolve(null);
      return;
    }

    const messageChannel = new MessageChannel();
    
    // 监听返回通道
    messageChannel.port1.onmessage = (event) => {
      if (event.data && event.data.type === 'INITIAL_STATE_RESPONSE') {
        resolve(event.data.payload);
      }
    };

    // 向 SW 发送请求,并将 port2 传递过去作为回传通道
    navigator.serviceWorker.controller.postMessage(
      { type: 'GET_INITIAL_STATE' },
      [messageChannel.port2]
    );
  });
}

/**
 * 触发状态变更并通知其他窗口
 */
function dispatchStateUpdate(partialState) {
  if (navigator.serviceWorker.controller) {
    navigator.serviceWorker.controller.postMessage({
      type: 'STATE_UPDATE',
      payload: partialState
    });
  } else {
    console.warn('当前页面未受 Service Worker 控制,无法同步状态');
  }
}

// --- 以下为 UI 渲染与交互绑定演示 ---
function renderUI(state) {
  if (!state) return;
  
  // 更新页面背景色
  document.body.className = state.theme;
  
  // 更新购物车数量
  const badge = document.getElementById('cart-badge');
  if (badge) {
    badge.textContent = state.cartCount;
  }
}

// 模拟用户交互:增加购物车
document.getElementById('add-to-cart-btn')?.addEventListener('click', () => {
  // 这里可以先获取当前状态再进行修改,此处简写模拟自增
  const currentCount = parseInt(document.getElementById('cart-badge')?.textContent || '0', 10);
  const nextCount = currentCount + 1;
  
  // 本地先渲染,提升响应体验
  renderUI({ theme: document.body.className, cartCount: nextCount });
  
  // 通知 Service Worker 去同步其他窗口
  dispatchStateUpdate({ cartCount: nextCount });
});

// 模拟用户交互:切换主题
document.getElementById('toggle-theme-btn')?.addEventListener('click', () => {
  const currentTheme = document.body.className;
  const nextTheme = currentTheme === 'dark' ? 'light' : 'dark';
  
  renderUI({ theme: nextTheme, cartCount: parseInt(document.getElementById('cart-badge')?.textContent || '0', 10) });
  
  dispatchStateUpdate({ theme: nextTheme });
});

避坑指南与关键细节

在实际工程中落地这套方案时,还需要注意以下细节:

1. 为什么不用 Service Worker 内存变量做“唯一数据源”?

我们在 sw.js 中定义了 let globalState。在短时间内,这个变量是可用的。但 Service Worker 在没有事件处理时会被浏览器强制终止(休眠)。当下一个事件唤醒它时,内存变量会被重置。

  • 解决方案:必须在 Service Worker 中使用 Cache API 或者 IndexedDB 读写状态,如上文代码中的 saveStateToStorage 方法,以此保证状态的物理持久化。

2. 应对 Service Worker 的“未激活”状态

当用户首次打开页面时,Service Worker 还在安装和激活中,navigator.serviceWorker.controllernull。此时如果发生交互,消息是发不出去的。

  • 解决方案:在注册时,通过监听 oncontrollerchange 确保在页面正式被 SW 控制后再初始化应用,或者在 SW 的 activate 事件中调用 self.clients.claim() 强行立刻接管客户端。

3. 如何解决网络延迟或并发更新的竞态问题?

如果两个窗口几乎同时修改了状态(例如同时快速点击增加购物车),可能会发生覆盖。

  • 解决方案:给状态对象加上 version(自增版本号)或 lastUpdated(高精度时间戳)。Service Worker 接收更新时,比对时间戳或版本号,只有“更新”的数据才允许覆盖,并向外广播。

总结

利用 Service Worker 解决跨窗口同步,核心在于利用其**作为同源下唯一“常驻进程”**的特性。配合 clients.matchAll() 筛选活跃窗口并精准定向 postMessage,我们不仅摆脱了对 BroadcastChannel 的依赖,更能够利用离线缓存特性,极大优化首屏加载时状态的获取体验。这一套方案也是 PWA(渐进式 Web 应用)架构中处理状态一致性的核心解法。

技术架构探路者 前端状态管理跨窗口通信

评论点评