只要提到协同编辑,很多人脑海里最先冒出的模型都是:
- 谁改了内容;
- 把最新 HTML 或 DOM 发给别人;
- 别人直接替换掉;
- 完事。
这种想法听起来直觉,但一旦多人同时编辑、网络时延、撤销重做、光标恢复这些问题叠上来,就很快会崩。
Odoo 在 addons/html_editor/static/src/others/collaboration/collaboration_plugin.js 里的设计给了一个更成熟的答案:
协同编辑里真正该同步的,不是“一份最新 DOM”,而是可排序、可补缺、可快照的历史步骤系统。
这也是为什么它的协同插件依赖的不是“网络广播器”,而是:
historyselectionsanitize
这三个依赖几乎已经把设计思路写在脸上了。
一、协同插件站在 history 上,而不是直接站在 DOM 上
插件 setup 后最重要的动作之一,不是监听 DOM 变更广播出去,而是:
- 通过
step_added_handlers处理历史步骤; - 通过
history_step_processors给步骤打上peerId; - 通过
onExternalHistorySteps()接收外部步骤并插入当前历史链。
这说明 Odoo 认为真正值得同步的单位不是“此刻页面长什么样”,而是:
- 谁做了一步编辑;
- 这一步接在哪个前置步骤后面;
- 它属于哪个 peer;
- 它如何进入本地历史序列。
这种设计比同步 DOM 强很多,因为 DOM 只是当前结果,而 history step 更接近“变更事件”。
二、peerId 不是附属信息,而是可逆性与分支判断的关键
源码里 setup() 时会强制要求:
- collaboration config 必须有
peerId
而且每次步骤进入 processHistoryStep() 或 onStepAdded(),都会给 step 写入:
step.peerId = this.peerId
这件事非常关键。
因为一旦多人协作,系统必须知道:
- 哪些步骤是我自己产生的;
- 哪些步骤来自别人;
- 某些撤销操作对谁有效;
- 缺失父步骤时,应沿着谁的历史分支去追。
你会看到 isUnreversibleStep(step) 里直接判断:
step.peerId !== this.peerId
这说明 Odoo 默认就把“别人的步骤”视作不该被你本地随手逆掉的历史。
换句话说,peerId 不是标记作者那么简单,它还决定可逆性边界。
三、外部步骤进入时,不是直接 append,而是要找插入点
onExternalHistorySteps(newSteps) 里,对每个新步骤都会调用:
getInsertStepIndex(steps, newStep)
这里特别有意思。
系统不会假设外部步骤一定发生在本地历史末尾,而是会先去找:
newStep.previousStepId对应的已知位置
如果找到了,才能决定当前新步骤应该插在哪。
这说明 Odoo 的协同模型默认承认一个现实:
多个客户端的步骤到达顺序,和它们真实发生顺序,不一定一致。
所以你不能靠“收到得晚就放后面”这种简单逻辑处理协同编辑。
四、缺失父步骤时,系统会显式触发补历史,而不是盲猜
getInsertStepIndex() 里还有一段更关键:
如果一路往前找都找不到 previousStepId,系统不会硬塞进去,而是:
- 从当前历史里往前回溯;
- 找到该 peer 最后一个已知步骤,或者第一步;
- 触发
history_missing_parent_step_handlers; - 带上
fromStepId去请求缺失步骤。
这代表官方对协同编辑的理解很清醒:
- 本地不知道父步骤,不代表这条外部变更无效;
- 更不能凭空猜它应该插在哪里;
- 正确做法是先承认信息不完整,再补齐缺失历史。
这和很多“协同编辑 demo”最大的区别就在这里:demo 常常只在理想网络顺序里成立,而正式系统必须处理乱序与断连。
五、并发分支不是简单覆盖,而是带有确定排序的合并
如果多个步骤共享同一个 previousStepId,源码不会说“后来的覆盖前面的”。它会:
- 收集 concurrent steps;
- 基于 step id 的字典序比较,决定插入顺序;
- 再沿着这条并发链继续往后找。
这说明 Odoo 没把协同理解成“总有一个绝对线性顺序”,而是承认会出现并发分支,然后给出一个确定性的并发排序策略。
这种策略不一定完美,但它至少满足两个条件:
- 所有 peer 最终可以收敛到同一历史顺序;
- 不会因为消息到达顺序不同,导致每个人文档结构都不一样。
对协同系统来说,“确定性收敛”往往比“局部最自然”更重要。
六、为什么要定时做 snapshot,而不是永远从第一步重放
插件里定义了:
HISTORY_SNAPSHOT_INTERVAL = 60 秒HISTORY_SNAPSHOT_BUFFER_TIME = 10 秒
并在 setup 时启动定时器,周期性 makeSnapshot()。
这说明 Odoo 很清楚,如果协同历史只是一味追加步骤,随着编辑变长,会出现两个问题:
- 新 peer 加入或重连时,要从很早以前一路补步骤,成本太高;
- 本地恢复或重建时,历史链越来越长,处理会越来越重。
snapshot 的意义就是:
- 定期把“当前文档状态 + 某个步骤 id”压成一个锚点;
- 后续同步时,只要拿这个锚点后的步骤增量即可。
这和数据库里的 checkpoint、日志截断思路很像:不是不要历史,而是要有分段恢复点。
七、为什么应用外部步骤后还要修正 selection
onExternalHistorySteps() 里插入步骤后,如果当前 selection 还在 editable 内,会调用:
selection.rectifySelection(...)
这是非常成熟的一笔。
因为协同编辑最难受的体验之一就是:
- 别人一改,你的光标乱飞;
- 甚至选区落到非法位置;
- 再一输入,整个节点结构更乱。
Odoo 显然不把 selection 当成“浏览器自己会处理好的细节”,而是把它视为协同编辑的重要状态。历史合并后不修 selection,编辑体验就不稳定。
八、sanitize 进入协同链,说明“来自别人”也不能默认可信
插件提供了 set_attribute_overrides,最终会走 safeSetAttribute():
- 先创建一个 clone 节点;
- 在 clone 上设置属性;
- 调 sanitize;
- 如果 sanitize 后还允许该属性,再真正写回节点。
这特别值得注意。
因为协同编辑很容易让人误以为:
- 既然是内部协作用户,外部步骤就直接信任。
但 Odoo 明显不这么想。即使是协同来源的属性修改,也要经过 sanitize 这一关。
这代表它把协同数据当成“外部输入的一种”,而不是可以绕过编辑器安全边界的内部捷径。
九、实战里最常见的几个误区
误区 1:协同编辑就是同步最新 HTML
这在单人串行演示里能跑,多人并发、乱序消息、撤销重做一来就会失控。
误区 2:不记录 step 的前驱关系
没有 previousStepId,你几乎没法可靠处理并发插入和缺失补齐。
误区 3:把 snapshot 理解成“定时备份”
它更像历史链的恢复锚点,不只是存档。
误区 4:合并外部步骤后不修 selection
结构可能对了,体验仍然会坏。
误区 5:来自协同网络的数据就默认可信
安全边界一旦让路给“反正是同事改的”,迟早会出问题。
十、结论
Odoo 协同编辑的核心,不是广播一份“最新 DOM”,而是维护一条可补缺、可并发排序、可定期快照的历史步骤链。
它通过:
peerId区分步骤归属与可逆边界;previousStepId维护历史依赖;- 缺失父步骤处理器补齐断链;
- snapshot 缩短恢复路径;
- selection rectification 稳住交互体验;
- sanitize 守住外部输入边界。
所以更准确的一句话是:
Odoo 的协同编辑同步的不是一份 DOM,而是一套能让多人编辑最终收敛的历史机制。
理解这一点,你再看富文本协同、评论区协作、知识库编辑甚至未来更复杂的多人内容工具,都会更容易抓住问题本质:同步结果只是表面,同步可合并的变更结构才是关键。
DISCUSSION
评论区