你敲下的字,怎样出现在别人屏幕上?

两个人同时编辑一篇笔记时,看起来只是一个字从我的屏幕出现在你的屏幕。真正麻烦的是:浏览器只告诉编辑器“DOM 变了”,而协同服务需要的是能在另一份文档上重放的操作。本文以 NoteDeep 当前的云端编辑链路为例,讲清这一步转换,以及接收端怎样避免把远端修改再次发送出去。

背景与目标

NoteDeep 的富文本编辑器基于 contenteditable,以 DOM 作为主要 model。设计上,一篇文档由 block 列表组成,段落、标题、列表都有各自稳定的结构。用户输入、粘贴和浏览器自身行为却可能生成不同的 DOM 形态。同样一段视觉内容,如果节点结构不同,后续的 op 路径就不可靠。

当前方案把页面表示为 JsonML,再以 JSON0 op 描述增量修改。一个节点可以表示为 [标签, 属性, 子节点…];op 用路径 p 定位修改位置,并用 si、sd 等字段描述文本插入或删除。客户端通过 ShareDB 订阅同一篇页面文档,发送本地 op,接收其他客户端的 op。

下面用一张主流程时序图,展示一次输入从本地 DOM 走到远端 DOM 的完整过程:

mermaid

这里最重要的约束是:所有客户端要把同一个路径理解为同一个位置。如果一个客户端的编辑区保留了浏览器临时生成的额外节点,另一个客户端没有,之后的路径就可能指向不同内容。

本地修改如何变成 op

本地输入先由浏览器改动 DOM。编辑器的 MutationObserver 收集节点增删、属性变化和文本变化。当前实现用 500ms 缓冲连续的 mutation,然后运行 normalize;接着读取 normalize 产生的 mutation,一起转换为 op。

normalize 分为四段:确保根容器至少有一个 block、预处理错误或混合 block、按 schema 清理不合法节点与属性、最后合并列表并补齐空 block 等结构。它不追求保留粘贴内容的所有 HTML 形态,目标是让后续路径计算看到稳定的 block 骨架。例如根容器中的注释节点要删除,否则本地 DOM 与 JsonML 的子节点索引就可能漂移。

这一步不能只看最终 DOM。比如用户删掉整个段落,删除操作还需要知道被删节点的原始内容,撤销时才能恢复。编辑器为此保留一份虚拟节点表示;转换 mutation 时既参考变化后的 DOM,也能从旧状态取回被删内容。

转换器还要筛掉不属于文档数据的变化。复杂 block 分为稳定的 data 区域和可重建的 preview 区域。例如 Mermaid 的代码文本属于 data,渲染后的图属于 preview。后者会自行重建,不该发送给其他人,更不该占用一次 undo。代码会过滤预览区 mutation 和仅改变本地选中态的 class;剩余变化再按节点、属性、文本处理,生成 JSON0 op。

例如,在某个文本节点的第 5 个字符处插入“协”,可以表示为:

{"p":[2,2,5],"si":"协"}

这里的路径只是示意;具体路径取决于当前 JsonML 结构。编辑器把生成的 op 记入 delta 序列和 undo 栈,再通过回调交给页面组件。页面组件调用 ShareDB 文档的 submitOp,发送这次增量修改。

远端如何接收,又如何避免回声

另一位用户订阅同一篇 ShareDB 页面文档后,收到非本端来源的 op。页面组件调用编辑器的 applyOps,编辑器按 op 路径找到对应 DOM 节点,执行文本、节点或属性修改。

这里有一个容易忽略的反馈循环:程序用 DOM API 应用远端 op,也会触发 MutationObserver。如果观察器照常工作,这次远端修改就可能再次被翻译成本地 op 并发出去。所以 applyDelta 在修改 DOM 前断开观察,改完后恢复;外部来源的 op 只更新本地 delta、虚拟节点和相关预览,不再调用发送回调。

暂停观察还有另一个后果:远端 op 改了 Mermaid 等 block 的 data,不能指望 mutation 再触发 preview 渲染。处理方式是在应用每个 op 时,按命中的 DOM target 收集需要渲染的对象;恢复观察后再合并连续 render,避免把尚未完整的中间状态画出来。

mermaid

同一个入口也服务于本地命令和 undo/redo,但这些来源需要把产生的 op 发出去。实现靠来源标记区分,不能把“程序修改 DOM”一概当作远端修改。

这套方案的取舍

DOM 作为主 model,编辑器可以直接处理浏览器输入,不必在每次按键时先维护另一棵完整的文档树。代价是浏览器 DOM 不天然稳定:normalize 必须在 mutation 转 op 前收敛结构,虚拟节点必须保留旧内容,预览与选择态必须从协同数据中过滤。远端更新时,还必须主动识别并重渲染派生内容。协同链路的复杂度集中在这些边界上。

并发编辑也不是“谁最后发送,谁覆盖”。JSON0 op 带路径,ShareDB 的 OT 链路处理并发操作;但路径本身会随其他人的插入、删除而变化。撤销更明显:某人插入的字符后来被别人改动后,旧 op 里的位置未必还是那个字符的位置。当前编辑器会结合后续 delta 计算逆操作;若自己插入的内容已经被他人删除,某些撤销就无法按原意完成。这是协同 undo 的实际边界,适合另开一篇细讲。

这条链路解决的是云端页面文档中“把一次富文本修改表达为可同步的增量操作”。它也提醒我们:实时协同不是接上 WebSocket 就结束了,编辑器内部的数据结构、DOM 收敛和变更过滤,决定了同一条 op 能否在每个客户端得到相同结果。

使用 NoteDeep 开始整理你的知识库

协同编辑 #富文本编辑器 #ShareDB #架构设计 #NoteDeep

posted on 2026-09-21 07:53  NoteDeep深度笔记  阅读(8)  评论(0)    收藏  举报