从0到1实现一个实时协作备忘录:基于CRDT的协同编辑实践

一、缘起:一个被忽视的协作场景

先问大家一个问题:当你需要和同事临时记录几个要点时,通常怎么做?

最常见的做法是打开微信拉群发公告,或者开个腾讯文档。但这两个方案都有痛点:

  • 微信消息很快被淹没

  • 腾讯文档等工具需要登录,很多人没有账号或懒得登录

于是我做了一个小工具——CollabNote

地址CollabNote

一句话总结:一个无需登录、打开即用的实时协作备忘录

使用时只需三步:

  1. 打开页面,创建备忘录

  2. 点击「复制」获取分享链接

  3. 把链接发给任何人,所有人打开后内容实时同步

不需要注册,不需要登录,不需要任何前置操作。

二、技术选型:为什么是CRDT?

实时协作的核心问题是冲突处理。当两个人同时编辑同一份文档,A删除了第2行,B在第3行加了几个字——如果只是简单按时间顺序覆盖,结果一定是错的。

业内主要有两种方案:

对比维度OT(操作转换)CRDT(无冲突复制数据类型)
代表应用Google DocsFigma、Linear
核心思路服务端转换冲突操作数据结构层面保证一致性
实现复杂度复杂,不同数据类型需不同逻辑相对模块化
离线支持较难天然支持
服务端依赖强依赖中心服务器支持P2P/客户端-服务器

OT 的典型代表是 Google Docs,但实现非常复杂。不同数据类型需要不同的转换函数,且强依赖服务端进行冲突合并。

CRDT 的代表是 Yjs。其核心思想是:每个字符或元素都有唯一ID,删除用"墓碑标记"而非真正移除,保证插入位置的相对性始终可追溯。无论操作以什么顺序到达,最终结果一致。

CollabNote 选择了 CRDT(Yjs)方案

为什么?

  1. 实现更清晰:Yjs 的 API 设计直观,社区活跃,有丰富的编辑器集成

  2. 服务端轻量:服务端只需转发和持久化操作,冲突合并完全在客户端完成

  3. 扩展性好:后续可以轻松支持离线编辑等高级特性

三、技术架构

CollabNote 的技术栈:

text

前端:React + TypeScript + Vite
编辑器:TipTap(基于 ProseMirror)
协同引擎:Yjs(CRDT)
通信协议:WebSocket(y-websocket)

架构图(简化版):

text

┌─────────┐      WebSocket      ┌─────────┐
│ 用户 A   │ ──── 操作更新 ────→ │         │
└─────────┘                     │ 服务端   │
                                │ (转发/   │
┌─────────┐                     │ 持久化)  │
│ 用户 B   │ ←─── 广播更新 ──── │         │
└─────────┘                     └─────────┘

四、核心实现

4.1 Yjs + TipTap 集成

Yjs 官方提供了与 ProseMirror(TipTap 底层)的集成方案,核心代码非常精简:

typescript

import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { ySyncPlugin, yCursorPlugin } from 'y-prosemirror'

// 1. 创建 Yjs 文档(CRDT 容器)
const ydoc = new Y.Doc()

// 2. 连接协同服务器
const provider = new WebsocketProvider(
  'wss://your-server.com/collab',
  'room-id-001',
  ydoc
)

// 3. 获取共享数据结构
const yXmlFragment = ydoc.getXmlFragment('prosemirror')

// 4. 注入到 TipTap 编辑器
const editor = new Editor({
  extensions: [
    // ... 其他扩展
    YjsExtension.configure({
      yDoc: ydoc,
      yXmlFragment: yXmlFragment,
      provider: provider,
    }),
  ],
})
4.2 自动保存机制

自动保存采用 500ms 防抖 策略:

typescript

// 500ms 防抖保存
const debouncedSave = useCallback(
  debounce((content: string) => {
    saveToServer(content)
  }, 500),
  []
)

// 监听内容变化
editor.on('update', ({ editor }) => {
  const content = editor.getHTML()
  debouncedSave(content)
})

500ms 是一个权衡值——太短服务端压力大,太长用户感知延迟。

同时保留 Ctrl+S 手动保存,满足部分用户习惯。

4.3 Awareness:光标与在线状态

Awareness 是协同系统中非持久化的临时状态,包括光标位置、选区、用户在线状态等。

typescript

// 设置用户信息
provider.awareness.setLocalState({
  user: {
    name: '用户A',
    color: '#ff6b6b',
  },
  cursor: { from: 10, to: 15 },
})

// 监听其他用户状态变化
provider.awareness.on('change', (changes) => {
  // 更新界面上的光标显示
  updateCursors(provider.awareness.getStates())
})

Awareness 与文档内容走不同的通道:

  • 文档内容:需要持久化、强一致性,通过 CRDT 同步

  • Awareness:不需要持久化、最终一致即可,通过独立广播通道传输

五、踩坑与经验

5.1 光标位置保持

当远程用户的更新到来时,如果直接替换编辑器内容,会导致当前用户的光标跳转。

解决方案:在应用远程变更前保存光标位置,更新完成后恢复。

TipTap 的 setContent(content, false) 中 false 参数用于阻止触发新的事件,配合光标位置保存基本解决问题。

5.2 删除确认

「清空」和「删除」两个操作都需要二次确认。原因很简单——协作场景中别人可能在编辑,你直接清空内容会严重影响体验。

5.3 关于"无需登录"的权衡

无需登录是 CollabNote 的核心体验优势,但也带来一些代价:

  • 没有用户身份,无法做权限控制和历史追溯

  • 数据安全依赖用户自觉(不建议记录敏感信息)

对于轻量级临时协作这个定位,这些权衡是可接受的。

六、后续计划

  1. 更丰富的文本格式:目前是纯文本,后续支持 Markdown 或更富的格式

  2. 历史版本回滚:基于操作日志重放实现

  3. 移动端适配:完善移动端体验

  4. 导出功能:支持导出为 Markdown、TXT

七、写在最后

CollabNote 的初衷很简单:做一个简单到不用思考的协作工具

不需要登录,不需要学习,打开就能用。它不会成为下一个 Notion,但它填补了"临时会议记录、头脑风暴、快速分享"这个场景的空缺。

如果你有类似的需求,欢迎试试。如果你对技术实现感兴趣,欢迎交流讨论。

项目地址CollabNote


欢迎在评论区留言交流,觉得有用的话点个赞支持一下 😊

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值