collab(上):协作编辑的 rebase 原理

3 分钟阅读
·

上一篇 menu 是 UI 层的收尾,从这篇开始进入高级扩展阶段,先看 collab。协作编辑听起来是网络问题,但 prosemirror-collab 里一行网络代码都没有:没有 WebSocket,没有 HTTP,没有定时器。源码只有 src/collab.ts 一个文件,185 行,对外是 collab、sendableSteps、receiveTransaction、getVersion 四个函数,另有一个标了 @internal 的 rebaseSteps。它做的事只有一件:维护「本地哪些 step 还没被中心确认」这份账,远端 step 到达时把账重整一遍。传输和服务端存取全部留给使用方。参考代码是 prosemirror-collab 的 7736c6c;顺带引用的 prosemirror-transform、prosemirror-state、prosemirror-history 分别是 662b7a9、ffad5d9、445409b。

系列目录

日期 标题
05-10 ProseMirror 源码分析开篇:富文本编辑器到底难在哪
05-17 ProseMirror 仓库全景:22 个包怎么分工
05-24 跑通一个最小 ProseMirror:先看文档长什么样
06-07 ProseMirror model(上):Node 与 Fragment,文档树的骨架
06-14 ProseMirror model(中):Mark,内联格式怎么挂在文本上
06-21 ProseMirror model(下):Schema 与 content expression,文档的类型系统
07-05 ResolvedPos:一个数字位置怎么变成路径
07-12 Slice 与 replace:切一块文档出来再塞回去
07-19 DOMSerializer:文档怎么变成 DOM 和 HTML
08-02 DOMParser:parseDOM 规则与 HTML 解析
08-09 findDiffStart / findDiffEnd:两份文档怎么求差
08-16 model 收官:Node 上的辅助方法与位置约定总结
09-06 ProseMirror transform(上):Step 抽象,所有修改的最小单位
09-20 ProseMirror transform(下):ReplaceStep 与 Fitter,最复杂的一步
10-03 StepMap:一步修改怎么映射每个位置
10-11 Mapping:多步映射的链式合并,rebase 的地基
10-18 structure.ts:split/join/lift/wrap 的可达性判断
10-25 Transform 类:构建修改的 API 层
11-08 ProseMirror state(上):EditorState,不可变编辑器状态
11-15 Selection 体系:四种选区与选区书签
11-22 Transaction:Transform 加上状态语义
12-06 Plugin 系统(上):StateField 与插件状态
12-13 Plugin 系统(下):props、appendTransaction 与 filterTransaction
12-20 state 收官:动手写三个插件验证理解
01-03 ProseMirror view(上):EditorView,状态与 DOM 之间的桥
01-10 ViewDesc(上):文档到 DOM 的描述树
01-17 ViewDesc(下):增量更新怎么做到只改动的部分
02-07 DOMObserver 与 readDOMChange:浏览器改了 DOM,怎么读回文档
02-14 input.ts:从 keydown 到 dispatchTransaction 的输入管线
02-21 选区同步:state 选区与 DOM 选区的双向对齐
02-28 Composition 与 IME:中文输入法事件的处理
03-07 NodeView 与 MarkView:把渲染权交给你
03-14 Decoration 体系:不修改文档的视觉标注
03-21 clipboard:复制粘贴的序列化与解析
04-04 domcoords:屏幕坐标与文档位置的双向换算
04-11 browser.ts:浏览器差异补丁集
04-18 view 收官:不用官方扩展,手写一个最小可用编辑器
05-09 扩展(上):keymap,最小的插件
05-16 commands:命令的签名约定与组合器
05-23 history:undo/redo 栈与 rebasing
06-06 inputrules:「# 空格」变成标题是怎么实现的
06-13 schema-basic:官方基础文档结构
06-20 schema-list:列表节点与最复杂的一批命令
07-04 gapcursor:光标落不进去的地方怎么办
07-11 dropcursor:拖拽时的插入位置指示
07-18 menu:菜单栏组件体系
08-01 collab(上):协作编辑的 rebase 原理(本篇)

集中式权威与版本号

prosemirror-collab 假设的拓扑是集中式的:所有客户端把本地产生的 step 发给同一个中心(authority),中心决定一个全局顺序,按顺序接受或拒绝,客户端之间不直接通信。这个假设换来一个直接推论:全局只有一条 step 序列,任何时刻客户端的文档都可以表示成「中心已确认的前 N 步」加上「本地已应用但中心还没确认的若干步」。

N 就是版本号。CollabState(src/collab.ts)只有两个字段:

  • version:最后一次与中心对齐的步数。中心每接受一步,版本号加一。
  • unconfirmed:本地已应用、尚未被中心确认的 step 列表。

这两个字段就是 collab 插件维护的全部状态。文档内容本身在 EditorState 里,插件只管记账。另外还有个 clientID,用来在中心发回的序列里认出自己的 step,默认是一个随机 32 位数;version 默认从 0 开始,collab(config) 里可以指定初始值,从某个历史版本接着编辑时用。

版本号同时是 unconfirmed 的基底坐标。sendableSteps 把 version 连同 step 一起发给中心,中心据此检查这批 step 是否基于自己当前的末尾。如果中心已经走到更新的版本(其他客户端的 step 先进去了),基底对不上,中心拒绝,客户端要先拉取新 step 做 rebase 再重发。拒绝、拉取、重发这套循环在使用方实现,包内只提供两端各需要的两个函数。

中心那一侧要满足的条件不多,但缺一不可:给所有 step 定一个全局顺序,把已接受的 step 持久化,能按版本号把「vN 之后的所有 step」发给任何来问的客户端。满足这三条,客户端断线重连后报上自己的 version,拿到差额补齐即可,不需要全量传文档。传输线上跑的是 Step 的 JSON 形式,Step 抽象自带的 toJSON/fromJSON(第 13 篇 Step 抽象,所有修改的最小单位)在这里成了协议的序列化层。冲突检测也退化成一个整数比较:客户端报上来的 version 和中心当前版本相等就接受,不等就拒绝。

unconfirmed 数组怎么收集

unconfirmed 的元素是 Rebaseable 三元组(src/collab.ts 的 Rebaseable 类),在裸 Step 之外多存两份东西:

  • inverted:这一步的逆 step。
  • origin:产生它的原始 transaction(Transform 类型)。

逆 step 要在收集时就算好,这是由 Step.invert 的签名决定的:算逆操作需要「这一步应用之前的文档」做参数,比如 ReplaceStep 的逆要把被删掉的内容存进新 step。收集发生在 transaction 刚应用完的时刻,中间文档还挂在 transform.docs 里,unconfirmedFrom 逐步调 transform.steps[i].invert(transform.docs[i]) 就能拿到。等 rebase 发生时这些中间文档早没了,现场算来不及。origin 存原始 transaction 是为了元数据:step 被 rebase 改写之后,时间戳、输入来源这类信息只留在原始 transaction 上,sendableSteps 的文档注释里专门提醒了 origins 是旧的未改写对象。

插件的 state.apply 只有三个分支:

apply(tr, collab) {
  let newState = tr.getMeta(collabKey)
  if (newState) return newState
  if (tr.docChanged)
    return new CollabState(collab.version, collab.unconfirmed.concat(unconfirmedFrom(tr)))
  return collab
}

第一个分支是给 receiveTransaction 留的入口:接收方算好新的 CollabState 塞进 meta,apply 直接采用,跳过收集。第二个分支是常态,任何改了文档的本地 transaction,全部 step 追加进 unconfirmed。只动选区的 transaction 走第三个分支,状态原样返回。

注意第二个分支的条件只有 tr.docChanged,不看 transaction 的出身。undo、redo 产生的 transaction 同样会被收集进 unconfirmed,按普通 step 发给中心。这在协作语义下是合理的:撤销改变的是共享文档,其他人也必须看到这次撤销。inputrules、appendTransaction 这类插件产出的改动也一样入账。collab 对本地改动不做任何甄别,账全收,重整交给 rebase。

发送侧是 sendableSteps(state):unconfirmed 为空返回 null,否则打包出 {version, steps, clientID, origins}。version 就是上面说的基底坐标,steps 是全部未确认 step 按顺序排列,origins 做成惰性 getter,第一次访问时才从 unconfirmed 收集并缓存,避免每次轮询都 map 一遍。getVersion(state) 只是读 version 字段,给使用方做同步状态展示用。

远端步骤到达:先认回自己的

中心广播新 step 时,使用方调 receiveTransaction(state, steps, clientIDs, options)。steps 是中心新接受的序列,clientIDs 与之一一对应,标明每步来自哪个客户端。函数先算版本和归属的账:

let version = collabState.version + steps.length
let ours = 0
while (ours < clientIDs.length && clientIDs[ours] == ourID) ++ours
let unconfirmed = collabState.unconfirmed.slice(ours)
steps = ours ? steps.slice(ours) : steps

version 直接加上收到的步数,对齐到中心末尾。然后数 clientIDs 的前缀里有多少步是自己的:自己发出的 step 被中心收进去再广播回来,这段前缀就是确认回执,unconfirmed 头部摘掉对应数量,steps 也切掉这段,剩下的才是没见过的新 step。前缀判断隐含一个顺序约定:本地只从 unconfirmed 头部往外发,中心按到达顺序接受,所以确认回来的部分一定排成前缀,不会穿插在别人的 step 后面。

为什么确认要等中心的 echo,本地发送成功时不直接确认:因为只有中心知道全局顺序。发送成功的时刻,本地无法知道这批 step 之前有没有被别人的 step 插队,也无法知道自己会不会因基底过期被拒,确认只能以中心的广播为准,echo 回来的 clientID 和顺序就是权威凭据。这也是 unconfirmed 里每个 step 都提前存好 inverted 的原因:拿到回执之前,任何本地改动都可能需要撤销重来。

如果这批全是自己的(切完后 steps 为空),没有新东西,返回一个不带文档改动、只通过 meta 更新 CollabState 的 transaction,确认流程到此结束。还有一条快速路径:本地没有积压(unconfirmed 为空),收到的又都是别人的 step,那就没有东西可重整,一个 for 循环把远端 step 依次应用进 transaction,unconfirmed 直接置空。只有本地积压和远端新 step 同时存在,才进入下面的 rebase。

rebaseSteps:撤销、追赶、重做

剩下的部分就是这篇要完整推导的 rebase。设本地与中心同步在 v10,本地之后打了两步 s1、s2 还没发出去,此刻收到远端一步 r1,同样基于 v10。本地文档已经是 v10 + s1 + s2,r1 插不进来:它的位置坐标是相对 v10 编的,直接 apply 会指错地方。rebaseSteps(src/collab.ts,标了 @internal)在同一个 transaction 的 Transform 上按三段推进。

第一段,撤销。逆序应用每个 unconfirmed 的 inverted,文档退回 v10。inverted 在收集时已算好,这里只是依次 step 进去。为什么非要先撤销:远端 step 必须真实应用到一个基于 v10 的文档上,才能产生正确的新文档,而 Transform 只会往后追加 step,回退的唯一手段就是应用逆 step。协作场景是 Step 可逆性(第 13 篇的 invert 接口)的第二个大消费方,第一个是 history。

第二段,追赶。把远端 step 依次应用,文档走到 v10 + r1,也就是 v11。

第三段,重做。把每个本地 step 映射到新基底上再尝试应用:

for (let i = 0, mapFrom = steps.length; i < steps.length; i++) {
  let mapped = steps[i].step.map(transform.mapping.slice(mapFrom))
  mapFrom--
  if (mapped && !transform.maybeStep(mapped).failed) {
    transform.mapping.setMirror(mapFrom, transform.steps.length - 1)
    result.push(new Rebaseable(mapped, mapped.invert(transform.docs[transform.docs.length - 1]), steps[i].origin))
  }
}

slice 的下标值得用 s1、s2、r1 这个例子走一遍。撤销和追赶之后,mapping 里有三个 map:s2⁻¹、s1⁻¹、r1。处理 s1 时 mapFrom 是 2,slice(2) 是 [r1]:s1 原本作用于 v10,只需越过 r1 就落到了 v11 的坐标系,映射结果是 s1′,maybeStep 应用后 mapping 变成四个 map。处理 s2 时 mapFrom 减到 1,slice(1) 是 [s1⁻¹, r1, s1′]:s2 的位置是相对 v10 + s1 编的,先过 s1⁻¹ 退回 v10,再过 r1 和刚重做的 s1′,落到 v11 + s1′ 的坐标系,得到 s2′。mapFrom 每轮减一,因为每处理完一个本地 step,mapping 末尾多出一个重做 map,起点跟着往前挪一位,恰好把下一个 step 所需的那个逆 map 包进来。这条链上每个 map 把位置从前一份文档的空间搬到后一份,就是第 16 篇 Mapping:多步映射的链式合并 讲的 map through 语义,当时预告的协作 rebase 消费方就是这里。

用一个带坐标的例子再过一遍第三段。设 v10 的文本是 “abcd”,s1 在 “c” 前插入 “X”(文档变 “abXcd”),s2 在末尾 “d” 后插入 “Y”,坐标相对 “abXcd” 编。r1 删掉了 “b”。撤销和追赶之后文档是 “acd”。处理 s1:它的插入点在 r1 的删除点之后,过 r1 的 map 时坐标前移一位,s1′ 仍在 “c” 前插入,得 “aXcd”。处理 s2:先过 s1⁻¹,插入点在 X 之后,坐标减一退回 “abcd” 的空间;再过 r1,再减一进入 “acd” 的空间;最后过 s1′,加一进入 “aXcd” 的空间。三个 map 各搬一程,净效果是 s2 的坐标只被 r1 挪了一位,仍落在末尾。位置映射就是这样一格格对齐坐标系的,每个 map 只负责自己那一份文档之间的差异。

maybeStep 应用失败这条分支值得单独看。映射只搬位置,不保证新位置上结构合法:如果例子里 s2 是给 “b” 加粗的 AddMarkStep,r1 把 “b” 删掉之后,Step.map 会直接返回 null,或者映射出一个应用必失败的 step。两种结局一样,这个 step 不进新的 unconfirmed,本次本地输入在冲突下放弃。冲突的丢弃粒度是整个 step,包内不做字符级合并。存活下来的 step 重新算 inverted(参数是重做后的最新文档),origin 沿用旧值,组成新的 Rebaseable。

setMirror 那句把旧 step 的逆 map 和新 step 的 map 登记为镜像对。消费方是 history:undo 栈里存着旧 step 和它的选区书签,rebase 之后栈要重建,镜像关系让 history 能把旧 item 对应到新 step。没有这句,协作开着的时候 undo 会映射到错误位置。镜像机制本身也在第 16 篇。

rebase 过程

三段在同一个 Transform 上连续追加,docs、steps、mapping 三个数组同步增长,对 view 来说这就是一次普通 transaction:一次 dispatch,DOM 从 v10 + s1 + s2 一次性更新到 v11 + s1′ + s2′。用户看到的是自己的字还在原地,别人的字插了进来。

这套结构的交互含义也值得记一句:本地输入永远先应用、先渲染,发不发的出去不影响打字,网络延迟被 unconfirmed 数组吸收掉。代价全部记在 rebase 一侧:每次收到远端 step,都要把积压的未确认工作撤销重做一遍,积压越多,单次 rebase 的链越长,丢弃冲突的概率也越高。所以使用方的节奏一般是收到确认就立刻发下一批,尽量让 unconfirmed 保持短小。

事务上挂的三个 meta

receiveTransaction 返回前在 transaction 上挂了三个 meta,各有明确的读者。

setMeta(“rebased”, nUnconfirmed) 的读者是 history。history 的 applyTransaction(prosemirror-history 的 src/history.ts)在分支链里读这个值,拿到后调 Branch.rebased 重建 done 和 undone 两个栈里受影响的 item。nUnconfirmed 为 0 时这个值是 falsy,分支自动跳过,走普通的 addMaps 路径。

setMeta(“addToHistory”, false) 让远端 step 不进 undo 栈。这个 meta 还决定了分支链的走向:addToHistory 为 false 的 transaction 不会进常规的记录分支,才会落到后面的 rebased 分支。撤销是本地语义,按 Ctrl+Z 不应该删掉别人打的字。

setMeta(collabKey, newCollabState) 装新的 CollabState,走 state.apply 的第一个分支生效。

反向还有一个约定挂在 collab 插件自己的 spec 上:historyPreserveItems: true。history 默认把相邻输入合并成一个 item(第 40 篇 history:undo/redo 栈与 rebasing 的事件分组),合并之后 item 和 step 不再一一对应,rebase 时对不上号。history 每次往栈里加 transform 前调 mustPreserveItems 扫一遍插件列表,发现任何插件声明了这个 flag 就放弃合并,按 step 原样保存。两个包的配合就靠这三个 meta 加一个 flag,代码上互不 import。

options.mapSelectionBackward 是个小开关:开启后如果当前是文本选区,anchor 和 head 按 -1 偏好映射,别人在光标处插入内容时光标留在插入内容之前,同时清掉 transaction 的选区更新标记位。默认关闭,注释里写的理由是向后兼容。

小结与下一篇

这篇把 collab 的账本和重整算法看完了:version 加 unconfirmed 两个字段,Rebaseable 三元组的收集时机,receiveTransaction 的前缀确认,rebaseSteps 的撤销、追赶、重做三段式。整个包不碰网络,把一个听起来很大的问题压缩成「一条中心序列加一份本地差额」的记账问题,差额的重整则全部复用 transform 层的 Step 可逆性和 Mapping 链。前面十六篇在 model 和 transform 上花的时间,到这里开始兑现。

下一篇看收发循环的完整面貌:sendableSteps 和 receiveTransaction 在实际使用里怎么排成轮询节奏,history 的 Branch.rebased 具体怎么重建 undo 栈,以及一个最小协作 demo 里各端的时序。


1229 字 · 47 段落
xi ming

Written by xi mingFollow onGitHub