1
0

给前端 AI 任务做多标签同步时,我会先把广播和单写锁分开

最近做一个带 AI 摘要和知识库刷新面板的前端应用时,我又碰到一个很容易被低估的问题:用户会同时开两个甚至三个标签页。一个标签页点了“重新分析”,另一个标签页还停在旧状态;一个请求已经跑到一半,第二个标签页又发起同样任务;后台页再切回前台时,进度像被倒放了一次。AI 任务本身已经够慢,如果前端状态再互相打架,用户会很难判断到底是模型慢、网络慢,还是页面坏了。

多标签页通过广播频道、单写锁和快照仓库协作执行 AI 任务的流程图

原创示意图:用户操作进入单写锁,持锁标签页执行 AI 任务并广播进度,其他标签页通过快照和事件流跟随状态。 来源:Codex image generation

问题背景

这类问题可以全放到后端 job 里处理,但轻量 AI 工具里有不少任务只发生在同源 Web 页面内。MDN 对 BroadcastChannel 的说明很贴近这个场景:同源窗口、标签页、iframe 和 worker 可以通过同名频道做基础通信。它只负责传消息,业务仍然要自己约定事件类型、版本号和去重规则。

另一个关键点是单写。Web Locks API 允许同源标签页或 worker 异步获取锁,持有锁时其他上下文不能拿到同名锁。W3C 规范也说明 exclusive 模式下同一资源只能被一个标签页或 worker 持有。对 AI 任务来说,这可以避免多个标签页重复消耗接口额度、重复写缓存、重复改 IndexedDB。

关键难点

第一个坑是把广播当成任务队列。BroadcastChannel 适合通知“发生了什么”,但它没有持久化,新打开的标签页收不到历史消息。第二个坑是把锁当成状态源。锁只能说明谁现在有资格写,不能说明任务进度、最后错误、产物位置。第三个坑是后台标签页的节流和可见性变化。Page Visibility API 能监听页面 visible 或 hidden,我用它来决定界面何时补读快照,避免后台页把过期 UI 刷回前台。

解决思路

我把链路拆成三层。第一层是事件频道,只广播 JOB_STARTEDJOB_PROGRESSJOB_DONEJOB_FAILED 这类事实,并带上 jobIdversioncreatedAtsourceTabId。第二层是单写锁,只有拿到锁的标签页能执行 AI 请求和写入快照。第三层是持久快照,保存任务最后状态、进度、错误摘要和结果索引,新标签页打开后先读快照,再订阅广播。

await navigator.locks.request("ai-job:refresh", { ifAvailable: true }, async lock => {
  if (!lock) {
    channel.postMessage({ type: "FOLLOW", jobId, version: 1 });
    return;
  }

  await runJobAndBroadcast(jobId);
});

这里的 ifAvailable 很实用。拿不到锁的标签页不排队执行同一任务,只进入跟随模式,先渲染快照,再等持锁标签页广播进度。如果持锁标签页崩掉,下一次用户操作或恢复流程再尝试拿锁,并用快照判断是否续跑。

关键步骤

落地时我先做消息白名单,所有广播事件都走 Zod 或手写类型守卫,未知版本直接忽略并记录调试日志。然后给每个标签页生成 sourceTabId,收到自己发出的消息时跳过渲染,避免一次状态变化触发两次 UI 更新。快照写成追加式结构,至少保留最后一次完成态和最后一次失败态,排障时可以定位失败发生在获取输入、调用模型、写缓存还是渲染结果。

可见性处理放在最后加。页面切到 hidden 时,只接收关键事件并降低 UI 刷新频率;页面回到 visible 时,先读一次快照,再处理后续广播。这样用户回到页面时看到的是最终事实,不会被旧进度覆盖。

可复用经验

多标签同步不要一开始就追求复杂总线。先把“通知”“互斥”“事实快照”分开,三个边界清楚后,代码会稳定很多。BroadcastChannel 解决同源页面之间的轻量通知,Web Locks 解决单写执行,快照解决刷新、崩溃和迟到订阅。以后接 AI 摘要、批量标注、知识库刷新、长表单自动补全这类前端任务,都可以沿用这套小协议。

主要来源

MDN Broadcast Channel API

MDN Web Locks API

W3C Web Locks API

MDN Page Visibility API

评论