await 不是问题根源,而是误用为同步屏障导致状态错位;正确做法是按键瞬间生成带唯一标识的原子操作,交由协作引擎统一排队、广播与合并,并用逻辑时间戳保障因果序。

这不是 await 本身的问题,而是把异步等待当作同步屏障来用导致的状态错位。await 只是暂停当前函数执行,不阻止其他代码运行,也不保证操作的全局时序。协同编辑冲突的关键不在“谁先 await”,而在于“谁的操作被按什么顺序应用”。
明确操作序列与状态快照边界
所有编辑操作必须封装为不可变、带唯一标识的原子指令(如 {op: "insert", pos: 12, text: "x", clientId: "u1", seq: 45}),不能依赖本地 await 后再发——那会把网络延迟、JS 执行耗时都算进操作逻辑里。正确做法是:用户按键瞬间生成操作对象,立即交由协作引擎(如 Yjs 或 ShareDB)排队,由引擎统一控制广播与合并。
- 避免在操作生成后加 await 等待 DOM 渲染或校验完成再发送;渲染应滞后于操作提交
- 每个操作携带逻辑时间戳(如 Lamport clock 或 vector clock),而非系统时间,确保跨设备可比
- 服务端不接受“带 await 延迟后发”的操作,只认协作引擎生成并签名的合法 op
分离“本地响应”与“协同状态”
用户输入后,前端应立刻更新本地视图(乐观更新),同时异步提交操作。await 不该用于等远端确认,而可用于等本地状态机完成 apply(如 Y.Text.applyDelta)。若在此处卡住,说明状态引擎被阻塞,需检查是否在 apply 中嵌套了未 await 的异步调用或同步重计算。
- 光标移动、高亮、语法提示等 UI 反馈走本地状态,不等服务器回执
- 协同状态变更(如他人插入内容)通过事件监听触发,而非轮询或 await 某个 promise
- 禁止在操作 handler 里写
await fetch(...)或await db.save(...)—— 这类副作用必须剥离到独立的副作用处理器中
用确定性变换替代时序依赖
OT 或 CRDT 的核心价值,就是让操作能在任意顺序下变换后收敛。如果发现 await 时间差导致不同客户端应用顺序不一致,说明底层算法没真正落地,或客户端混用了多种状态源(比如一部分读 DOM,一部分读 Y.Doc)。
- 确保所有读写都经过同一协作文档实例(如
doc.getText("content")),绝不直接操作编辑器内部 state - CRDT 场景下,禁止用
setTimeout或await delay()来“对齐”操作;因果关系由操作元数据(ID + 依赖集)保障,不是靠延时 - OT 场景下,服务端必须严格按逻辑序号排序广播,客户端收到 op 后先 transform 再 apply,transform 函数不能含异步逻辑
断连与重放场景下的 await 风险
客户端断线重连时,常有人写 await fetchSnapshot(); await replayOps(),但若 replayOps 是逐条 await 发送,则每条之间可能被新用户操作插入,破坏因果链。正确方式是批量重放、原子提交。
- 快照 + 增量日志必须一次性加载并交由协作引擎全量重放,引擎内部处理顺序,不暴露给上层 await
- 重放过程禁用用户输入,或把新输入暂存队列,等重放完成后再统一 transform 并提交
- 测试时故意制造网络抖动 + 高频输入,验证即便 op 到达顺序乱序,最终文档内容与光标位置仍一致











