上一篇把 collab 的账本和重整算法看完了:CollabState 的 version 加 unconfirmed,receiveTransaction 的前缀确认,rebaseSteps 的撤销、追赶、重做三段式。这篇看外围。包的 API 面只有四个函数,使用方要把它们排成一个收发循环才能跑起来;history 和 collab 同时开着的时候,undo 栈要在每次 rebase 后重建,重建逻辑全在 prosemirror-history 一侧;最后把网站仓库里那个最小协作 demo 的时序完整走一遍。参考代码是 prosemirror-collab 的 7736c6c、prosemirror-history 的 445409b;demo 在网站仓库(website 的 a16b4ec)的 src/collab 目录,客户端 client/collab.js,服务端 server/instance.js 和 server/server.js。
系列目录
四个导出函数的分工
collab.ts 的导出面就四个:collab() 装插件,sendableSteps、getVersion、receiveTransaction 三个普通函数,输入输出都围绕 EditorState。(rebaseSteps 前面也带 export 关键字,但注释里标了 @internal,上一篇已经拆过,不计入 API 面。)按读写分:
- sendableSteps(state):读。unconfirmed 非空时打包出 {version, steps, clientID, origins},空则返回 null。version 是这批 step 的基底坐标,中心靠它判断要不要拒收。
- getVersion(state):读。返回当前对齐到的版本号,给轮询请求和界面上的同步状态展示用。
- receiveTransaction(state, steps, clientIDs, options):写。把中心新接受的 step 序列折算成一个 transaction 返回。它只是构造函数,dispatch 与否、什么时候 dispatch,全由调用方决定。
插件自己不 dispatch、不计时、不联网,同步节奏完全在使用方手里。这意味着每个接 collab 的项目都要写一个自己的小状态机:什么时候发、失败了怎么办、多久拉一次。包内的分工很明确,两个读函数回答「现在有没有东西要发、发到哪个版本」,一个写函数回答「中心的回答怎么进编辑器」。
receiveTransaction 设计成「构造并返回 transaction」而不是直接改状态,给调用方留了两处余地。一是时机:transaction 拿到手之后可以攒着,等当前正在进行的输入告一段落再 dispatch。二是内容:调用方可以往同一个 transaction 上继续挂 meta,demo 里就在它上面追加了评论插件的同步信息,一次 dispatch 同时完成文档 rebase 和评论更新,界面只重绘一次。
demo 客户端里这三个函数的调用点可以当模板看:sendable 在每次 transaction 后检查有没有可发内容;poll 的 query 串和 send 的请求体都用 getVersion 报基底;poll 拿回新数据、或 send 成功之后,都调 receiveTransaction 收口。
unconfirmed 的三类变动
整个插件维护的不变量只有一条:本地文档等于中心 version 对应的文档再依次应用 unconfirmed 里的 step。version 只在 receiveTransaction 里前进,步长就是收到的 step 数。unconfirmed 的变动有三类,分别对应三个代码位置。
第一类,本地编辑追加。插件 state.apply 的第二分支,任何 docChanged 的 transaction 都经 unconfirmedFrom 把全部 step 连同逆 step、原始 transaction 一起追加到尾部。上一篇讲过逆 step 必须在收集时算好,这里不重复。
第二类,确认回执摘头。receiveTransaction 数 clientIDs 前缀里自己的步数,unconfirmed.slice(ours) 摘掉对应数量。举个混合的例子:本地 unconfirmed 是 [s1, s2],中心广播回来 [s1, s2, r1],前缀匹配摘掉 s1、s2,steps 剩 [r1],version 一次加 3,r1 直接应用,不需要 rebaseSteps。如果这批 step 全是自己的,走提前返回分支:返回一个不带文档改动、只通过 meta 写回新 CollabState 的 transaction,version 照样加上去,unconfirmed 清空或缩短。确认路径不产生任何 DOM 变化,成本接近零。
第三类,rebase 重写。收到别人的 step 且本地还有未确认内容时,rebaseSteps 返回新数组:step 映射过、inverted 用新文档重算、origin 保留旧对象,应用失败的整个丢弃。重写后的数组随 meta 写回,不变量在新基底上重新成立。
有一个容易看漏的点:undo 走的是第一类。history 插件产生的撤销 transaction 没有 collabKey meta,docChanged 为真,于是撤销产生的逆 step 照样追加进 unconfirmed,照常发给中心。对其他客户端来说,你撤销自己上一处修改和他打一个新字没有区别,都是中心序列里的普通 step。协作下的 undo 因此不需要任何特殊协议,这是把撤销建模成「更多本地 step」换来的好处。限制也存在:undo 栈里只有本地历史,远端 step 带 addToHistory false 从不入栈,按 Ctrl+Z 永远撤不掉别人的字。
三类变动之外,version 和 unconfirmed 还有一层此消彼长的关系值得留意。摘头只动 unconfirmed 不动文档,rebase 同时改文档和 unconfirmed,追加只动 unconfirmed 的尾部。任何时刻想回答「本地领先中心多少」,看 unconfirmed.length;想回答「中心走到哪了」,看 version。sendableSteps 把这两个数一起打包发出去,中心用 version 校验基底、用 steps 推进序列,正好对应这两个问题。
history 侧:rebased 之后重建 undo 栈
上一篇提过 receiveTransaction 挂在 transaction 上的三个 meta,这篇看消费方的完整处理。history 的 applyTransaction(src/history.ts)是一条分支链,顺序很关键:先处理 history 自己产的 transaction,再过滤 steps 为空的,然后查 addToHistory,为 false 才轮到 rebased 分支:
} else if (rebased = tr.getMeta("rebased")) {
return new HistoryState(history.done.rebased(tr, rebased),
history.undone.rebased(tr, rebased),
mapRanges(history.prevRanges!, tr.mapping), ...)
} else {
return new HistoryState(history.done.addMaps(tr.mapping.maps), ...)
}nUnconfirmed 大于 0 时,done 和 undone 两个栈各自拿同一个 rebasedCount 调 Branch.rebased 重建尾部。两个栈都要重建是因为 redo 栈里同样存着逆 step 和书签,坐标系一样被 rebase 换掉了,只修一边的话,撤销之后再重做就会落回旧坐标。等于 0 时 rebased meta 是 falsy,落到最后的 addMaps,把远端 step 的 map 作为无 step 的 item 追加进栈。addMaps 这条路径值得停顿一下:远端 step 不可撤销,所以不进 undo 事件,但栈里更老的 item 存的位置是相对旧文档的,没有这些 map 垫底,下次 undo 弹出老 item 时位置全是错的。map-only item 就是位置链上的接续段。
Branch.rebased 的输入是 rebase 用的 Transform 和 rebasedCount。它的前提假设是栈末尾 rebasedCount 个 item 与 unconfirmed 数组一一对应,这个对应关系怎么保证放到下一节,先看重建本身。仍用上一篇的例子:本地 s1、s2 未确认,远端 r1 到达,rebase mapping 是 [s2⁻¹, s1⁻¹, r1, s1′, s2′],setMirror 登记了 s1⁻¹ 与 s1′、s2⁻¹ 与 s2′ 两对镜像。重建分几步:
- start 定在 items.length - rebasedCount,先正序数一遍尾部这段里有几个带选区书签的 item,从 eventCount 里减掉,它们马上要被移除或替换。
- 再正序遍历这段 item,同时让 iRebased 从 rebasedCount 递减,使每个 item 正好取到自己当初那个逆 map 的下标,mapping.getMirror 一查就拿到它重做后的新下标 pos。getMirror 返回 null 说明那个本地 step 在 rebase 时应用失败被丢弃,对应 item 不进新栈,书签一起消失,eventCount 少一个。
- 存活的 item 整体换新:step 字段换成 rebasedTransform.steps[pos].invert(rebasedTransform.docs[pos]),也就是重做后新 step 的逆;选区书签用 mapping.slice(iRebased + 1, pos) 映射,对 s1 来说这段正好是 [r1],书签越过远端 step 落到新坐标系。
- newUntil 记录存活 item 里最小的 pos,mapping 下标 [rebasedCount, newUntil) 这段是远端 step 的 map,包成 map-only item 插到被重建 item 之前。start 之前的老 item 不受影响,但它们日后被弹出时,位置会顺着这些 map 越过 r1。
- 收尾检查 emptyItemCount,超过 max_empty_items(500)就 compress。协作开着时每个远端 step 都留一个 map-only item,时间一长栈里大半是这种空气项,compress 把指定水位以下的部分重写:老 step 映射过累积的 map、能合并的合并,把空气挤掉。水位参数保证刚重建的尾部不被触碰,因为下一次 rebased 还要靠这段干净的 item 对齐。
rebased 分支还有一行小维护:HistoryState 里的 prevRanges 用 mapRanges 整体映射一遍。prevRanges 记录上一次改动的范围,是事件分组里「这次改动和上次是否相邻」的判断依据(见第 40 篇 history:undo/redo 栈与 rebasing)。rebase 把文档坐标系换了,不映射的话下次本地输入会被误判成不相邻,平白多切一个撤销事件。
undo 命令本身也受影响。histTransaction 弹出事件时把 preserveItems 传进 popEvent:弹出的 item 不删除,全部转成 map-only 记录留在栈里,step 成功应用的再补一条带 mirrorOffset 的镜像 item。undo 之后栈的尾部结构仍然和 unconfirmed 对得上,后续 rebase 的 start 定位才不会偏。
preserveItems 的两处作用
上一篇说过 collab 插件在 spec 上声明 historyPreserveItems: true,这里把 history 一侧的消费补全。mustPreserveItems 扫一遍插件列表,任何插件声明这个 flag 就返回 true,结果按插件数组的引用缓存,不重复扫。true 之后有两处行为变化。
第一处在 addTransform。默认情况下 history 会把能合并的相邻 step 合成一个 item(Item.merge 尝试 step.merge,连续输入因此一次撤销一片),合并之后 item 和 step 不再一一对应。preserveItems 为真时 lastItem 直接取 null,合并分支短路,每个 step 独占一个 item。Branch.rebased 的 start 定位靠 item 数等于 unconfirmed 的 step 数,合并开着这个等式就不成立。
第二处就是上面说的 popEvent 保留记录。两处合起来,协作场景下 undo 栈的尾部永远是一段未合并、未被删除消耗的 item 序列,rebase 随时能按个数对齐。代价在存储上:item 数量跟着 step 数线性增长,合并带来的压缩没有了,栈比非协作时长。撤销的弹出粒度不受影响,undo 本来就按事件弹出,一个事件从最近的带书签 item 算到栈尾,里面是一个 item 还是十个 item,用户感知一样。
一个最小 demo 的完整时序
理论讲完,看一个能跑的实现。网站仓库 src/collab 下的 demo 是 collab 包配套的最小协作系统,传输用普通 HTTP,同步逻辑集中在客户端的 client/collab.js 和服务端的 server/instance.js、server/server.js。
服务端每个文档是一个 Instance(server/instance.js):doc、version、steps(全量历史 step,超过 MAX_STEP_HISTORY 一万条就从头部截断)、waiting(挂起的长轮询响应)。两个操作对应两个端点。addEvents 先 checkVersion 做区间检查,version 不等于当前值返回 false,路由层转成 409 “Version not current”;相等就逐步 apply、version 加步数、steps 追加,最后 sendUpdates 把 waiting 数组排空,逐个调 finish,挂起的请求在这一刻拿到包含新 step 的响应。getEvents 用 steps.length - (this.version - version) 算切片起点,小于 0 说明客户端要的版本已经被截断冲掉,返回 false,路由层转成 410 “History no longer available”。GET events 端点拿到数据就直接返回,拿不到就把响应包进 Waiting 挂进 inst.waiting,最长挂五分钟,超时回一个空对象。空对象里没有 steps 字段,客户端的 poll 分支识别后只是原地再发一次,长轮询因此等价于一个廉价的推送通道。
客户端是 EditorConnection(client/collab.js),一个 comm 字段取值 start/poll/send/recover 的小状态机,所有动作走 dispatch(action) 单入口。时序按一次完整收发走:
- start:GET 文档,拿到 doc 的 JSON 和当前 version,EditorState.create 时用 collab({version}) 把初始版本灌进插件,插件列表里同时有 history(),转 poll。
- poll:GET events?version=getVersion(state)。返回里有 steps 就 Step.fromJSON 还原,receiveTransaction 包成 transaction 送回 dispatch,然后继续 poll。空响应(长轮询超时)也直接继续 poll。
- 本地输入:view 的 dispatchTransaction 把 transaction 送进 dispatch,apply 出新 state 后查 sendableSteps,非空就转 send,POST 请求体带 version、steps 的 JSON、clientID。转 send 之前先 closeRequest 把还在飞的轮询请求 abort 掉:发送成功后客户端会自己合成确认(下一步),那个挂起的轮询即使被唤醒返回的也是即将过期的数据,留着只会制造一次多余的重绘。
- send 成功后有一个省往返的做法:客户端不等 poll 绕一圈回来确认,直接拿刚发出的 steps 和 repeat(clientID, steps.length) 调 receiveTransaction。clientIDs 全是自己,前缀匹配一次摘光,走「全是自己的」提前返回分支,version 当场对齐,unconfirmed 清空。确认回执由客户端自己合成,省掉一次往返。
- send 失败按状态码分流。409 说明中心的版本已经超前,转 poll 把新 step 拉回来,receiveTransaction 里做完 rebase,unconfirmed 还在,下一轮 sendableSteps 原样重发。400(invalid version)和 410 说明基底太旧已经救不回来,转 restart 重新加载整个文档。其余网络错误进 recover,退避从 200 毫秒起每次翻倍,封顶 60 秒,到点转 poll。
状态机里还有一个 detached 分支:dispatch 里每次 apply 之后检查文档尺寸,超过四万就报「Document too big」,comm 置为 detached,之后所有 transaction 只在本地 apply,不再发送。文档超限在协作里是服务端保护存储的常见做法,这里客户端自觉退出同步,编辑能力保留,等于降级成单机编辑器。
另外提一句,demo 的评论功能复用同一对 /events 端点:poll 的响应里除了 steps 还带 comment 数组,send 的请求体里除了 steps 也带 comment 事件,两边各有一个独立的 commentVersion 做基底。文档同步和评论同步共用一条通道、各记各的账,这个结构和 collab 包的设计是同一种思路,信道与账本分离。
整个循环里 collab 包只出现在三个点:初始化时的 version、发送前的 sendableSteps、收口时的 receiveTransaction。版本检查、顺序裁定、历史存取、长轮询全在服务端那两个文件里,传输层换成 WebSocket 也只是把 poll 换成推送,包一侧的调用时序不变。这对应上一篇的结论:包管账本,使用方管循环。
小结与下一篇
这篇把 collab 的外围补完了:四个导出函数的读写分工,unconfirmed 的不变量和追加、摘头、重写三类变动,history 一侧 Branch.rebased 的尾部重建和 preserveItems 的两处作用,以及 demo 里 start、poll、send、recover 的完整时序。undo 被建模成更多本地 step 这一点尤其值得记住,它让协作和撤销两个功能在同一个账本模型里共存,代价全部由 history 的重建逻辑承担。
下一篇看 changeset:变更集怎么表示两个文档之间的差异,和 collab 的 step 序列是什么关系。

