多窗口同步方案
概念介绍:从纯函数思想到副作用归一化
将窗口抽象想象为函数调用:如果能让所有窗口都表现为”纯函数”,就能解决同步问题。
// 理想状态:窗口 = 纯函数
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" />
// 上下文的一致性标识
方式二:动态生成事件上下文标识,将事件与标识捆绑同步给其他窗口, 也能保障事件执行上下文的一致性。 简单对比: 动态生成对应副作用不方便细颗粒管理
- 没法感知事件执行次数
- 没法回放调试
- 调用链路不方便扩展
根据静态特征,很方便实现插件机制举例🌰:
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_3
和 sync_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
插槽获得结果后,会记录并继续执行后续代码- 如果某个同步插槽在上下文结束时仍无结果,说明它依赖于
Promise
的await
结果(处于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/J
S 上下文跟踪能力。 - 框架层调度渲染:调度系统(如
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
的事件优先级调度、批处理; 状态更新可能被合并、延后。 - 如果此时另一个高优先级任务先占用调度队列,导致
Card
的x
更新触发变成“和其他任务合并”。
以上无法感知组件 Card
更新是 click+setTimeout
造成的,还是其他 React
内部机制合并的。 结论:副作用方案最多能以“组件为单位”追踪和同步,无法做到“事件精确归因”。
框架想兼容,与事件链路不同只能是以组件维度为最小副作用跟踪单元 以 React 18
的 useId
为例:它可以保证所有窗口下,组件节点的上下文 ID
一致,多窗口同步副作用时,只需根据组件 ID
归位,即使不是同一次事件触发也能达成一致。
副作用同步的“粒度边界”
只要保证组件 contextId/上下文一致
,多窗口副作用同步就可归一到“组件级自动同步”,不关心事件链。
存储
- 时效性非常高的项目,考虑
opfs + wasm
- 常规项目
localStorage + indexDB
存储分层即可
设想会提供自动恢复的 api
, 类似:
// const [messageList, setMessageList] = useState([])
// storageKey 为加密随机健
const { useSyncState, storageKey } = createSyncState();
const [messageList, setMessageList] = useSyncState([])
// 恢复前的调用,转换函数
syncRecover(storageKey, () => {})
窗口恢复
根据上述插槽设计,事件链路不强依赖固定主窗口,每个操作窗口都可以是事件发送方。 根据副作用记录的同步特性,窗口恢复分为两种场景:
- 事件执行完毕后窗口关闭
- 同步操作:副作用记录已完整同步到其他窗口,可直接从记录中恢复
- 异步操作:当某个窗口竞选为新的
Leader
后,接管异步上下文的执行权,将未完成的异步插槽标识为发起方继续执行
- 事件执行过程中窗口恢复
- 非阻塞执行链发送概率非常低。
- 一旦发生:新竞选的
Leader
窗口将重新执行完整的事件链路
操作链路会有类似事务的操作, 如问答系统: 对于多问一答多问多答,整个一个多问轮次可以被统计为一通操作(事务)恢复时会重新执行这一通操作
隐式收益:测试生态的构建
上述方案,本身会收集用户操作指令,基于改特性。本地环境,当开发者实现某个功能时,会自动捕获该功能链路中的所有用户交互指令。
指令收集:
- 通过智能降噪和语义提取,可以相对精确获得与特定功能相关的完整指令序列
- 类似reactquery 界面工具,手动记录操作开始->结束
指令包括:
- 用户交互操作(点击、输入、滚动等)
- API请求调用及响应
- 页面状态变化
- 路由跳转记录
E2E自动化测试
将用户指令集直接转换为端到端测试代码(如 Playwright、Cypress
等)
基础流程生成 将收集的指令序列直接转换为测试代码(流程)
断言生成策略 每个指令对应一个事件链路,通过分析插槽记录生成关键断言(请求),按顺序将该断言插入到对应用例流程代码块中
MCP智能测试平台
将指令集转换为 MCP(Model Context Protocol
所需的标准化输入格式,让AI助手能够:
- 理解完整的业务操作流程
- 自动执行功能验证测试【结合现有测试用例(前端e2e, 测试自动化测用例)的智能推断验证】
- 提供智能化的测试建议和优化方案