纯 WebSocket 广播 HTML 字符串不可用于协同编辑,因其导致覆盖、光标错位、操作丢失和带宽爆炸;必须采用结构化操作(Operation)+ OT/CRDT 算法,WebSocket 仅作低延迟传输通道。

纯 WebSocket 广播 HTML 字符串或 innerHTML 快照,无法支撑真实协同编辑——它会导致内容覆盖、光标错位、操作丢失、带宽爆炸。真正可用的同步机制必须基于结构化操作(Operation)+ 一致性算法(OT 或 CRDT),WebSocket 只负责低延迟传递。
为什么不能 send(document.body.innerHTML)
很多在线运行系统一上来就监听 input 或 contenteditable 的 change 事件,然后把整个 HTML 字符串发给服务端再广播。这会立刻暴露问题:
- 两个用户同时在
<div>内插入文本,后到的快照直接覆盖前者的修改,无合并逻辑 -
innerHTML不包含光标位置、选区、折叠状态等 UI 上下文,客户端无法还原焦点 - 10KB 的 HTML 每次按键都全量传输,5 个用户并发编辑时带宽和解析压力陡增
- 网络抖动导致消息乱序,A 发的“第 3 版”比 B 的“第 2 版”晚到,别人看到的 HTML 突然“倒退”
必须用 Operation 替代快照,且带完整上下文
前端应监听编辑器底层变更(如 CodeMirror 6 的 dispatch、Monaco 的 onDidChangeModelContent),生成原子操作对象,而不是抓取 DOM:
-
type必须是语义化动作:"insert"、"delete"、"replace"、"format" -
index是逻辑偏移(字符数 or AST 节点路径),不是 DOM 坐标;需配合当前文档版本校准 - 每条操作必须含
docId、clientId、version(本地已知最新版)、timestamp(服务端打戳优先) - 示例:
{"type":"insert","docId":"html-abc","clientId":"u7","index":42,"text":"<p>Hello</p>","version":17,"timestamp":1717845621000}
服务端不能只做广播中转,必须介入 OT/CRDT 流程
Spring Boot 或 Node.js 的 WebSocket 处理器收到操作后,若直接 broadcast(),协作必然崩坏。关键拦截点有三个:
文章转信息图。将文章/笔记转化为手机可读的 HTML 信息图,自动匹配视觉风格。触发场景:文章转图、笔记转图、信息图、转小红书图、做张图、可视化这篇文章、文生图。
立即学习“前端免费学习笔记(深入)”;
- 校验
docId是否在有效会话中,拒绝伪造请求(如篡改他人docId) - 查缓存获取该文档当前
version,丢弃version <= current的旧操作(防重放) - 调用 OT 变换函数:比如用户 A 在位置 5 插入
<span>,用户 B 同时在位置 6 删除 2 字符,B 的操作必须先被transform(B_op, A_op)调整位置,再写入历史并广播 - 不建议手写完整 OT;用
ot.js(JS)或operational-transform(Java)等成熟库,确保前后端变换逻辑一致
前端渲染与操作必须解耦,光标/选区单独同步
编辑器(如 Monaco)不是被动接收 HTML 字符串,而是作为视图层响应协作引擎的状态变更:
- 所有操作先交由 Yjs 的
Y.Text或 ShareDB 的Doc处理,它维护唯一真相源 - 编辑器仅监听
yText.observe或doc.on('op'),收到操作后调用自身applyEdits()安全更新内容 - 光标和选区不能从 HTML 推导——必须单独发送
{"type":"cursor","clientId":"u7","pos":45,"anchor":45}类消息,服务端按房间广播,前端用绝对定位 DOM 渲染他人光标 - 避免在
onmessage里直接操作document.body.innerHTML,否则 OT 链断裂、状态不可逆
最易被忽略的一点:前后端对同一操作的 apply 函数必须完全一致。哪怕 JS 里 insert 用 slice + concat,Java 里用 StringBuilder.insert,只要索引偏移规则不同,最终各端文档就会分叉。建议把核心 apply 逻辑抽成独立模块,前后端共用或严格对齐。


















