跳到主要内容

Tiptap / ProseMirror 协作问题

🧭 核心结论:Tiptap 的编辑状态本质上是符合 Schema 的 ProseMirror 不可变节点树;接入协作后,Y.Doc 中的 Y.XmlFragment 才是共享事实来源。y-prosemirror / Tiptap Collaboration 负责两者之间的双向映射,业务代码不要同时维护 JSON、HTML 和 Yjs 三套“真相”。

一、先建立正确的心智模型

用户输入 / 命令

ProseMirror Transaction(step、selection、meta)

EditorState.doc(不可变 Node 树)
↕ y-prosemirror / Collaboration extension
Y.Doc → Y.XmlFragment(CRDT 共享状态)

Provider(同步、Awareness、重连)

Hocuspocus / Liveblocks / Y-Sweet / 自建服务

这几层解决的问题不同:

  1. Tiptap:对 ProseMirror 的扩展、命令和 UI 开发体验进行封装。
  2. ProseMirror:定义 Schema、节点树、Selection、Transaction 与 View。
  3. Yjs:合并并发修改、支持离线编辑;它不负责编辑器 UI,也不绑定传输协议。
  4. Provider:传输 Yjs update 和 Awareness,处理连接与重连。
  5. 协作服务端:鉴权、持久化、扩容、WebSocket 生命周期、Webhook 与可观测性。

二、Tiptap 的本质数据结构

Tiptap 文档不是 HTML,也不是 React/Vue 组件,而是一棵 ProseMirror Node 树editor.getJSON() 只是这棵树的可序列化表示:

{
"type": "doc",
"content": [
{
"type": "paragraph",
"attrs": { "textAlign": "center" },
"content": [
{ "type": "text", "text": "Hello, " },
{
"type": "text",
"text": "world",
"marks": [{ "type": "bold" }]
}
]
}
]
}
  • Nodetype + attrs + content + marks/text
  • Schema:规定哪些节点和 Mark 合法、节点允许包含什么内容。
  • EditorState:包含当前 docselection、stored marks 和插件状态。
  • Transaction:由一组 Step 组成,把旧 State 变成新 State;位置通过 mapping 映射。
  • EditorView / NodeView:把 State 投影到 DOM。DOM 不是数据源。

HTML 适合渲染和导入导出,JSON 适合 API、搜索、快照与调试;协作运行时应以 Y.Doc 的二进制更新/状态 为准。

三、Yjs 如何处理 Tiptap 节点

Tiptap 的 Collaboration 扩展建立在 y-prosemirror 上,把 ProseMirror 文档绑定到一个 Y.XmlFragment

import * as Y from 'yjs'
import Collaboration from '@tiptap/extension-collaboration'

const ydoc = new Y.Doc()

const editor = new Editor({
extensions: [
StarterKit.configure({ undoRedo: false }),
Collaboration.configure({
document: ydoc,
field: 'default',
}),
],
})

概念上的映射是:

ProseMirrorYjs 中的表示
doc 的内容Y.XmlFragment
块级/行内节点XML element / fragment 结构
文本节点Y.XmlText
MarkY.XmlText 的格式属性
Node attrs元素属性/映射数据
光标、选区、用户信息Awareness;不写入持久文档

这不是简单地“把 Tiptap JSON 塞进 Y.Map”。绑定层会把编辑事务转换为细粒度 CRDT 变更,再把远端更新转换回合法的 ProseMirror 文档。这样并发输入发生在文本和树结构层,而不是两个完整 JSON 对象互相覆盖。

自定义节点的正确处理

  1. 所有客户端使用完全一致的 Schema:扩展名称、版本、优先级、属性默认值和 content expression 都要一致。
  2. 通过 Tiptap command / ProseMirror transaction 修改节点,例如 updateAttributessetNodeMarkup;不要直接改 Yjs XML,也不要改 DOM 后等待编辑器“发现”。
  3. NodeView 只负责视图与交互:共享业务数据放在 node attrs 或独立的 Yjs shared type;hover、弹窗开关、异步加载状态等本地 UI 状态留在组件内部。
  4. 不要把大对象频繁塞进单个 attrs:属性级更新比整体 JSON 字符串覆盖更容易合并。大型或高频变化的数据可用独立 Y.Map / Y.Array,节点只保存稳定 ID。
  5. 异步操作不要缓存绝对 position:跨 transaction 后使用 tr.mapping、relative position、稳定节点 ID,或重新解析当前位置。
  6. 原子节点要明确边界:合理设置 atomisolatingselectabledraggablecontentDOM,并谨慎实现 stopEventignoreMutationupdatedestroy
  7. 未知节点不能静默丢失:上线新 Schema 前先做兼容与灰度;旧客户端无法理解新节点时,可能规范化或删除内容。

四、生产最佳实践

1. 只保留一个共享事实来源

  • 协作启动后不要在每次 onUpdate 中把 editor.getJSON() 回写数据库,再用数据库结果 setContent
  • 持久化 Yjs update 或合并后的 Y.Doc;按需异步导出 JSON/HTML,作为搜索索引、预览或快照。
  • 不要同时运行传统 ProseMirror collab 和 Yjs collab。

2. 初始化必须幂等

  • 空文档的初始内容只写入一次,最好在服务端创建文档时完成。
  • 客户端等待首次同步后再判断文档是否为空;不要让多个客户端都执行 setContent(defaultContent)
  • 文档名、room ID、租户 ID 必须稳定且经过权限校验。

3. 使用协作专用 UndoManager

  • 关闭 StarterKit 自带的本地历史,使用 Collaboration/Yjs 的 undo/redo。
  • Undo 应只撤销当前用户追踪的本地 origin,不应回滚他人的远端修改。
  • 将一次业务动作包在同一个 transaction 中,合理设置 capture timeout。

4. Awareness 与文档分离

Awareness 适合光标、选区、在线状态和临时用户名;它是短暂状态,不保证持久化。评论、任务状态等需要可靠保存的数据应进入 Y.Doc 或业务数据库。

5. 离线与重连

  • 浏览器端可搭配 y-indexeddb,先从本地恢复,再与服务端合并。
  • Yjs update 天然可重复应用,但鉴权、初始化、Webhook 和业务副作用仍需自行实现幂等。
  • UI 明确展示 connecting / synced / offline / error,不要把“WebSocket 已连接”等同于“文档已同步”。

6. 持久化与压缩

  • 保存 Yjs 二进制状态,而非仅保存最终 HTML。
  • 增量 update 日志需要周期性合并/压缩,避免文档历史无限增长。
  • 定期生成可恢复快照并演练恢复;导出的 JSON 不能完整替代 CRDT 历史。
  • 服务端导出 JSON 时必须使用与客户端一致的 Schema。

7. 安全与可观测性

  • 在 WebSocket 握手或 provider token 阶段完成文档级鉴权,不能只信任前端 room 名。
  • 限制单条 update、Awareness payload、文档大小、连接数和消息频率。
  • 记录连接、首次同步、update 大小、广播延迟、持久化耗时、重连次数和异常关闭原因;不要记录敏感正文。

五、Hocuspocus 是最优解吗?

不是绝对最优,但它通常是“自托管 Tiptap + Yjs”的默认优选。 Hocuspocus 是面向 Yjs 的 TypeScript WebSocket 服务端,和 Tiptap 集成自然,提供鉴权、持久化钩子、Webhook、Redis 扩展与多文档复用。若团队已有 Node.js/TypeScript 基础设施,并且需要掌控数据和部署,它的综合成本通常最低。

需要注意:开源 Hocuspocus 服务端托管的 Tiptap Collaboration不是同一个交付形态。前者需要自行负责部署、数据库、扩容、监控和灾备;后者把这些能力产品化。

方案形态优势代价 / 局限更适合
Hocuspocus开源、自托管Tiptap/Yjs 集成直接;TS 钩子丰富;鉴权、持久化、Redis 易接入需要自己做运维、容量规划、压缩和灾备已有 Node 平台、要数据自主权
Tiptap Collaboration托管/企业部署与 Tiptap、评论、版本和转换链路最完整;接入快商业成本和平台绑定更高以 Tiptap 为核心、追求交付速度
Liveblocks托管服务Yjs、Presence、评论、通知和离线能力一体化;适合完整协作产品付费与供应商绑定;服务端数据模型需适配不只需要编辑,还要协作 UI/评论/通知
Y-Sweet开源 + 托管Rust 服务;S3/对象存储持久化;文档级 token;架构简洁Tiptap 高阶产品能力较少,需自行组合偏 local-first、对象存储、低运维模型
PartyKit / y-partykit边缘平台 + 开源库房间模型、全球部署、按需运行、可编程平台运行时约束;持久化和业务能力需设计Cloudflare/边缘优先、定制实时应用
y-websocket基础参考实现简单、透明、适合原型和学习官方仓库明确指出其简单后端不易扩展;生产鉴权、持久化和扩容工作多PoC、内网小规模、理解协议

选型建议

  • 自托管 + Tiptap 为核心:优先 Hocuspocus。
  • 最快上线且愿意购买托管能力:优先 Tiptap Collaboration;若评论、通知、跨编辑器协作更重要,评估 Liveblocks。
  • S3 文档存储、local-first 架构:评估 Y-Sweet。
  • 边缘运行、房间级自定义逻辑:评估 PartyKit。
  • 只做 Demo:y-websocket 足够;不建议未经加固直接承担关键生产数据。

最终不要只比较“能不能同步”。PoC 至少要压测:并发连接数、热点大文档、离线数天后重连、Schema 升级、update 压缩、跨区延迟、权限撤销、备份恢复和总体成本。

六、故障排查顺序

  1. 确认各端 Schema、扩展顺序和版本一致。
  2. 记录 transaction 的 steps、selection、meta、origin 和时间戳。
  3. 区分本地输入、插件追加事务、Yjs 远端事务和初始化事务。
  4. 对比变更前后的 ProseMirror JSON,并记录 Yjs update 大小与 state vector。
  5. 检查是否重复初始化、重复绑定 provider,或在同步后调用 setContent
  6. 检查 NodeView 的 updateignoreMutationstopEventcontentDOM 和销毁逻辑。
  7. 用中文输入法、粘贴、拖拽、undo/redo、双端同位置编辑、离线重连分别复现。

七、回归矩阵

  • 中文输入法组合输入不产生重复事务
  • 光标可进出自定义节点,NodeSelection 与 TextSelection 正常
  • 两端同时编辑同一段文本或同一节点属性
  • 本地 undo 不删除远端内容
  • 首次加载与多客户端同时初始化不会重复内容
  • 离线编辑后重连,重复 update 不产生副作用
  • 新旧 Schema 客户端兼容或被明确阻止连接
  • 大文档和热点房间的内存、延迟、update 大小可接受
  • 持久化故障后可从快照和更新日志恢复
  • NodeView 销毁后监听器、定时器和 provider 已释放

参考资料