实现多窗口同步的副作用归一化实践

2025/09/19 WebDevelopment JavaScript 共 8419 字,约 25 分钟

多窗口同步方案

概念介绍:从纯函数思想到副作用归一化

将窗口抽象想象为函数调用:如果能让所有窗口都表现为”纯函数”,就能解决同步问题

// 理想状态:窗口 = 纯函数
Window(userActions) => UIState

// 纯函数特性:相同输入 → 相同输出
Window(actions) === Window(actions) // 总是为 true

如果窗口表现为纯函数,用户输入行为对应存函数入参,传入相同入参执行后可以得到完全一致的页面输出,从而实现多个窗口效果同步。

然后,事实是副作用无处不在:

  • 网络请求:HTTP调用,API调用
  • 埋点曝光:行为统计,数据上报
  • 随机数状态:UUID生成,时间戳获取等
  • 存储操作

副作用产生的不确定性,导致相同入参调用还是会产生不同结果。 举例来说🌰:

const [uuid, setUuid] = useState();
<div onClick={handleTest}>button--{uuid}</div>

function handleTest() {
   const newUuid = getUuid();
   console.log(newUuid);
   setUuid(newUuid)
}

哪怕俩个窗口都触发调用 handleTest 方法,界面展示会因为 Uuid 的随机性出现差异化。

副作用归一化

什么是副作用归一化?

副作用归一化 = 让”不确定操作”变成”确定操作” 简单来说:由一个窗口先执行完整流程期间自动收集对应副作用结果,其他窗口执行相同操作副作用计算从记录中获取副作用结果,而不重新计算结果。

Leader窗口(发起):用户操作 → (执行副作用 → 收集结果 → 缓存)[劫持] → 同步
Other窗口(同步):用户操作 → (劫持副作用 → 从记录获取)[劫持] → 使用结果

副作用归一化难点:

  • 保障同一个事件在多个窗口调用一致的上下文标识
  • 怎么保障副作用存储顺序,以及缓存恢复顺序
  • 异步及异步嵌套场景,怎么保障存储顺序
  • 异步上下文丢失错误场景,怎么主动恢复
  • 怎么确保一定有可以消费的副作用缓存记录
  • 内存管理与清理

实现方案

上下文一致性 ContextId

方式一:利用 Babel,SWC 这类编译工具,在编译阶段为每个用户交互操作自动注入唯一标识符,这个过程对开发者完全透明(开发人员不可见)

事件劫持 + 上下文标识自动绑定:

// 编写的原始代码
<div onClick={handleClick}>

// 自动转换后的代码(开发者不可见)
<div onClick={syncEvent(handleClick, 'sync-唯一id')} syncKind={'sync-唯一id'}>
通过编译时注入的唯一标识符,确保多个窗口在执行相同用户操作时拥有一致的执行上下文
// 所有窗口中相同的交互元素都拥有相同的syncKind
TabA: <button syncKind="sync-uuid-002" />
TabB: <button syncKind="sync-uuid-002" />  
TabC: <button syncKind="sync-uuid-002" />

// 上下文的一致性标识

方式二:动态生成事件上下文标识,将事件与标识捆绑同步给其他窗口, 也能保障事件执行上下文的一致性。 简单对比: 动态生成对应副作用不方便细颗粒管理

  1. 没法感知事件执行次数
  2. 没法回放调试
  3. 调用链路不方便扩展

根据静态特征,很方便实现插件机制举例🌰:

const sendMessageKind = 'Component_sync_demo'

const Component = () => <div sync-bind={sendMessageKind}>xxxx</div>
const syncPlugins = {
   [sendMessageKind]: {
      // 用户行为执行前 () => void;
      // 消息同步前 () => void;
      // 消息接受前 () => void;
      // 消息接受方执行完毕 () => void;
      // ....
   }
}

上例 sync-bind 可覆盖 Babel 自动生成的唯一值。 静态方便扩展,方便提供不同阶段钩子,同时有利后续链路回放调试。

用户行为同步
发送方
function syncEvent(eventHandler, syncContextId) {
   return (...args) => {
      // 标识当前操作上下文 -> syncContextId

      // 事件执行
      eventHandler(...args)

      // 窗口同步
      syncMesssageInfo(
         syncContextId, // 操作上下文
         effectRecords, // 事件调用链同步上下文副作用记录
         eventType,     // 事件类型
         ...more
      )
   }
}

发送方执行流程


用户操作 → 事件劫持 → 上下文声明 → 原始事件执行 → 副作用收集 → 窗口同步
         ←————————————— 插件钩子覆盖整个生命周期 —————————————→
接受方
function receiveMessage({ payload }) {
   const {
      syncContextId,
      effectRecords,
      eventType
   } = payload;

   // 副作用恢复

   const eventDom = document.querySelector(`sync-${syncContextId}`)
   eventDom[eventType]?.()
}

接收方执行流程

消息接收 → 副作用上下文恢复 → 目标元素定位 → 原生事件模拟 → 事件劫持处理

副作用收集 ️

方案思考

以下面函数举例🌰:

const handleTest = () => {
   const u1 = getUuid(); // <- 随机性,需要记录
   const t1 = Date.now();// <- 瞬态,需要记录
   const newU1 = `sync_${u1}_last`; // u1一致newU1一致,自动计算
   const u2 = getUuid(); // <- 随机性,需要记录
}
按类型分组存储(初始方案)

最初尝试为不同类型的副作用分配独立队列:

{
   uuid: [u1, u2],
   date: [t1]
}

考虑到异步场景,扩展为同步/异步分离结构:

{
   async: {
      uuid: [u1, u2],
      date: [t1]
   },
   sync: {
      uuid: [],
      date: []
   }
}
异步顺序问题

然而,异步不同于同步这般简单可控,由事件循环调度上下文切换;无法保障顺序一致性:

setTimeout(() => {
   console.log('log1')
}, 2)
setTimeout(() => {
   console.log('log2')
}, 2)

这种不确定性在复杂的异步嵌套场景下更加明显,上述的分类存储方案无法解决顺序问题。

插槽机制

为了解决异步顺序问题,这里引入”插槽”概念,通过“静态分析”确保执行顺序的一致性。

插槽类型定义

  • 同步插槽:立即执行并赋值
  • 异步插槽:等待事件循环调度,但插槽位置预先确定
  • 上下文继承:异步回调执行时,会继承父级插槽上下文,形成嵌套的插槽序列

这样关注面不再是同步/异步的区别,而是调用顺序的确定性。 示例分析🌰:

const handleTest = () => {
   const u1 = getUuid();   // 0  <-自增标识
   const t1 = Date.now();  // 1
   setTimeout(() => {      // 2
      getUuid()            // 2-0
      getUuid()            // 2-1
   })
   setTimeout(() => {      // 3
      getUuid()            // 3-0
   }, 1000)
   const newU1 = `sync_${u1}_last`;
   const u2 = getUuid(); 	// 4
}

插槽上下文结构

// eventContext
eventContext = [
   slot(u1 = getUuid()),
   slot(t1 = Date.now()),
   slot(syncContext_2), // 异步上下文
   slot(syncContext_3), // 异步上下文
   slot(u2 = getUuid())
]


// 异步上下文syncContext2
syncContext2 = [
   slot(getUuid())// 2-0, <-自增标识 延续父插槽标识
   slot(getUuid())// 2-1,
]

// 异步上下文syncContext3
syncContext3 = [
   slot(getUuid())// 3-0,
]

每个插槽生成自增标识,并延续父插槽标识,形成层次化的标识体系。

副作用同步机制

分阶段同步
  • 主上下文完成:事件执行完毕,同步上下文中“同步插槽副作用”。
  • 异步上下文完成:当每个异步上下文执行完毕,自动同步当前上下文副作用。

以上文“异步上下文 syncContext2 ”同步举例🌰:

// syncContext2 的父级插槽为
setTimeout(() => {      // slotKind => 2
   getUuid()            // slotKind => 2-0
   getUuid()            // slotKind => 2-1
})

上下文副作用提取

利用插槽的自增标识特性(延续父插槽标识)。通过简单标识前置位匹配,可以很方便将当前上下文副作用提取出来:

Array.from(EffectStore)
.filter(([key]) => key.starsWith(`slotKind => 2`))
// [[2-0, xx], [2-1, xx]]

存在同步副作用,会自动推送到其他窗口。

接收方副作用恢复

接收方事件调用时,同样会收集插槽,每个插槽的副作用结果从记录中根据相同插槽标识获取恢复:

const handleTest = () => {
   const u1 = getUuid();   // <劫持  根据 slotKind_0 获取记录
   const t1 = Date.now();  // <劫持  根据 slotKind_1 获取记录
   setTimeout(() => {      // <劫持  根据 slotKind_2 获取记录
      getUuid()            // <劫持  根据 slotKind_2_0 获取记录
      getUuid()            // <劫持  根据 slotKind_2_1 获取记录
   })
   setTimeout(() => {      // <劫持  根据 slotKind_3 获取记录
      getUuid()            // <劫持  根据 slotKind_3_0 获取记录
   }, 1000)
   const newU1 = `sync_${u1}_last`;
   const u2 = getUuid(); 	// <劫持  根据 slotKind_4 获取记录
}

这样就可以得到一致的结果

发送方handleTest() === 同步方A_handleTest() === 同步方B_handleTest()
await 分段推送

await 上下文切割问题

根据上文设计,同步代码会在首次执行完毕后立即同步,异步操作会在完成时自动触发同步。但 await 关键字会切割执行链路,导致 await 后续的同步操作无法被正确同步。

举个例子🌰:

// 插槽序列分析
[
   slot(sync_1),    // 同步操作
   slot(await_2),   // 异步等待点
   slot(sync_3),    // await 后的同步操作
   slot(sync_4),    // await 后的同步操作  
   slot(await_5),   // 下一个异步等待点
]

在上述序列中,sync_3sync_4 由于位于 await_2 之后,会在异步恢复时执行,但此时同步上下文已经丢失,导致这些操作无法被正确同步到其他窗口。

解决方案:分段推送策略

await 为分割点,将执行链路划分为多个同步段: 切分时机

  • 首次执行阶段:仅同步 sync_1
  • 延时同步阶段:每个 await 操作完成时,不立即同步结果,而是继续执行后续同步操作,直到遇到下一个 await 或执行结束,然后将整个段的结果一次性同步

分段示例:

// 段1:立即同步
[slot(sync_1)] → 立即推送

// 段2:await_2 完成后同步
[slot(await_2), slot(sync_3), slot(sync_4)] → await_2 完成后推送

// 段3:await_5 完成后同步  
[slot(await_5)] → await_5 完成后推送

这种避免了 await 切割导致的上下文丢失问题。

Promise 消费方式识别

在插槽设计中,我们只有 同步插槽异步插槽异步Promise插槽 的概念。关键问题是: 如何区分 Promise 是通过同步的 .then() 消费,还是通过阻塞的 await 消费?

识别方案

每个执行上下文完成时,必然能够获取到所有同步操作的结果。利用这个特性来判断 Promise 的消费方式:

  • await Promise 插槽获得结果后,会记录并继续执行后续代码
  • 如果某个同步插槽在上下文结束时仍无结果,说明它依赖于 Promiseawait 结果(处于 await 下方)
  • 将这些未完成的同步插槽与对应的 Promise 插槽归为一组

分段推送逻辑 当遇到下一个 Promise 插槽时:

  • .then() 场景:忽略,继续收集后续插槽直到上下文结束。
  • await 场景:识别为当前上下文的最后一个 Promise 插槽,立即同步当前批次的所有记录

副作用绑定理念 其实插槽绑定理念与经常使用的 React Hooks 底层绑定非常相似

  • 纯函数不能绑定副作用,hooks 链挂载组件 fiber 单元上
  • 函数组件初次渲染,构建 hooks 副作用记录 (类似多窗口发送方, 记录副作用)
  • 函数组件后续渲染,查询 hooks 副作用记录 (类似多窗口被同步方, 从记录恢复副作用)

Demo 版本:https://github.com/ScriptOverture/EffectSync

副作用保障上方案: 1. 因为该方案,一个事件执行会有多段同步,首次执行几乎不会因窗口刷新阻断(函数调用非常非常快) a. 存在 cpu 密集型阻塞操作另外优化 2. 如果首次同步后,异步计算中窗口关闭了,某一个leader窗口获取主动权,更新剩下异步上下文标识(需要调用/被同步),使其调用记录副作用并推动其他窗口 因为每个窗口都是独立的FN, 这个特性导致任何窗口任意时间段都可以被选中为主窗口,甚至可以模糊掉传统leader窗口的概念,也可以是当前窗口执行其他窗口记录。

内存管理与清理

问题分析

当前方案存在以下内存管理问题:

  • 单次执行假设:方案设计基于不同事件只执行一次的假设
  • 持久化积累:副作用记录在收集后持续持久化,缺乏释放机制
  • 存储边界模糊:同一事件多次执行、不同事件调用之间没有明确的副作用存储划分

基于上述问题,采用按执行实例分组的存储结构:

{
   contextId: {
   effects: [
      [事件1插槽...],  // 第1次执行的插槽记录
      [事件2插槽...]   // 第2次执行的插槽记录
   ],
   status: 'pending|completed|failed',
   timestamp: Date.now(),
   hasAsync: boolean
   }
}

当用户点击2次相同事件时,会创建类似上述结构,每次执行独立记录,便于精确管理和清理。

清理策略

基础清理规则

  • 同步事件:执行完毕立即销毁上下文和相关记录
  • 异步事件:根据异步操作完成情况进行分段销毁,或等待所有异步操作成功后统一销毁
异常情况处理

考虑到可能存在永不回调的异步操作,在开发阶段需要设置最大生命周期监控:

  • 若副作用记录超过最大周期仍未完成,触发异常监控告警
  • 强制销毁超时的上下文,防止内存泄漏

容错与恢复机制(个别极端)

同步失败的自我纠正

在多窗口同步场景中,可能出现极端情况: TabA 发送通讯到其他窗口,个别窗口因线程阻塞等极端情况导致同步通知丢失,需要感知异常并主动向最新版本的 Leader 窗口发送恢复请求。

延迟销毁策略

为支持容错恢复,副作用插槽的销毁需要采用延迟销毁或缓存恢复机制:

  • 延迟销毁:在正常清理时间基础上延长一定周期,为异常恢复预留时间窗口
  • 缓存恢复:将关键副作用记录转移到持久化缓存中,支持异常情况下的状态恢复

闭包引用分析

需要同步的副作用都会被劫持一次,每个劫持函数本质上都是一个上下文闭包

  • 同步场景:执行完毕后闭包释放,引用自动断开
  • 异步场景:闭包持续存在,但所有闭包引用同一个全局实例,内存开销可控
  • 优化效果:全局实例共享机制有效减少了内存占用

逃生舱

方案无法全方面兜底覆盖所有场景,并非银弹

  • 第三方库副作用:不可预知、封装黑盒(如 Antd 动画、lodash random),只能人工接管。
  • ref+原生事件:如 DOM 原生事件、直接操作 ref,无法劫持事件与副作用。
  • 动画:浏览器/库实现,帧同步、异步、物理引擎,副作用不可控。
  • 密集型阻塞操作:长耗时同步、Web Worker,大概率超出 DOM/JS 上下文跟踪能力。
  • 框架层调度渲染:调度系统(如 React、Vue 的调度模式、批处理)和组件更新优先级,触发结果和真实用户操作链路未必一一对应。

针对上述情况,仍然可以降级为手动同步。插件也支持不同阶段扩展同步参数。 同时插件机制 + 操作记录会相对方便链路回放跟踪问题。

举例案例分析 —— 框架层调度渲染

以 React 为例,考虑如下代码:

function Card() {
   const [x, update] = useState(0)
   useEffect(() => {
      getUuid()// <===不确定副作用
   }, [x])
   return <div onClick={() => setTimeout(() => update(x => x + 1))}>click</div>
}
  • 点击触发 setTimeout,延迟更新 x
  • React 的事件优先级调度、批处理; 状态更新可能被合并、延后。
  • 如果此时另一个高优先级任务先占用调度队列,导致 Cardx 更新触发变成“和其他任务合并”。

以上无法感知组件 Card 更新是 click+setTimeout 造成的,还是其他 React 内部机制合并的。 结论:副作用方案最多能以“组件为单位”追踪和同步,无法做到“事件精确归因”。

框架想兼容,与事件链路不同只能是以组件维度为最小副作用跟踪单元 以 React 18useId 为例:它可以保证所有窗口下,组件节点的上下文 ID 一致,多窗口同步副作用时,只需根据组件 ID 归位,即使不是同一次事件触发也能达成一致。

副作用同步的“粒度边界”

只要保证组件 contextId/上下文一致,多窗口副作用同步就可归一到“组件级自动同步”,不关心事件链。

存储

  • 时效性非常高的项目,考虑 opfs + wasm
  • 常规项目 localStorage + indexDB 存储分层即可

设想会提供自动恢复的 api, 类似:

// const [messageList, setMessageList] = useState([])
// storageKey 为加密随机健
const { useSyncState, storageKey } = createSyncState();
const [messageList, setMessageList] = useSyncState([])

// 恢复前的调用,转换函数
syncRecover(storageKey, () => {})

窗口恢复

根据上述插槽设计,事件链路不强依赖固定主窗口,每个操作窗口都可以是事件发送方。 根据副作用记录的同步特性,窗口恢复分为两种场景:

  1. 事件执行完毕后窗口关闭
    • 同步操作:副作用记录已完整同步到其他窗口,可直接从记录中恢复
    • 异步操作:当某个窗口竞选为新的 Leader 后,接管异步上下文的执行权,将未完成的异步插槽标识为发起方继续执行
  2. 事件执行过程中窗口恢复
    • 非阻塞执行链发送概率非常低。
    • 一旦发生:新竞选的 Leader 窗口将重新执行完整的事件链路

操作链路会有类似事务的操作, 如问答系统: 对于多问一答多问多答,整个一个多问轮次可以被统计为一通操作(事务)恢复时会重新执行这一通操作

隐式收益:测试生态的构建

上述方案,本身会收集用户操作指令,基于改特性。本地环境,当开发者实现某个功能时,会自动捕获该功能链路中的所有用户交互指令。

指令收集:

  1. 通过智能降噪和语义提取,可以相对精确获得与特定功能相关的完整指令序列
  2. 类似reactquery 界面工具,手动记录操作开始->结束

指令包括:

  • 用户交互操作(点击、输入、滚动等)
  • API请求调用及响应
  • 页面状态变化
  • 路由跳转记录
E2E自动化测试

将用户指令集直接转换为端到端测试代码(如 Playwright、Cypress 等)

基础流程生成 将收集的指令序列直接转换为测试代码(流程)

断言生成策略 每个指令对应一个事件链路,通过分析插槽记录生成关键断言(请求),按顺序将该断言插入到对应用例流程代码块中

MCP智能测试平台

将指令集转换为 MCP(Model Context Protocol 所需的标准化输入格式,让AI助手能够:

  • 理解完整的业务操作流程
  • 自动执行功能验证测试【结合现有测试用例(前端e2e, 测试自动化测用例)的智能推断验证】
  • 提供智能化的测试建议和优化方案

文档信息

Search

    Table of Contents