collab(下):receiveTransaction 与整个收发循环

7 分钟阅读
·

上一篇把 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。

系列目录

日期 标题
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 原理
08-08 collab(下):receiveTransaction 与整个收发循环(本篇)

四个导出函数的分工

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) 单入口。时序按一次完整收发走:

  1. start:GET 文档,拿到 doc 的 JSON 和当前 version,EditorState.create 时用 collab({version}) 把初始版本灌进插件,插件列表里同时有 history(),转 poll。
  2. poll:GET events?version=getVersion(state)。返回里有 steps 就 Step.fromJSON 还原,receiveTransaction 包成 transaction 送回 dispatch,然后继续 poll。空响应(长轮询超时)也直接继续 poll。
  3. 本地输入:view 的 dispatchTransaction 把 transaction 送进 dispatch,apply 出新 state 后查 sendableSteps,非空就转 send,POST 请求体带 version、steps 的 JSON、clientID。转 send 之前先 closeRequest 把还在飞的轮询请求 abort 掉:发送成功后客户端会自己合成确认(下一步),那个挂起的轮询即使被唤醒返回的也是即将过期的数据,留着只会制造一次多余的重绘。
  4. send 成功后有一个省往返的做法:客户端不等 poll 绕一圈回来确认,直接拿刚发出的 steps 和 repeat(clientID, steps.length) 调 receiveTransaction。clientIDs 全是自己,前缀匹配一次摘光,走「全是自己的」提前返回分支,version 当场对齐,unconfirmed 清空。确认回执由客户端自己合成,省掉一次往返。
  5. 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 序列是什么关系。


1373 字 · 49 段落
xi ming

Written by xi ming You should follow him on Github