前端 · 2026年3月17日

协同编辑别再无脑选Yjs了:40行代码就能替代CRDT的方案

做过在线文档或者协同编辑功能的前端都知道,技术选型第一步就是”用什么算法”。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就够了。

参考链接: