前端

Odoo 协同编辑为什么不是“大家共用同一份 DOM”:history step、snapshot 与分支合并讲透

很多人理解协同编辑时,会自然想到“把最新 DOM 广播给所有人”。但 Odoo `collaboration_plugin.js` 的做法完全不是这样:它站在 history step 之上同步分支、补缺失父步骤、定时做 snapshot,并在外部步骤进入后修正 selection。它真正同步的是可合并的编辑历史,而不是一坨最新 HTML。

前端
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 5 阅读

只要提到协同编辑,很多人脑海里最先冒出的模型都是:

  • 谁改了内容;
  • 把最新 HTML 或 DOM 发给别人;
  • 别人直接替换掉;
  • 完事。

这种想法听起来直觉,但一旦多人同时编辑、网络时延、撤销重做、光标恢复这些问题叠上来,就很快会崩。

Odoo 在 addons/html_editor/static/src/others/collaboration/collaboration_plugin.js 里的设计给了一个更成熟的答案:

协同编辑里真正该同步的,不是“一份最新 DOM”,而是可排序、可补缺、可快照的历史步骤系统

这也是为什么它的协同插件依赖的不是“网络广播器”,而是:

  • history
  • selection
  • sanitize

这三个依赖几乎已经把设计思路写在脸上了。

一、协同插件站在 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,系统不会硬塞进去,而是:

  1. 从当前历史里往前回溯;
  2. 找到该 peer 最后一个已知步骤,或者第一步;
  3. 触发 history_missing_parent_step_handlers
  4. 带上 fromStepId 去请求缺失步骤。

这代表官方对协同编辑的理解很清醒:

  • 本地不知道父步骤,不代表这条外部变更无效;
  • 更不能凭空猜它应该插在哪里;
  • 正确做法是先承认信息不完整,再补齐缺失历史。

这和很多“协同编辑 demo”最大的区别就在这里:demo 常常只在理想网络顺序里成立,而正式系统必须处理乱序与断连。

五、并发分支不是简单覆盖,而是带有确定排序的合并

如果多个步骤共享同一个 previousStepId,源码不会说“后来的覆盖前面的”。它会:

  • 收集 concurrent steps;
  • 基于 step id 的字典序比较,决定插入顺序;
  • 再沿着这条并发链继续往后找。

这说明 Odoo 没把协同理解成“总有一个绝对线性顺序”,而是承认会出现并发分支,然后给出一个确定性的并发排序策略

这种策略不一定完美,但它至少满足两个条件:

  1. 所有 peer 最终可以收敛到同一历史顺序;
  2. 不会因为消息到达顺序不同,导致每个人文档结构都不一样。

对协同系统来说,“确定性收敛”往往比“局部最自然”更重要。

六、为什么要定时做 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()

  1. 先创建一个 clone 节点;
  2. 在 clone 上设置属性;
  3. 调 sanitize;
  4. 如果 sanitize 后还允许该属性,再真正写回节点。

这特别值得注意。

因为协同编辑很容易让人误以为:

  • 既然是内部协作用户,外部步骤就直接信任。

但 Odoo 明显不这么想。即使是协同来源的属性修改,也要经过 sanitize 这一关。

这代表它把协同数据当成“外部输入的一种”,而不是可以绕过编辑器安全边界的内部捷径。

九、实战里最常见的几个误区

误区 1:协同编辑就是同步最新 HTML

这在单人串行演示里能跑,多人并发、乱序消息、撤销重做一来就会失控。

误区 2:不记录 step 的前驱关系

没有 previousStepId,你几乎没法可靠处理并发插入和缺失补齐。

误区 3:把 snapshot 理解成“定时备份”

它更像历史链的恢复锚点,不只是存档。

误区 4:合并外部步骤后不修 selection

结构可能对了,体验仍然会坏。

误区 5:来自协同网络的数据就默认可信

安全边界一旦让路给“反正是同事改的”,迟早会出问题。

十、结论

Odoo 协同编辑的核心,不是广播一份“最新 DOM”,而是维护一条可补缺、可并发排序、可定期快照的历史步骤链

它通过:

  • peerId 区分步骤归属与可逆边界;
  • previousStepId 维护历史依赖;
  • 缺失父步骤处理器补齐断链;
  • snapshot 缩短恢复路径;
  • selection rectification 稳住交互体验;
  • sanitize 守住外部输入边界。

所以更准确的一句话是:

Odoo 的协同编辑同步的不是一份 DOM,而是一套能让多人编辑最终收敛的历史机制。

理解这一点,你再看富文本协同、评论区协作、知识库编辑甚至未来更复杂的多人内容工具,都会更容易抓住问题本质:同步结果只是表面,同步可合并的变更结构才是关键。

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。