做过在线文档或者协同编辑功能的前端都知道,技术选型第一步就是”用什么算法”。CRDT还是OT?Yjs还是Automerge?社区里铺天盖地的推荐都指向Yjs——GitHub 17k star,Tiptap在用,Notion据说也参考了它的思路。
但这两天Hacker News上一篇文章炸了锅:Lies I was Told About Collaborative Editing, Part 2: Why we don’t use Yjs,来自Moment.dev团队,直接指出Yjs在生产环境中的根本性缺陷。不是小bug,是架构级别的问题。
我把文章啃完了,结合自己之前用ProseMirror做富文本编辑器的经验,聊聊这事到底怎么回事。
Yjs的致命问题:每次按键都在重建整个文档
这是最让我震惊的一点。
y-prosemirror(Yjs的ProseMirror绑定层)的工作方式是:每一次协同编辑的按键,都会删除并重新创建整个ProseMirror文档。
不是diff patch,不是增量更新,是整个文档从头建一遍。
这意味着什么?
- 每次按键都要重建所有NodeView、所有Decoration、所有DOM元素
- 依赖position mapping的插件全部失效——评论定位、协同光标、选区管理全崩
- Undo行为变得诡异
- 文档里的小组件(widget)状态不断被清空
- Plugin的apply回调变成全文档扫描,而不是只看变更部分
- Node identity不稳定
这不是bug,是设计决策。y-prosemirror的GitHub issue #113早就暴露了这个问题,Yjs作者Kevin在讨论中承认这是有意为之。ProseMirror作者Marijn直接说这种做法会破坏大量功能。
做过富文本编辑器的都知道,ProseMirror的Transaction机制是精心设计的——每次操作生成精确的Step,position mapping可以追踪文档变化。Yjs的”全量替换”策略直接把这套精密机制废掉了。
40行代码的替代方案
Moment.dev团队展示了一个让人汗颜的对比:用prosemirror-collab这个极其简单的库,40行代码就能实现:
- 乐观更新
- 网络断开时继续编辑
- 重连后自动同步
- 细粒度的编辑溯源
原理简单到不像话:
1. 服务端维护一个authority:文档 + 已应用的steps + 当前版本号 2. 客户端提交steps和lastSeenVersion 3. 如果版本不匹配,客户端拉取最新变更,rebase自己的修改,重新提交 4. 完事
这就是经典的OT(Operational Transformation)思路,ProseMirror原生就支持。prosemirror-collab-commit还能在服务端做rebase,省掉一次往返。
那CRDT的优势在哪?
说了这么多Yjs的问题,CRDT本身的理论优势还是存在的:真正的无主节点P2P编辑。
用OT方案,你必须有一个authority——不一定是云服务器,可以是你的笔记本电脑,但总得有一个”裁判”来做最终的冲突解决。
CRDT不需要。每个节点独立解决冲突,最终自动收敛。这对于完全去中心化的场景(比如local-first的笔记应用、没有服务器的P2P协作)是刚需。
但问题是:你的项目真的需要无主P2P吗?
绝大多数在线协同编辑场景都有服务器。Google Docs有服务器,Notion有服务器,飞书文档有服务器。既然有服务器,为什么要用一个复杂度高出几个量级的方案?
实际项目里的选型建议
基于这些信息,整理了一个选型对照:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 有后端的在线文档/编辑器 | prosemirror-collab | 简单、性能好、ProseMirror原生支持 |
| Tiptap项目(已绑定Yjs) | 观望或考虑迁移 | Tiptap和Yjs深度绑定,但问题真实存在 |
| 纯P2P、无服务器协作 | Yjs / Automerge | CRDT是唯一选择 |
| 代码编辑器协同 | OT或自研 | 代码编辑器结构简单,OT足够 |
| 简单的表单/表格协同 | WebSocket + 锁 | 杀鸡不用牛刀 |
prosemirror-collab实战:关键代码
核心逻辑真的不复杂。客户端这边:
import { collab, receiveTransaction, sendableSteps, getVersion } from 'prosemirror-collab'
// 初始化编辑器时加上collab插件
const plugins = [
collab({ version: initialVersion }),
// ...其他插件
]
// 发送本地修改
function sendSteps(state) {
const sendable = sendableSteps(state)
if (sendable) {
ws.send(JSON.stringify({
version: sendable.version,
steps: sendable.steps.map(s => s.toJSON()),
clientID: sendable.clientID
}))
}
}
// 接收远端修改
function receiveSteps(steps, clientIDs) {
const tr = receiveTransaction(
view.state,
steps.map(s => Step.fromJSON(schema, s)),
clientIDs
)
view.dispatch(tr)
}
服务端更简单:
class DocumentAuthority {
constructor(doc, steps = [], version = 0) {
this.doc = doc
this.steps = steps
this.version = version
}
receiveSteps(version, steps, clientID) {
if (version !== this.version) {
// 版本不匹配,客户端需要先拉取最新变更再rebase
return { status: 'rebase', steps: this.steps.slice(version) }
}
// 应用steps
let doc = this.doc
for (const step of steps) {
const result = step.apply(doc)
doc = result.doc
}
this.doc = doc
this.steps.push(...steps)
this.version += steps.length
// 广播给其他客户端
this.broadcast(steps, clientID)
return { status: 'ok' }
}
}
离线支持也不难——客户端把pending steps存起来,重连后走一遍rebase流程就行。这个过程prosemirror-collab已经帮你处理了。
离线冲突解决:CRDT也没什么魔法
有人会说”CRDT的优势是离线编辑后自动合并”。没错,但这里有个被严重忽视的事实:所有离线冲突解决算法的结果都差不多随机。
两个人同时离线编辑同一段文字,不管你用CRDT、OT还是其他算法,自动合并的结果都不可能完美。Moment.dev的Part 1文章已经做了详细论证——用户普遍认为CRDT的冲突解决结果是”静默损坏文档”。
既然结果差不多,那CRDT的离线优势就不成立了。至少OT方案简单、性能好、不会带来上面说的那堆问题。
Yjs团队的回应
公平起见,Yjs维护者正在处理y-prosemirror的这个架构问题。如果他们能解决全量替换的问题,让Yjs真正支持增量更新,那很多批评就不成立了。
但截至今天(2026年3月17日),这个问题还是open状态。如果你正在启动一个新的协同编辑项目,建议先不要把Yjs当成唯一选项。
我的判断
前端社区有一种”复杂度崇拜”倾向——觉得分布式算法、CRDT这些概念听起来高级,就一定比简单方案好。但工程上,能用简单方案解决的问题就不要上复杂方案。
prosemirror-collab几十行代码搞定的事,Yjs要引入一整套CRDT数据结构、序列化协议、绑定层,还附送一堆性能和兼容性问题。这不叫技术升级,这叫过度工程。
当然如果你真的在做local-first、完全P2P、无服务器的应用——Yjs和Automerge仍然是你的选择。只是对于99%有后端的项目,OT就够了。
参考链接: