协同编辑:ProseMirror collab 与 Yjs / CRDT
返回 🗂 富文本编辑专题 · 相关私有笔记
实时协同编辑要解决的核心问题只有一句话:多个客户端并发地修改同一份文档,如何保证最终所有人看到完全一致的结果(eventual consistency)。围绕这一目标,业界形成了两条主流路线——以 ProseMirror 内置 prosemirror-collab 为代表的 OT(Operational Transformation,中心化 rebasing),以及以 Yjs + y-prosemirror 为代表的 CRDT(Conflict-free Replicated Data Type,去中心化)。两者都能达到收敛,但收敛的“机制”截然不同:OT 靠中心服务器强制全局线性顺序,CRDT 靠操作本身满足交换律(commutativity)。
这篇笔记的目标不是“怎么接一个协同”,而是讲清两条路线的设计内核:并发编辑究竟是怎么收敛的,各自的收敛保证从何而来,以及由此衍生出的中心化 vs 去中心化、离线、历史/撤销、内存等取舍。要理解 PM 这一侧,前置概念是 State 与 Transaction 里的 Step / Mapping 与 PM 核心模型 里的整数位置模型。
进入前:先确认协同真的是当前问题
这不是单人编辑器的下一节必修课。只有已经跑通 TipTap 实战 或一个等价的单人 PM 编辑器,且产品明确需要“多人同时改同一份结构化文档”时才进入本篇。离线草稿、本地撤销、普通的后端保存和“多个用户各自编辑不同记录”都不自动等于协同编辑。
最小先修:
- 读过 ProseMirror 入门,能区分浏览器 DOM 与编辑器文档状态。
- 能跟随一条
Transaction里的 Step、位置和 Mapping;这是理解 rebase 而不把它当黑箱的最低要求。 - 知道 Schema 是共享合同:接入协同前,所有客户端必须使用兼容的节点、标记和属性定义。
- 需要把远端事务接入 View 或自定义同步逻辑时,再回看 Plugin 与 Decoration 与 View 与 NodeView。
先按产品约束选路线,再读实现细节
| 先问的问题 | 更接近的路线 | 进入本篇的位置 |
|---|---|---|
| 已有中心服务,需要权威顺序和可控的在线流程 | prosemirror-collab | 路线一 |
| 需要离线优先、多端弱网合并或点对点能力 | Yjs + y-prosemirror | 路线二 |
| 还没有确定共享数据模型、权限、房间与持久化策略 | 暂不接入 | 先完成单人产品与数据合同 |
一、路线一:prosemirror-collab(OT 风格,中心权威)
1.1 模型:中心权威 + version + steps
prosemirror-collab 实现的是一种服务器中心化的 OT 思路。它的世界观可以归纳为三件东西:
- 中心权威服务器(central authority):它是唯一的“真相来源(single source of truth)”,负责给每一批被接受的改动分配一个单调递增的整数 version。
- version:一个简单的整数,从 0 开始。它表示“文档已经被确认应用了多少批 step”。客户端用它来标记“我所知道的、已被服务器确认的最新状态”。
- steps:文档的原子改动(
ReplaceStep、AddMarkStep等),即 PM 里Transaction拆解出的 Step。客户端把本地产生的 step 发往服务器,服务器线性排序后广播给所有人。
关键的设计抉择是:服务器不做 OT 变换,它只做排队和编号。真正的“变换/重映射”逻辑被下放到客户端(见 1.3 rebase)。服务器智能程度要求很低——“接受、编号、广播”而已。这与传统 OT(服务器侧也要 transform)不同,是 Marijn 在 ProseMirror 里的一个简化。
1.2 客户端的两份文档状态
为了在“有未确认改动”的情况下还能正常工作,collab 客户端在概念上维护两份状态:
- 包含本地未确认改动的状态:就是用户当前正在编辑、UI 上看到的
EditorState。 - 最后确认 version 对应的状态:用于 rebase 的基准。
当用户输入时,本地 step 立即应用到第 1 份状态(乐观更新,UI 不卡顿),同时进入“未确认队列”等待服务器确认。getVersion(state) 返回客户端当前已与权威同步到的 version。
1.3 收敛的核心:客户端 rebase 未确认 step
这是整条路线的“正确性所在”。设想:客户端本地有未确认的 step [L1, L2],此时服务器推来了别人提交的远端 step [R1, R2](它们已经被分配了更前的 version)。客户端不能简单把 R1, R2 接到 L1, L2 后面——因为 L1, L2 是基于 R 之前的文档算出来的位置,直接套用会错位甚至损坏文档。正确做法是 rebase:
- 先用本地 step 的逆(
step.invert(doc))把文档“退回”到产生L1, L2之前的基准; - 应用远端
R1, R2,得到新的文档基准; - 把
L1, L2通过R1, R2产生的位置映射重映射为L1', L2',再应用到新基准上。
最终效果等价于按 [R1, R2, L1, L2] 的顺序应用——即远端先、本地后,所有客户端都遵循服务器钦定的这个全局顺序,于是收敛。这里位置重映射靠的是 PM 的 Step.map(mapping) 与 Mapping 类:
// 概念示意:把本地 step 通过远端 step 的位置映射重映射后再应用
// R1, R2 各自带有 StepMap,串成一个 Mapping
const mapping = new Mapping([R1.getMap(), R2.getMap()])
const L1mapped = L1.map(mapping) // 可能返回 null(被删区间整体落空)
const L2mapped = L2.map(mapping)
// 应用顺序:远端先,重映射后的本地 step 后实践中不要手写这套 rebase。collab 插件内部封装了
receiveTransaction,它会自动完成“反转本地 step → 套远端 step → 重建本地 step”的全过程。手动操纵Mapping(尤其是 mirror 索引)极易出错并损坏内容。这里展开只是为了说清原理。
Mapping 内部用 mirror 索引把“正向 map”和它对应的“反转 map”关联起来,这样在 rebase 时能正确地“退回去再前进”。Step.invert(doc) 能无损地生成逆操作,这同时也是 undo 的基础(见第四节)。位置映射时的 bias 参数(Mapping.map(pos, bias))决定了“在同一位置插入内容时,旧位置是被推后还是保持不动”,bias = -1 让位置“粘”在插入点之前,这对“两人同点插入”时保留用户意图很关键。
关于 Step / StepMap / Mapping 的完整语义,见 State 与 Transaction。
1.4 collab 插件 API
import { collab, getVersion, sendableSteps, receiveTransaction } from "prosemirror-collab"
// 创建协同插件:version 起始号、clientID 区分本客户端的改动
const plugin = collab({ version: 0, clientID: Math.floor(Math.random() * 0xffffffff) })collab(config?)→Plugin。config.version默认 0;config.clientID(类型number | string)默认是一个随机 32-bit 数,用于让服务器与同伴归因改动来自谁。sendableSteps(state)→{ version, steps, clientID, origins } | null。返回尚未发送给服务器的本地 step 批次;没有可发的就返回null。其中version是这批 step 所基于的版本号(服务器据此判断兼容性),origins是产生这些 step 的原始Transaction数组(用于查时间戳等元数据)。receiveTransaction(state, steps, clientIDs, options?)→Transaction。把权威推来的远端 step 包装成一个Transaction,内部自动 rebase 本地未确认 step。clientIDs是与steps等长的 ID 数组。options.mapSelectionBackward(默认false)在远端改动触及光标时,用负向 bias 映射选区两端,使在光标处插入的内容落在光标之后——通常更符合直觉。getVersion(state)→number。返回客户端已与权威同步到的 version。
一个最小循环:本地每次 dispatch 后调用 sendableSteps 把新 step POST 给服务器;收到远端 step 时用 receiveTransaction 生成事务并 dispatch。
const view = new EditorView(mount, {
state: EditorState.create({ schema, plugins: [collab({ version: 0 })] }),
dispatchTransaction(tr) {
const newState = view.state.apply(tr)
view.updateState(newState)
const sendable = sendableSteps(newState) // 拿到待发送的本地 step
if (sendable) {
void fetch("/send", {
method: "POST",
body: JSON.stringify({
version: sendable.version,
steps: sendable.steps.map((s) => s.toJSON()),
clientID: sendable.clientID,
}),
})
}
},
})
// 收到权威推来的远端 step → 自动 rebase 本地未确认 step
// clientIDs 必须与 steps 等长
function onRemoteSteps(steps: Step[], clientIDs: (number | string)[]) {
view.dispatch(receiveTransaction(view.state, steps, clientIDs))
}1.5 这条路线的硬约束
- 必须有中心权威来维护单一的 version 序列。没有它,客户端必然发散。
- 服务器必须分配单调递增、不跳号的 version。若用错误的 version 或缺失 step 调用
receiveTransaction,会损坏文档。 prosemirror-collab不定义传输层。连接失败重试、sendableSteps的排队、丢包恢复,全部要你自己实现(HTTP / WebSocket / 自定义皆可)。- Step 序列化为 JSON(
step.toJSON())是schema 感知的;在 schema 不一致的编辑器之间互传 step 会失败或损坏数据。
二、路线二:Yjs + y-prosemirror(CRDT,去中心化)
2.1 模型:用唯一 ID 取代 version
Yjs 是一种 delta-state CRDT,底层算法是 YATA。它的世界观和 OT 正好对偶:
- 每个操作(insert / delete)被分配一个全局唯一 ID
{ clientId, clock }:clientId每个 peer 唯一,clock是该 client 内部单调递增的计数器。 - 这套 ID 不是 version——它不要求全局线性顺序,而是用来追踪因果关系(causality),并在“两人同点插入”时给出一个确定的排序依据。
- 操作满足交换律(commutative)、结合律(associative)与幂等(idempotent):无论以什么顺序、应用多少次,只要所有副本最终收到全部操作,结果一定收敛到同一份文档。
这是和 OT 最根本的区别:OT 把收敛性建立在“服务器强制的顺序”上,CRDT 把收敛性建立在“操作本身的代数性质”上。因此 Yjs 不需要中心权威逻辑——任何 peer 都能独立产生操作,服务器(若有)只需傻瓜式广播原始 update 即可。
2.2 共享数据类型:Y.Doc / Y.XmlFragment
import * as Y from "yjs"
const ydoc = new Y.Doc() // 根容器
const yXmlFragment = ydoc.getXmlFragment("prosemirror") // 文档结构容器Y.Doc是根容器。Y.XmlFragment持有一个有序的Y.XmlElement/Y.XmlText列表,用来建模类 DOM 的树结构——它正好对得上 PM 核心模型 里的文档树。insert(index, content)/delete(index, len)形态像数组操作,但冲突自动消解。- 关键内核:删除是 tombstone(墓碑)而非真删除。被删内容仍以墓碑形式保留在状态中,以维持因果关系——这也是 CRDT 内存随历史增长的根因(见第五节)。
- 所有改动都触发
update事件:ydoc.on("update", (update, origin) => {})。y-prosemirror监听这些事件,把它们翻译成对应的 ProseMirrorTransaction应用到编辑器;反过来,编辑器的本地改动也会写回Y.XmlFragment。
Y.XmlFragment 的结构必须符合 ProseMirror schema(见 PM Schema)。y-prosemirror 会维护这层映射,但绕过插件直接手改 Y.Doc 可能产生不符合 schema 的结构,导致编辑器崩溃。
2.3 同步协议:state vector + update(只传缺失的)
Yjs 同步的精髓在于只传对方缺的那部分,而非整篇文档:
Y.encodeStateVector(doc)→Uint8Array:编码一个{ clientId → clock }的映射,表示“我已经见过每个 peer 到第几号 clock 的操作”。Y.encodeStateAsUpdate(doc, encodedTargetStateVector?)→Uint8Array:把状态编码为二进制 update;若传入对端的 state vector,则只编码对端缺失的差量。Y.applyUpdate(doc, update, origin?):应用二进制 update,幂等且可交换。
握手过程(y-protocols 的 sync 协议):连接时双方交换 state vector(SyncStep1);对端回 encodeStateAsUpdate(doc, 你的stateVector),即“你缺的那些 update”(SyncStep2);之后双方再增量交换 update 消息。
// 手动同步两份 doc(演示 state vector 的作用)
const sv = Y.encodeStateVector(ydocA) // A 把自己的进度告诉 B
const diff = Y.encodeStateAsUpdate(ydocB, sv) // B 只算出 A 缺的差量
Y.applyUpdate(ydocA, diff) // A 应用差量 → 两端收敛为什么这样设计:state vector 避免重复传历史;二进制编码大幅压缩;这套机制可扩展到很多 peer 和很长的编辑历史。幂等性意味着同一 update 应用两次也安全(网络重发无害),但 CRDT 仍要求所有操作最终到达所有副本——网络分区期间会暂时发散,直到同步完成。
2.4 awareness:光标 / 在线状态(独立的临时 CRDT)
光标、用户名/颜色、在线状态这类信息不该像文档编辑那样持久化,Yjs 把它们放进一个独立、临时(ephemeral)的 awareness 协议:
- awareness 是状态型(state-based) 而非操作型:每个 client 的最新状态直接覆盖旧状态(latest-wins,靠递增 clock 判断新旧),不需要合并历史——对“当前光标在哪”这类语义,只有最新值有意义。
- 它会在 peer 不活动约 30 秒后超时(30 秒收不到某 peer 的更新就标记其离线),据此优雅地判定离线,无需显式关闭消息;持续在线的 peer 通过周期性广播心跳来避免被误判。
- provider(
y-websocket/y-webrtc)通常负责管理 awareness 实例。
import { Awareness } from "y-protocols/awareness"
const awareness = new Awareness(ydoc)
awareness.setLocalState({
user: { name: "Alice", color: "#ff0000" },
cursor: { anchor: sel.$anchor.pos, head: sel.$head.pos }, // 用 PM 整数位置
})
awareness.on("change", () => {
for (const [clientId, st] of awareness.getStates()) { // getStates() 返回 Map<clientId, state>
if (st?.cursor) renderRemoteCursor(clientId, st.cursor, st.user) // 渲染远端光标
}
})注意:awareness 状态不持久,千万别拿来存文档内容,只用于 presence 和光标。
2.5 provider 模式:可插拔的网络传输
Yjs 把 CRDT 核心与网络传输解耦——这是它生态繁荣的关键:
- provider 负责把
Y.Doc同步给 peer 或存储,并管理 awareness。 y-websocket:走 WebSocket,服务器无状态(只广播 update),对应客户端-服务器拓扑。y-webrtc:走 WebRTC,点对点,无需中心服务器。y-indexeddb:本地持久化,实现离线优先(offline-first)。- 多个 provider 可链式叠加——例如
y-indexeddb(离线持久)+y-websocket(在线同步)。
import { WebsocketProvider } from "y-websocket"
import { ySyncPlugin, yCursorPlugin } from "y-prosemirror"
const provider = new WebsocketProvider("ws://localhost:1234", "my-room", ydoc)
const state = EditorState.create({
schema,
plugins: [
ySyncPlugin(yXmlFragment), // 把 PM state 绑定到 Y.XmlFragment
yCursorPlugin(provider.awareness), // 远端光标渲染
],
})
provider.awareness.setLocalState({ user: { name: "Alice", color: "#ff0000" } })因为
y-prosemirror是通过 ProseMirrorTransaction来应用远端改动的,dispatchTransaction/view.updateState对远端改动也会触发。要区分本地与远端,需读 transaction 的 meta / origin(见 Plugin 与 Decoration 里的 meta 约定)。另外,多个编辑器连同一个Y.XmlFragment时,务必避免反馈回环。
三、收敛保证对比:rebasing vs CRDT(本节是重点)
两条路线都给出 eventual consistency,但“为什么会收敛”的论证完全不同:
| 维度 | OT / prosemirror-collab | CRDT / Yjs |
|---|---|---|
| 收敛依据 | 中心服务器强制全局线性顺序;所有客户端按同一顺序应用变换后的操作 | 操作天然满足交换律 + 幂等;靠唯一 ID 与因果追踪,顺序无关 |
| 顺序要求 | 需要序列化(serialization),version 不能跳号 | 顺序无关(order-independent),可任意次序到达 |
| 服务器角色 | 排队 + 编号 + 广播(本实现不做 transform) | 傻瓜广播 raw update(可有可无) |
| 并发同点编辑 | 由全局顺序 + rebase 决定先后 | 由唯一 ID {clientId, clock} 决定先后 |
| 离线工作 | 困难(依赖随时可达的权威) | 天然支持(离线先写,联网再合并) |
rebasing 的收敛:每个客户端把本地未确认 step 反转→套远端 step→重建,使最终序列等价于服务器钦定的全局顺序。只要 step 被正确变换,所有人收敛到同一文档。这一步是“并发正确性”的所在——rebase 错误就会内容损坏。
CRDT 的收敛:所有客户端应用全部操作(顺序可不同),因为操作效果本身可交换(唯一 ID + 因果追踪保证),所以顺序无所谓,最终一致。
一个常被忽略的细微点:CRDT 保证“收敛”,但不保证应用 UI 语义上的“意图”。两个用户在同一位置插入,Yjs 会按它们的唯一 ID 排序,这个顺序未必符合用户直觉。某些场景需要额外的意图元数据(如 intention index)来补偿。OT 这边则用 bias 参数在位置映射层面尽量保留意图。
四、历史与撤销(undo / redo)
协同编辑里 undo 的铁律是:用户的 undo 只能撤销自己的改动,绝不能撤销同伴的工作——撤掉别人的内容既破坏性又反直觉。两条路线都为此做了区分:
- prosemirror-collab:远端 step 不应进入 undo 栈,只有本地 step 可被撤销。机制上,
receiveTransaction在生成事务时会打上addToHistory: false的 meta,因此prosemirror-history天然不会把远端改动算进历史——这是“只撤销自己”的实现根基,而非靠你手动隔离。 - Yjs:
y-prosemirror导出yUndoPlugin()插件与配套的undo/redo命令,实现尊重 CRDT 的撤销;undo 只反转本地改动,保留同伴改动不动。其底层是Y.UndoManager,通过追踪 origin /clientID来界定“哪些可撤销”。
二者背后是同一思路:靠 origin / clientID 过滤“谁的改动可撤销”。PM 的逆操作能力(step.invert(doc) 无损可逆)是其 undo 的底层支撑,详见 State 与 Transaction 与 Plugin 与 Decoration。
五、内存与可扩展性取舍
- OT(collab):为支持晚加入者(late-joiner),服务器需要保存历史 step;若不做 snapshot,服务器内存无界增长。客户端只存未确认 step(通常很小)。复杂度集中在服务器(它要排序、广播,传统 OT 还要 transform)——“纵向扩展”。
- CRDT(Yjs):默认把完整操作历史(含 tombstone)保存在内存里,所以历史也是状态的一部分。超长文档 + 重度编辑历史可能撑爆客户端内存。可用
Y.Doc.destroy()、tombstone 的垃圾回收、以及把 update 落库 + snapshot 缓解。复杂度分散到所有客户端(各自维护操作 ID 与幂等合并)——“横向扩展”。
一句话权衡:小团队 / 小文档,CRDT 更省心(无需服务器逻辑);大历史,两者都需要外部存储 + snapshot 策略。
六、选型小结
- 已有传统后端、追求强一致 + 可控的中心化权威、且能接受“必须在线”的场景 →
prosemirror-collab。它实现概念简单(整数 version),但要自己搭传输层、重试、snapshot。 - 需要离线优先 / 点对点 / 多端弱网合并、希望服务器尽量傻瓜化 → Yjs + y-prosemirror。生态(provider、awareness、bindings)成熟,但要理解 CRDT 内存模型,并接受“收敛≠意图完全对齐”。
更全面的横向方案比较(数据模型 / 扩展性 / 协同 / 框架耦合)见 选型对比;协同对大文档的性能影响见 性能与可访问性;若用 TipTap,其官方协同方案即基于 Yjs,集成方式见 TipTap 架构 与 TipTap 实战。
参考
- ProseMirror Collaborative Editing Guide — https://prosemirror.net/docs/guide/#collab
- ProseMirror Reference(collab / receiveTransaction / sendableSteps)— https://prosemirror.net/docs/ref
- Marijn Haverbeke: Collaborative Editing in ProseMirror — https://marijnhaverbeke.nl/blog/collaborative-editing.html
- Yjs Official Documentation — https://docs.yjs.dev/
- Yjs Document Updates API — https://docs.yjs.dev/api/document-updates
- Yjs Awareness API — https://docs.yjs.dev/api/about-awareness
- y-protocols: Sync & Awareness Protocol — https://github.com/yjs/y-protocols/blob/master/PROTOCOL.md
- y-prosemirror: ProseMirror Binding for Yjs — https://github.com/yjs/y-prosemirror
- Bartosz Sypytkowski: Delta-state CRDTs with YATA — https://www.bartoszsypytkowski.com/yata
- TinyMCE: Real-time collaboration — OT vs CRDT — https://www.tiny.cloud/blog/real-time-collaboration-ot-vs-crdt