竞态条件本质是多个异步操作因执行时机不确定而争抢修改同一共享状态,结果依赖“谁后写入”而非业务逻辑;多窗口场景因各窗口独立上下文却共用存储、服务端状态及UI更新逻辑,显著放大该风险。
竞态条件在异步编程中,本质是多个异步操作因执行时机不确定,争抢修改同一份共享状态,最终结果依赖“谁后写入”,而非业务逻辑本意。在多窗口场景下(比如浏览器多个标签页、Electron 多窗口、或 PWA 多实例),这个问题尤为突出——每个窗口都可能独立发起请求、更新本地缓存、刷新 UI,若缺乏协调,极易出现状态错乱:例如用户在窗口 A 提交订单后跳转,在窗口 B 仍显示旧库存;或两个窗口同时编辑同一份草稿,后保存者覆盖前者的修改。
为什么多窗口会放大竞态风险
多窗口天然打破单实例约束,各窗口拥有独立 JS 执行上下文、内存空间和事件循环,但又常共用以下资源:
共享存储
:localStorage、IndexedDB、甚至同一 WebSocket 连接或 Service Worker 控制的缓存;
共享服务端状态
:如用户购物车、文档版本、实时协作光标位置;
共享 UI 反映逻辑
:比如多个窗口监听同一 broadcastChannel 消息并触发本地状态更新,但未校验消息时效性或来源一致性。
基于 AbortController 的请求级竞态控制
适用于“用户快速连续触发相同类型请求”(如搜索框防抖失效、多次点击刷新按钮)导致的多窗口内请求重叠问题。核心思路是:新请求启动时,主动中止前一个未完成的请求。
每个窗口维护独立的
实例,不跨窗口共享;
在发起 fetch 前调用
清理上一轮控制器,再新建一个;
注意:仅对支持
参数的 API(如 fetch、stream)有效,Promise 链需自行封装取消逻辑。
利用 BroadcastChannel + 版本号协调多窗口状态
当多个窗口需同步同一份数据(如用户登录态、主题设置、草稿内容),可结合客户端版本管理和广播通知:
为每份共享状态维护一个单调递增的版本号(如 Date.now() 或自增 counter),存于 localStorage;
任一窗口修改状态时,先更新本地值 + 版本号,再通过
发送含新版本号的消息;
其他窗口监听到消息后,比对自身版本号:仅当收到版本 > 本地版本时,才拉取最新数据并更新 UI;
避免“旧消息覆盖新状态”——这是多窗口竞态最典型的错误模式。
服务端协同:乐观更新 + 冲突检测
对关键操作(如提交表单、更新文档),不能只靠前端规避。应设计带服务端校验的闭环:
前端提交时附带当前已知的资源版本(ETag、lastModified、或自定义 revision 字段);
服务端收到后检查该版本是否仍有效,若已被他人更新,则返回 409 Conflict;
前端捕获 409,自动拉取最新版本,合并变更(或提示用户手动解决冲突);
此机制将竞态判断从“不可控的执行顺序”转移到“明确的版本比较”,更可靠。
不复杂但容易忽略:多窗口竞态不是单纯的技术选型问题,而是状态归属权的问题。优先问一句——这个状态,到底该由谁来决定?是某个窗口(主窗口)、服务端、还是共识机制(如 CRDT)?答案不同,方案就完全不同。
AbortControllercontroller.abort()signalBroadcastChannel