协同编辑示例
基于 CRDT(冲突自由复制数据类型)技术和 WebSocket 实现实时多人协同编辑,支持多用户同时编辑同一文档而无需中央服务器协调。
核心特性
- 增量同步:使用 Loro 的结构化 CRDT 容器,每次操作仅同步变更部分
- 实时同步:基于 Loro CRDT 库实现的强一致性数据同步
- 离线支持:用户离线时可继续编辑,重新连接后自动同步
- 冲突解决:CRDT 算法自动解决并发编辑冲突
- 房间隔离:支持多个独立的协同编辑会话
- Peer 管理:支持实时显示在线用户列表
- 自动重试:连接失败时自动进行指数退避重试
增量同步架构
协同编辑插件将 Schema 结构映射到 Loro 的扁平化键值存储,利用操作级钩子实现真正的细粒度同步:
扁平化设计理由:Loro 的 getOrCreateContainer() 方法在嵌套使用时会返回 JsValue 容器引用而非普通对象,导致无法调用方法。扁平化键值存储避免了这个问题,所有值都直接存储在根 Map 中。
操作级钩子实现增量同步
插件系统的 Schema 钩子接收操作级详细信息而非仅最终 schema,使插件能够精确执行增量更新:
优势:
- 每次操作(insert/move/remove/updateProps)只更新对应的 Map/List 容器
- 避免全量序列化和差异计算的性能开销
- Loro 自动计算最小化的增量更新补丁(通常只有几十字节)
- 大幅减少网络传输和处理开销,支持高频编辑场景
操作详情类型:
快速开始
安装依赖
协同编辑插件已内置 Loro CRDT 库,只需安装 Socket.IO 客户端:
启动协同编辑
配置选项
CollaborationOptions
插件 API
协同编辑插件在 EditorProvider 的上下文中提供以下扩展方法:
连接管理
增量更新方法
插件导出了细粒度的增量更新方法,允许直接操作 Loro 容器:
默认情况下,插件会在 onAfterInsert、onAfterMove 等钩子中自动同步。
增量更新方法适用于需要精确控制同步时机和粒度的高级场景。
信息查询
后端部署
协同编辑需要后端服务处理房间管理和更新广播。
启动服务器
环境配置
服务器 API
后端服务提供以下 REST 端点:
- GET /health - 健康检查,返回在线房间数
- GET /rooms - 列出所有房间及其连接数
Socket.IO 事件
客户端发送
服务器广播
工作流程
初始化连接
- 用户加载编辑器时,插件初始化 Loro 文档
- 如果启用自动连接,立即连接到协同服务器
- 建立 Socket 连接并发送
join-room事件 - 服务器返回
room-joined事件,包含在线用户列表 - 客户端发送
sync-request请求文档状态 - 服务器返回
sync-response,包含文档快照或增量
本地编辑同步
- 用户在编辑器中进行操作(插入、移动、删除、更新属性)
- SchemaUtils 方法返回
{ schema, operation }对象 - 插件的
onAfterInsert/Move/Remove/UpdateProps钩子接收 operation 参数 - 增量更新:根据 operation 详情直接更新 Loro 扁平化键(而非全量序列化)
- Insert: 设置
element_{id}_*键,更新elementIds和structure_{parentId} - Move: 从旧父的
structure_{oldParentId}删除,在新父的structure_{newParentId}插入 - Remove: 删除
element_{id}_*键,从elementIds和structure_{parentId}删除 - UpdateProps: 仅更新
element_{id}_props
- Insert: 设置
- 调用
loroDoc.commit()提交变更 - Loro 订阅回调触发,检测到
event.by === 'local' - Loro 文档自动导出为最小化的二进制 update(通常几十字节)
- Update 通过
update事件发送到服务器 - 服务器接收 update,导入到房间的 Loro 文档
- 服务器广播
remote-update给其他在线客户端 - 其他客户端导入 update 并更新本地编辑器 schema
远程更新接收
- 客户端接收服务器的
remote-update事件 - 验证消息:检查 peerId 格式、roomId 匹配、update 数据有效性
- 提取更新数据并导入到本地 Loro 文档:
loroDoc.import(updateBytes) - Loro 订阅回调触发,检测到
event.by === 'import' - 从扁平化键重建完整 schema:
- 读取
elementIds获取所有元素ID - 对每个ID读取
element_{id}_type、element_{id}_props、element_{id}_hidden - 读取所有
structure_*键重建父子关系
- 读取
- 调用
ctx.setSchema(schema)更新编辑器 - 编辑器 UI 自动重新渲染,展示其他用户的编辑结果
错误恢复
- 连接失败:自动触发指数退避重试(1s → 2s → 4s → 8s → 16s)
- 同步失败:重新请求完整快照
- 服务器断开:自动重连并重新同步
- 消息验证失败:忽略无效消息并记录警告
高级用法
自定义 Peer ID
默认情况下,插件会随机生成 53 位整数作为 peerId。可以传入自定义值:
延迟连接
如果需要稍后手动连接(例如等待用户授权):
自定义重试策略
监听连接事件
在 onInit 或其他生命周期中监听连接状态变化:
使用场景
适用于需要多人实时协作的场景,如:
- 👥 团队协作编辑页面布局
- 📝 共享文档协编
- 🎨 设计稿协同修改
- ⚙️ 流程编排多人参与
最佳实践
推荐做法
- 使用 UUID 或业务用户 ID 作为 peerId,便于用户识别
- 在关键操作前检查
isConnected()状态 - 配置合理的重试策略,避免无限重试消耗资源
- 在生产环境部署多个协同服务器实例,使用负载均衡
- 定期持久化文档状态,防止服务器重启数据丢失
注意事项
- 持久化:当前服务器仅将文档存储在内存中,建议集成 Redis/数据库
- 认证:生产环境需添加房间访问权限控制
- 扩展性:在线人数过多时,考虑实现更新订阅过滤(而非广播所有更新)
- 离线同步:用户长时间离线后重新连接,可能有大量待同步更新
- 消息验证:插件已内置 peerId、roomId 和数据格式验证
故障排除
连接失败
症状:isConnected() 始终返回 false
排查步骤:
- 确认服务器正在运行:
GET http://localhost:3001/health - 检查
serverUrl是否正确 - 检查浏览器控制台是否有 CORS 错误
- 验证防火墙是否阻止 WebSocket 连接
- 查看是否超过最大重试次数(默认 5 次)
数据不同步
症状:其他用户的编辑在本地看不到
排查步骤:
- 检查
isConnected()是否为 true - 查看浏览器网络标签,确认 Socket.IO 消息是否正常发送/接收
- 检查事件名称:客户端监听
remote-update,服务器广播remote-update - 检查浏览器控制台中的 Loro 订阅日志:
- 本地操作应显示
event.by === 'local' - 远程更新应显示
event.by === 'import'
- 本地操作应显示
- 检查服务器日志中是否有 import 错误
- 验证所有客户端连接的是同一个
roomId - 尝试手动调用
disconnect()后重新connect()
常见问题:
- 事件名不匹配:确保客户端监听
remote-update而非update - Loro事件判断错误:检查是否使用
event.by而非event.origin或event.local - 初始同步标志:确保
isInitialSync在同步完成后设为false
频繁重连
症状:连接不稳定,频繁断开重连
排查步骤:
- 检查网络稳定性
- 增加
maxRetries和retryDelay配置 - 检查服务器是否有资源限制(连接数、内存)
- 查看服务器日志是否有异常断开记录
多个用户同时编辑时出现冲突
说明:这是正常行为,Loro CRDT 会自动解决冲突。每个用户都会看到一致的最终状态。
如果看到不同结果,检查:
- Schema 序列化/反序列化逻辑是否正确
- Loro 文档的导入/导出操作是否有异常
- 服务器是否正确广播所有更新
示例项目
完整的协同编辑示例源码:
- 演练场地址:https://keiseiti.github.io/tangramino/playground/collab
- 前端源码:playground/antd-demo
- 后端源码:playground/collab-server
