资讯动态

Yjs与CRDT:前端实时协作的无冲突同步实践

发布时间:2026/10/9 18:48:29 来源:尧图企业网站定制
最近在调研前端实时协作方案把几个主流库翻了个遍最后注意力还是落在了 Yjs 这个前端实时协作库上。它最狠的地方不是把“多人同时编辑”做出来而是底层用 CRDT无冲突复制数据类型把并发冲突消解在了数据结构层面不需要中心服务器仲裁离线、弱网、多人乱序同步都能收敛到一致状态。这篇笔记是我从选型到落地一个协作白板项目的完整复盘会从“为什么必须用 CRDT”讲起再拆 Yjs 的核心模块、踩坑记录、性能优化和常见问题排查适合正在选型协同编辑方案、或者想快速上手 Yjs 但不想只停留在 demo 阶段的前端开发同学。1. 为什么实时协作库普遍绕不开 Yjs 这类 CRDT 方案1.1 传统 WebSocket OT 方案的痛点早些年做实时协作最通用的思路是“WebSocket 推送 操作转换OT”。服务端维护一份权威文档每个客户端把操作比如在第 3 个字符后面插入一个“a”发给服务端服务端按顺序应用操作然后广播给其他人。听起来挺顺但一旦两个人同时改同一个位置就会出岔子先到服务端的操作会把后到的操作“顶”得错位所以 OT 需要一套复杂的转换函数把每个操作在别人的操作序列上做偏移换算。这类算法在单一文本编辑、且节点在同一个房间里的场景下是可以工作的比如早期 Google Docs 的雏形就是 OT 思路。但为什么实际做的时候特别痛苦因为 OT 非常吃“顺序”。客户端收到的每个操作都依赖前一个操作的位置上下文A 和 B 离线改了同一份文档他们各自本地都保留自己的操作序列等联网合并时服务端必须做一次“操作历史重放”才能让双方收敛网络稍微乱序、断点、多端同时开多个页面出问题的概率指数上升。协作人数一多操作序列拉得很长OT 的状态空间也越来越难维护。我见过不少团队拿 OT 做简单文档到了多文档、富文本、嵌套列表、多端离线这种需求直接卡在算法调整上几个月推不动。1.2 Yjs 走 CRDT 路线的设计取舍Yjs 换了一条路不追求“顺序”而是追求“状态收敛”。每个字符、每个节点、每个 Map 条目在创建时都有一个全局唯一的 ID由客户端 ID 时钟计数组成。多人编辑时每个节点把自己本地的新增、变更以增量更新update的形式广播出去另一端收到后做合并。本质上CRDT 把冲突消解写成了一套纯代数规则——不依赖到达顺序不依赖中心化时间戳只要双方最终交换了同样的更新集合最终状态一定一致。这套思路的工程价值非常明显Yjs 天然支持离线编辑。客户端断网后本地照常改重连后把积压的 update 捅给对方对方合并完又把自己的 update 回传两边就自动同步不需要服务端做复杂的 diff 重放。也正因如此Yjs 特别适合做前端实时协作库——它把一致性压力从服务端卸载到了客户端的数据类型层服务端只需要转发二进制更新甚至可以做成无状态的 WebSocket relay。当然CRDT 也不是没有代价每个操作都要携带元信息数据体积比纯操作日志大一些底层实现也比普通 JSON 复杂直接读写普通 Object 是行不通的必须用 Yjs 提供的数据类型访问。前期需要一点学习成本但对“多人实时协作”这个需求来说这个取舍非常值。我自己在选型时对比过几个 CRDT 实现Yjs 的生态最完善、文档最全、对编辑器ProseMirror、CodeMirror、Monaco的绑定也现成所以最终选了它。2. Yjs 的核心模块与数据模型2.1 Doc、Y.Map、Y.Array、Y.Text——用起来像 JSON 但不是 JSONYjs 里最基础的概念是Y.Doc它是一个独立的文档容器承载所有共享数据。多人协作时每个客户端自己new Y.Doc()通过 Provider 交换 updateDoc 内部自动合并。你可以把Y.Doc类比成一个“内存里的共享数据库”所有读写都走它的 API。常用的数据类型有四种Y.Map对应普通对象{}适合存结构化配置信息比如文档标题、成员列表、光标位置等。Y.Array对应数组适合存有序列表比如事项清单、评论列表。Y.Text对应一段富文本/纯文本是协同编辑的核心内部按字符/片段拆成带 ID 的节点天然解决多人同时插入同一位置的问题。Y.XmlFragment/Y.XmlElement/Y.XmlText对应 XML 结构适合做富文本编辑器底层的结构化数据。实际操作上它们的使用风格接近Map/Array用get()读、set()写、insert()插入、delete()删除。但要注意它们不是原生对象不能直接JSON.stringify(doc)想拿到普通对象要自己遍历导出或者依赖 Yjs 提供的toJSON()方法。我在做白板项目时把每个图形节点放在Y.Map里节点的坐标、颜色、文本都存字段操作起来非常直观。2.2 Awareness 与用户状态多人协作除了要同步文档数据还得同步“谁在线”“光标在哪”“选中的是哪段文字”这类轻量状态。Yjs 专门为这种场景设计了Awareness协议它同样走底层通信通道但语义上不同于持久化的文档数据——它是临时性的用户断开后状态会被清掉。典型用法是先定义自己的状态结构const awareness doc.awareness; awareness.setLocalStateField(user, { name: 小明, color: #ff6600 }); // 监听别的用户上线 / 离线和状态变化 awareness.on(change, (changes) { const states awareness.getStates(); states.forEach((state, clientId) { if (clientId ! doc.clientID) { // 渲染其他用户的光标、选区或在线状态 } }); });这里有个经验Awareness状态非常容易丢因为它本质是“发送即忘”的。如果业务上需要展示“最后编辑时间”“用户昵称列表”最好把关键信息同步落到Y.Map里持久化Awareness只用来做临场感知。我在最初版本里把用户头像颜色直接放在 Awareness 里刷新页面就没了调试了很久才意识到设计分层有问题。2.3 UndoManager 与事件监听Yjs 内置了UndoManager这是很多自研协作方案容易忽略的功能。它允许对某个Y.Map/Y.Text/Y.Array范围内的操作做撤销/重做并且是跨客户端感知的A 撤销自己的操作不会撤掉 B 刚做的修改。使用起来也很简单const undoManager new Y.UndoManager(yText); // 绑定到编辑器的撤销快捷键 editor.on(undo, () undoManager.undo()); editor.on(redo, () undoManager.redo());但要小心UndoManager默认跟踪所有变化如果数据结构复杂、事件触发频繁会吃内存。我会给Y.Doc上的observeDeep挂一个轻量回调只记录变化计数器不把整棵快照放进闭包避免老引用泄漏。监听数据变化用observe/observeDeepyMap.observe((event) { console.log(变更的 key:, event.keysChanged); }); doc.observeDeep((events) { // 监听所有子属性变化适合把变化 debounce 后同步到远端 });observe和observeDeep的区别在于前者只监听某个类型的直接变更后者能感知嵌套子数据的变化。实际开发中我基本都用observeDeep做统一同步触发再在业务层筛选感兴趣的变更类型。3. 从零开始搭建一个实时协作的实践流程3.1 技术选型与依赖清单上手 Yjs 最省力的路径是直接用官方生态包下面是常用依赖及适用场景的一览依赖作用适用场景yjs核心库提供Y.Doc与全部数据类型必装所有项目都绕不开y-websocket最常用的 WebSocket Provider提供房间管理与广播小中型协作应用想快速自建服务器时首选y-indexeddb基于 IndexedDB 的本地持久化方案需要离线可用、刷新不丢数据时使用y-prosemirrorYjs 与 ProseMirror 的绑定层富文本编辑器场景y-codemirror.nextYjs 与 CodeMirror 6 的绑定层代码 / Markdown 编辑场景y-monacoYjs 与 Monaco Editor 的绑定层需要类 VSCode 编辑体验时使用选型时有个决策点要不要自己写 Provider。如果只是内部工具、几十个人同时编辑y-websocket足够了它内置的心跳、重连、房间同步逻辑能省下大量工程时间。但如果你要做生产级 SaaS 产品建议参考y-websocket的协议用自己后端实现一个带鉴权的 relay因为原生y-websocket是明文转发所有更新没有用户体系。3.2 初始化 Doc 与连接 Provider第一步是创建共享文档并接上 WebSocket Provider。以y-websocket为例import * as Y from yjs; import { WebsocketProvider } from y-websocket; // 每个房间对应一个唯一 room 名所有人都连同一个 room 即可同步 const doc new Y.Doc(); const wsProvider new WebsocketProvider( ws://localhost:8080, // Yjs WebSocket 服务端地址 my-room-id, doc, { connect: false } // 先不自动连接等业务数据准备好再连 ); // 手动建立连接给渲染层一点准备时间 wsProvider.connect(); // 监听同步状态 wsProvider.on(sync, (isSynced) { if (isSynced) { console.log(已与服务端同步完成); } });服务端部分用官方提供的y-websocket服务器包可以几分钟跑起来npm install -g y-websocket # 默认监听 1234 端口可通过环境变量改动 HOST0.0.0.0 PORT8080 y-websocket不过真实项目中通常需要把房间与鉴权接进现有服务。常见做法是WebSocket 连接时在 query 里带上 token服务端校验通过后才允许进入房间Yjs 的更新包体本身不加密敏感业务不要直接把明文内容塞进Y.Text要过业务层加密或脱敏。这也是我在做安全评估时特别提醒团队的。3.3 绑定编辑器与多端同步接着把Y.Text绑到编辑器上。以 CodeMirror 6 为例用官方绑定y-codemirror.next代码量很小import { CodeMirrorBinding } from y-codemirror; import { EditorState } from codemirror/state; import { EditorView, basicSetup } from codemirror; const ytext doc.getText(editor-content); const view new EditorView({ parent: document.getElementById(editor), state: EditorState.create({ doc: ytext.toString(), extensions: [ basicSetup, yCollab(ytext, wsProvider.awareness) ] }) });yCollab内部会自动把编辑器的每次变更映射成对Y.Text的插入/删除操作并把远端更新应用到编辑器上。这里有个看起来很简单但坑了不少人的点初始化编辑器内容不要直接从view写入一定要通过Y.Text的insert()方法。如果先调用view.dispatch写内容绑定层可能把本地写入再当一次变更回传造成重复插入或内容抖动。正确做法是先在Y.Text里写入初始内容再接编辑器。富文本编辑器的绑定思路类似用y-prosemirrorimport { ySyncPlugin, yCursorPlugin, yUndoPlugin } from y-prosemirror; import { EditorState } from prosemirror-state; import { schema } from ./schema; const yXmlFragment doc.getXmlFragment(prosemirror-content); const state EditorState.create({ schema, plugins: [ ySyncPlugin(yXmlFragment), yCursorPlugin(wsProvider.awareness), yUndoPlugin() ] });ProseMirror 绑定会自动处理节点映射比手写同步安全得多。我后来给团队的要求是能用官方绑定就不要自己写绑定层对 DOM 的 diff 和批量插入做了很多细节优化自己写很容易漏掉。3.4 多端同步的配置与经验多端同步不是把connect打开就完事有几个配置和顺序很关键同步时机我建议先把本地持久化数据恢复到Y.Doc再连接 Provider。否则会出现“先连上拿到远端空文档再写入本地数据”的两个状态互相覆盖导致本地数据丢失。房间隔离不同业务线用不同的 room name不要在同一个Y.Doc里塞多个项目的全部数据。我之前用过一个全局大 Doc所有房间的数据都塞进去结果observeDeep每次都被无关变更打醒性能退化特别明显。服务端存储策略y-websocket默认不持久化服务端只是内存中转。如果不做持久化所有用户刷新后会回到空文档。生产环境至少要做一个“服务端定期把 doc 编码成 update 存到数据库”的逻辑或者用官方推荐的 y-leveldb / y-redis 持久化方案。持久化这一块最简单的起步方式是结合y-indexeddb客户端本地每次变更后自动存 IndexedDB刷新后先读本地再和服务端合并。做法如下import { IndexeddbPersistence } from y-indexeddb; const doc new Y.Doc(); const indexeddbProvider new IndexeddbPersistence(my-doc, doc); indexeddbProvider.on(synced, () { // 本地数据加载完成后再建立 WebSocket 连接 wsProvider.connect(); });这个模式在弱网、断线场景下体验提升非常大断网时用户照常编辑重连后 Yjs 会把本地积压更新和远端状态合并最终双方都收敛。4. 实操中的性能优化与安全把控4.1 数据同步与带宽优化Yjs 内部以二进制 update 为单位进行同步一个 update 里可能包含多条变更。但在高频输入场景如果把每一次输入都立即发送会产生大量小包WebSocket 带宽被元信息吃掉。官方提供了一些底层方法但工程上更简单的做法是控制 Provider 的发送频率。对于y-websocket默认是实时发送我一般会在业务层加一个 debouncelet timer null; doc.observeDeep(() { clearTimeout(timer); timer setTimeout(() { // 手动触发一次同步消息 const update Y.encodeStateAsUpdate(doc); wsProvider.send(update); }, 50); });注意这么做要确保你真的知道 Provider 是否已经自动发送更新否则可能重复发送。如果你用的是底层Y.doc.on(update)要自己负责发送那加 debounce 是合理的。如果用官方 Provider最好先阅读源码确认有没有队列合并逻辑不要盲目加。我在某个版本里同时用 Provider 自动同步和手动encodeStateAsUpdate结果相同变更被广播了两遍虽然 CRDT 幂等合并不会造成数据错误但带宽浪费很明显。4.2 断线重连与离线编辑Yjs 一个很大的卖点是离线编辑。把y-indexeddb接上之后哪怕服务端挂掉一小时用户本地也能继续编辑重连后通过 merge 恢复一致。但有个前提你要理解“离线编辑 ≠ 版本管理”。CRDT 只保证最终一致不保证你在冲突时能拿到“A 的版本”或“B 的版本”作为整包选择。要支持分支合并、版本回滚还需要在业务层记录结构化的变更历史而不是把所有逻辑都压给 Yjs。实际操作中我建议给每个用户生成一个稳定 ID并且在联合文档里用Y.Map存一份用户维度的分片const doc new Y.Doc(); const perUserData doc.getMap(users).get(userId); if (!perUserData) { doc.getMap(users).set(userId, new Y.Map()); }这样即使两个用户离线编辑同一个区域也能按用户维度做数据归属分析。不过要注意如果业务需要“某个用户改完立刻被另一个用户看到”Yjs 的最终一致模型相对够用但如果要做“审批后合入”这种强流程就要在设计上预先加状态机不能指望 CRDT 帮你做业务锁。4.3 协作应用的安全边界避免把敏感信息明文放进Y.Text。前端的协作更新是全网广播的任何能接入同一个 room 的客户端都能拿到全量数据这不只是后端权限的问题更是协议本身的设计。因此WebSocket 服务端一定要做房间校验和用户鉴权数据校验字段类型、长度、格式必须在服务端再做一遍不能依赖前端过滤。Yjs 的 update 本身是二进制格式普通用户不会直接读但抓包一样能看到内容敏感场景要引入 TLS 和业务层加密。不要在前端直接展示“其他人正在输入”就认为是安全的Awareness 同样携带用户信息服务端要及时清理过期状态。这些点看起来和 CRDT 不直接相关但做实时协作工具一旦上线这就是事故高发区。我们初期只做了简单的 token 鉴权后来压测时发现只要拿到 WebSocket 地址就能直接连入任意房间所有 room 的数据全裸奔后面把每间房的密钥换成了由服务端动态签发的短期凭证才把风险按下去。5. 常见问题与排查技巧实录5.1 典型报错和排查方法速查这里把我在实际开发中遇到的高频问题整理成了表供遇到类似情况的人快速定位现象可能原因排查与解决页面刷新后内容丢失Provider 未持久化服务端重启后内存清空服务端接持久化方案如 y-leveldb 或 y-redis客户端配合y-indexeddb先恢复本地再连接远端多人同时输入同一处文档出现重复字符编辑器绑定层和Y.Text初始化序冲突先写Y.Text加载完成后再初始化编辑器检查编辑器是否直接 dispatch 了本地内容同步时断时续偶现数据不一致WebSocket 连接不稳定重连逻辑没有等待上次连接释放正确调用wsProvider.destroy()/disconnect()避免多次创建 Provider 残留旧连接Awareness 用户列表一直消失Awareness 是临时状态连接断开即清理业务上展示用户列表时同时把用户注册信息写入Y.Map用 Awareness 仅标记“在线”撤销把别人刚写的内容也撤掉了UndoManager 捕获范围过大或捕获了远端更新检查是否对远端更新也注册了 UndoManager 捕获必要时在捕获回调中过滤event.transaction.origin内存只增不减observe 回调持有大量闭包或者 Doc 未被正确销毁使用事件监听时提供解绑函数在页面卸载时调用provider.destroy()、doc.destroy()5.2 调试 Yjs 数据的小技巧调试 Yjs 最有效的工具是看底层 update 和 doc 状态。官方提供了Y.encodeStateAsUpdate和Y.applyUpdate可以做序列化快照和恢复const update Y.encodeStateAsUpdate(doc); localStorage.setItem(doc-snapshot, update.toString(base64)); const saved localStorage.getItem(doc-snapshot); const restoredDoc new Y.Doc(); Y.applyUpdate(restoredDoc, Uint8Array.from(atob(saved), c c.charCodeAt(0)));配合浏览器 Network 面板看 WebSocket 帧可以比较直观地确认同步是否正常。遇到数据错乱时我还会把两个客户的端 doc 分别序列化成 JSON 再做字段对比const json doc.toJSON(); console.log(JSON.stringify(json, null, 2));toJSON()只能看到业务数据看不到内部 ID但对排查“哪块字段没有同步”“重复插入从哪里来”已经够用了。要深入到底层 id 对应关系则需要用doc.getText().toDelta()或doc.getArray().toArray()手工检查。另外调试 Yjs 时有一个老坑不要在模块顶层创建Y.Doc作为全局单例。多个页面 / 多个编辑器实例共用一个 Doc会导致状态串线、observe 事件爆发。正确做法是每个编辑器实例、每个房间独立创建 Doc并在销毁时完整释放。5.3 最后分享一个调试阶段非常加分的小技巧如果你用y-websocket可以在服务端加一行直接打印每次收到的 update 长度并对连接数做监控。官方y-websocket只是最小实现我建议在上线前做一个透传日志中间件# 用环境变量控制是否需要打印每个房间的消息统计 DEBUG_ROOM_STATS1 HOST0.0.0.0 PORT8080 y-websocket把每个房间的“在线人数”“每秒消息数”“update 平均字节数”打印出来能很直观地发现某张表是否被高频改动拖累。我们上线前发现一个项目文档房间在大量用户同时拖拽图形时每秒产生了几百个一百多字节的小 update马上把图形拖拽的同步策略改成“停止拖拽后统一提交”WebSocket 压力立刻降了一个数量级。性能问题往往不是 Yjs 本身慢而是业务把太多中间状态塞进同步通道了。最后再分享一点我个人的体会学习 Yjs千万不要只看官方 README 里的“hello world”就以为理解了。真正做进生产项目时你会遇到持久化、鉴权、事件生命周期、带宽控制这些和 CRDT 核心无关但决定项目成败的工程问题。把这几种场景一个个吃透这个前端实时协作库才真正算在你的技术栈里落地了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑