跳转到内容

Yjs — 让任何编辑器都能接的协同编辑内核

是什么

Yjs 是一个编辑器无关的协同编辑库——你写的文本、列表、地图,多人同时改不会冲突,断网回来也能自动合并。日常类比:像一群人一起填同一张共享表格,每个人手上都有副本,回来对一下就自动同步好了,没人当裁判。

它的核心是一组”共享数据类型”:YText(文本)/ YArray(数组)/ YMap(键值映射)/ YXml(XML 树)。你像操作普通对象一样操作它们,Yjs 在背后把每次改动序列化成二进制 update,发给其他人。

import * as Y from 'yjs'
const doc = new Y.Doc()
const text = doc.getText('content')
text.insert(0, 'hello') // 你这边
// 别人那边同时插了别的——重连后自动合并不冲突

ProseMirror、CodeMirror、Lexical、TipTap、Quill 都能接同一套 Yjs,绑定胶水通常 ≤ 500 行。

为什么重要

不理解 Yjs 这类 CRDT 库,下面这些事都没法解释:

  • 为什么 Linear、Notion、JupyterLab 能做”多人同时编辑、不卡、断网也能写”——它们底下就是 Yjs 或同类
  • 为什么 Google Docs 当年用 OT(Operational Transform)那么难写,CRDT 出来后小团队都能做协同
  • 为什么 local-first 软件运动(Ink & Switch)反复推 CRDT——它是”先离线、再合并”的数学基础
  • 为什么协同编辑代码这么少出现冲突弹窗——CRDT 公理保证最终一致,根本没有”冲突”这个概念

核心要点

Yjs 的工作机制可以拆成 三步

  1. 每个改动有 Lamport ID:每个客户端有 clientID,本地操作计数 clock,合起来 (clientID, clock) 是全局唯一 ID。类比:每个人的便签有”姓名+第几张”,全世界不会重。

  2. 文档是一条双向链表:每个字符或元素是一个 Item 节点,带 left/right 指针 + origin/rightOrigin 历史锚点。concurrent 插入冲突时,YATA 算法按 (origin 邻居序 + clientID 仲裁) 决定谁排前面——所有人算出同样顺序。

  3. 传输是紧凑二进制:update 用 9 列 column-oriented 编码,分别压缩 client 码、clock、info bits、字符串、parent 信息等。比 JSON 小一个数量级,WebSocket / WebRTC / IndexedDB 都能直接收发。

三步加起来叫 YDoc 模型

实践案例

案例 1:10 行接好协同富文本

用 y-prosemirror 把 ProseMirror 接到 Yjs:

import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { ySyncPlugin, yCursorPlugin } from 'y-prosemirror'
const ydoc = new Y.Doc()
const provider = new WebsocketProvider('wss://demo', 'room-1', ydoc)
const yXml = ydoc.getXmlFragment('prosemirror')
new EditorView(dom, { state: EditorState.create({
schema, plugins: [ySyncPlugin(yXml), yCursorPlugin(provider.awareness)]
}) })

ProseMirror 不需要知道协同存在——ySyncPlugin 双向翻译 ProseMirror transaction 和 Yjs update。

案例 2:YArray 做实时白板图层列表

const layers = ydoc.getArray('layers')
layers.observe(event => {
event.changes.delta.forEach(d => {
if (d.insert) renderLayers(d.insert)
if (d.delete) removeLayers(d.delete)
})
})
layers.push([{ id: 'rect-1', x: 10, y: 20 }]) // 多人同时拖图层不抢锁

observe 拿到的 delta 已经是合并后的最终顺序——CRDT 保证所有客户端 delta 序列等价。

案例 3:YMap + IndexedDB 做 local-first 笔记

import { IndexeddbPersistence } from 'y-indexeddb'
const persistence = new IndexeddbPersistence('notes-db', ydoc)
const notes = ydoc.getMap('notes')
await persistence.whenSynced // 先从本地恢复
notes.set('note-1', { title: '...', body: '...' })
// 离线写、上线自动合并到服务端

y-indexeddb 把整个 YDoc 存浏览器,断网随便写,重连后跟服务端自动 diff 同步。

踩过的坑

  1. 改动必须包在 transact():单条调用没事,但同一 tick 多个改动如果不包,会发出多份 update 拖垮性能;用 ydoc.transact(() => { ... }) 把它们合成一份。

  2. 协同光标别存绝对索引:别人在你前面插了一行,你的 cursor: 5 就指错位置了。用 Y.RelativePosition 锚定到 Item,索引随别人编辑自动跟。

  3. update 是 Uint8Array,不是 JSON:发送时不能 JSON.stringify——会破坏二进制结构。WebSocket 走 binaryType: 'arraybuffer',HTTP 走 base64 或 multipart。

  4. 大量离线改动要切片:堆了几小时改动后单条 update 可能几 MB,WebSocket 单帧打爆。要么 Y.encodeStateAsUpdate(doc, stateVector) 增量发,要么手动按 length 切片。

适用 vs 不适用场景

适用

  • 多人协同富文本 / 代码编辑器(接 ProseMirror / CodeMirror / Lexical)
  • local-first app(先离线写,回来自动合并)
  • 实时白板 / 看板(图层、卡片这种”列表式”对象)
  • P2P 协作(y-webrtc 完全无中心服务器)

不适用

  • 强一致性事务(金融账本、库存扣减)→ 用数据库 + 锁
  • 需要”操作可审计 / 可撤销到任意点”且要数学证明 → 选 Automerge(论文派,op log 可追溯)
  • 极小内存设备(嵌入式 IoT)→ Yjs 的 Item 链表内存开销不低
  • 不需要协同的本地 app → 直接用普通对象,别引入 CRDT 复杂度

历史小故事(可跳过)

  • 2014 年:Kevin Jahns 在博士期间开始写 Yjs 原型,最初尝试 OT 派实现,发现 transform 函数难写到爆。
  • 2016 年:改用自己提的 YATA 算法(RGA 家族双向链表变体),成为 CRDT 派——concurrent insert 冲突有了简洁可证的解。
  • 2018 年:发布 v13,确立 YDoc + YType + Item + UpdateEncoder 的四层架构,性能拉到”编辑器无感”。
  • 2020 年起:被 Linear、JupyterLab、GitBook 等工业项目采用。
  • 现在:GitHub Sponsors 上活跃度最高的 CRDT 项目,Kevin 个人维护接近 9 年。

学到什么

  1. 协同不必绑死编辑器——把”共享状态”和”传输 update”切开,编辑器只写薄胶水
  2. CRDT ≠ OT:CRDT 让 concurrent op 有”天然全序”,不需要中央 transform;代价是数据结构复杂、内存占用高
  3. 算法选择决定性能:YATA 双向链表 vs flat ops vec(Automerge),同样满足公理但工程取舍完全不同
  4. 二进制编码很重要:9 列 column-oriented 比 JSON 小一个数量级,是 Yjs 能在生产用的关键

延伸阅读

关联

  • crdt-json —— 同样满足 CRDT 公理,但用 op log 而不是链表骨干
  • prosemirror —— Yjs 富文本的主力宿主,schema-first 让协同与编辑解耦
  • codemirror —— y-codemirror.next 把协同接进代码编辑器
  • lexical —— Meta 的新一代编辑器框架,也有 y-lexical 绑定
  • lamport-1978 —— Lamport 时钟是 Yjs Item ID 的理论基础
  • paxos-1998 —— 强一致协议的对比项;CRDT 选了”最终一致”而不是 Paxos 的强一致

反向链接

  • affine —— AFFiNE — 文档和白板共用同一棵 block 树的开源知识库
  • automerge —— Automerge — 让两份 JSON 自动合并的 CRDT 库
  • codemirror —— CodeMirror — 编辑器不是一个类,是一组扩展的合奏
  • collabora-online —— Collabora Online — 浏览器里直接编辑 Office 文档的开源后端
  • crdt-json —— CRDT JSON — 协同编辑 JSON 数据结构
  • excalidraw —— Excalidraw — 手绘风协作白板
  • hocuspocus —— Hocuspocus — 给 Yjs 配一个能直接上线的协作后端
  • lamport-1978 —— Lamport 1978 — 分布式系统里没有”绝对的同时”
  • liveblocks —— Liveblocks — 多人协作的托管基础设施
  • partykit —— PartyKit — Cloudflare Durable Objects 上的实时协作 framework
  • paxos-1998 —— Paxos 1998 — 古希腊议会寓言里藏的共识协议
  • pouchdb —— PouchDB — 浏览器里的 CouchDB
  • prosemirror —— ProseMirror — schema 先定 DOM 后服从的富文本编辑器框架
  • sharedb —— ShareDB — 基于 OT 的实时数据库