知行札记
专题技术实践文档与编辑工具

Yjs 协同文档与持久化

连接编辑变换、CRDT 更新、provider、awareness、权限和保存,并辨明收敛与业务保证。

文档、在线状态和列表有不同责任

Yjs 文档保存协同数据,编辑器绑定把文档变更与编辑 transaction 互相转换;provider 负责传播 update。awareness 传播光标、选区和在线信息,文档列表及权限由应用服务管理,本地面板偏好仍属于界面。不要把同一正文同时作为独立 Query 缓存与 Yjs 权威副本。

Hocuspocus 提供 Yjs 的 WebSocket 服务端与接入能力。CRDT 合并处理算法所定义的并发变更,访问权限、文档语义、资源配额与保存保证仍属于宿主责任。Hocuspocus 概览

Tiptap Collaboration 使用 Y.Doc 的字段或 fragment,带自己的撤销历史;与 StarterKit 合用时需关闭 UndoRedo。绑定中途换文档、启动时重复插入默认内容和多端 schema 不一致,都会改变实际文档行为。Collaboration 扩展

启动及结束一份文档

启动先确定文档 ID 和身份,再建立该文档的 Y.Doc、载入本地持久化并连接 provider。初始内容应只有明确的创建方写入。若每个客户端在连接前都插入“空文档默认段落”,这些插入可能在合并后同时存在。

import * as Y from 'yjs';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { IndexeddbPersistence } from 'y-indexeddb';

export function connectDocument(documentId: string, token: string) {
  const document = new Y.Doc();
  const local = new IndexeddbPersistence(`document:${documentId}`, document);
  const provider = new HocuspocusProvider({
    url: 'wss://collaboration.example.test',
    name: documentId,
    document,
    token,
  });
  return {
    document,
    provider,
    local,
    destroy() {
      provider.destroy();
      void local.destroy();
      document.destroy();
    },
  };
}

这是客户端生命周期片段;示例域名不对应实际服务,尚未提供鉴权、重连反馈或编辑器绑定。本地数据恢复、provider 已连接、同步完成、服务端持久保存是不同状态。UI 的“已保存”应该对应项目声明的保存确认,不能只看 WebSocket 连接状态。

切换文档时销毁旧 provider、订阅和本地绑定。旧异步认证、加载或保存回调携带文档身份,避免更新新页面。用户登出或权限撤销后,本地持久缓存的保留和清理另需明确;关闭写 UI 无法收回已经下发的数据。

权限必须绑定具体房间

token 需要由服务端核对签名、有效期、主体、文档范围和写权限。只验证用户身份后接受任意 documentName,会让其他文档成为可访问对象。客户端声明 readOnly 或用户 ID 只能服务展示,服务端强制才保护文档。

可以用已有登录会话换发短期协同凭证,也可以采用服务端能够核对的其他认证契约;没有一种接线必须依赖特定鉴权产品。凭证过期、权限改变和断线重连需共同设计。长期连接是否重新验证权限必须由服务端明确,不能靠短期 token 的签发时间自动推导。

awareness 不适合保存访问凭证或敏感信息。光标颜色与姓名是界面内容,也需清洗和长度限制。presence 不属于文档永久历史,离线用户的状态应按协议过期。

二进制更新和持久状态

Yjs update 为二进制,通常用字节存储;在 PostgreSQL 可以用 bytea。JSON 导出适合检索或呈现快照,不能替代全部协同元数据与历史。保存 Y.Doc 全量状态和保存增量日志具有不同容量、恢复和压缩取舍。

单实例持有一份文档时,可以在存储钩子中合并变更并写快照。多实例需要共享传播及明确的持久化并发规则。Redis 扩展解决跨实例传播的一部分,不自动建立外部存储提交顺序;upsert 只使单次写入原子,两个实例以旧快照覆盖新快照仍可能丢失持久信息。

可采用每文档串行写、单写入所有者、版本检查或增量追加后合并。选择时定义确认点:用户看见变更、其他在线端收到变更、服务端接纳变更和持久化完成,分别是什么保证。退出时等待存储完成、崩溃恢复和备份恢复,需要实际服务端设计。

// 算法示意:合并已取得的更新,具体读取和提交需要并发控制。
import * as Y from 'yjs';

export function mergeDocumentUpdates(updates: Uint8Array[]) {
  return Y.mergeUpdates(updates);
}

文档合法性与 schema 演进

CRDT 收敛表示参与者取得相同有效更新后,按算法形成一致状态;它不保证节点符合应用 schema、任务状态满足业务不变量或用户意见没有冲突。协同编辑器的 schema 和绑定规则限制数据形状,服务端仍需核对可接受的内容与大小。

旧客户端不认识新节点时可能删除、拒绝或降级内容。新增字段、改变字段含义和清理历史分别制定迁移策略,不将“只新增字段”当成所有 CRDT 的永久规则。文档身份、schema 版本、转换器和读写客户端的支持窗口需要一起维护。

撤销要区分自己的一次编辑、远端插入和共享提案接受。Y.UndoManager 的捕获范围及 trackedOrigins 决定哪些变更可撤销,不宜笼统说“只能撤销当前客户端”。模型建议或结构化命令应使用稳定 origin 与事务边界,避免撤销时连带他人的编辑。

验证到什么范围

算法层可构造并发插入、删除、重复及乱序 update,检查最终内容;服务端集成检查文档授权、认证失败、存储顺序和恢复;真实双客户端检查输入、断线重连、光标及本地缓存。模拟通知通道不能证明编辑器绑定与实际 provider 同步。

归档接线中存在几个需要修正的示例:原子 upsert 无法单独保证多实例快照不互相覆盖;React 外部 store 的快照需要稳定身份,不能在每次读取时无条件创建新数组;Y.Text 的头部插入用 insert(0, text),不凭空使用 prepend。本页按这些机制重新组织,未启动协同服务或运行浏览器验证。

最后更新于

本页目录