← 全部文档

按节点同步的 Yjs CRDT 引擎

v2.2 中提供的 textSync: "crdt" 模式的架构与集成参考。端到端的用法请参阅协作演示。

这种模式是什么

editor.collab.attach({ doc, provider, textSync: "crdt" }) 会启用一个按段落的 Yjs 绑定,把可编辑 DOM 镜像到共享 Y.Doc 中的一个 Y.XmlFragment。两个用户同时编辑同一段落时,会产生互不冲突的 Yjs 操作:每一次插入/删除/格式范围都带有 Yjs 向量时钟,由 CRDT 以确定性的方式合并。没有“最后写入者获胜”,正常负载下光标不会跳动,也不需要代理服务器——引擎完全在浏览器中运行,可以与任何 Yjs 提供程序配合使用(y-websocket、Hocuspocus、y-webrtc、Liveblocks 或任何其他兼容 CRDT 的传输层)。关于为什么这一选择在演示中几乎看不出来、在生产环境中却至关重要,请参阅 CRDT 与 OT:协作编辑器如何合并编辑。

模式对照表

textSync路由到行为
false(默认)仅在线状态的空操作句柄只有感知/光标;不同步文档。适用于内容存放在别处的情况(例如存储在服务器上的 HTML)。
true(旧版)快照模式 MVP共享的 Y.Text 把编辑器的 HTML 作为一个不透明的字符串保存。合并粒度粗——同一段落发生冲突时“最后写入者获胜”。为了向后兼容 v2.0 客户而保留;将于 2027 年弃用。
"crdt"(推荐)按节点的引擎(本页)完整的字符级 CRDT。同一段落中的并发输入可以合并;通过 Y.RelativePosition 显示在线光标;按作者区分的 Y.UndoManager。

架构

引擎以一个单独的插件脚本(richtexteditor/plugins/crdt-engine.min.js,压缩后约 88 KB)提供,它会暴露 window.RichTextEditorCrdt。yjscollab 插件的 collab.attach 方法会检测这个全局对象;如果存在,就把内容同步委托给引擎,而自己继续负责感知叠加层、在线面板和审阅台账桥接。

   Browser editable (rte.js)
                          ↓ MutationObserver
                    [crdt-engine.js]
                          ↓ Y.Doc transactions (LOCAL_ORIGIN)
                       Y.XmlFragment
                          ↓ Yjs provider (y-websocket / Hocuspocus / etc.)
                        Network
                          ↓
                       Other peers' Y.Docs

   Other peer's Y.Doc updates
                          ↓ ydoc.on("update", origin !== LOCAL_ORIGIN)
                     [crdt-engine.js renders to DOM,
                      preserves selection via path-based capture]
                          ↓
                  Browser editable updates

引擎负责的部分

  • Y.XmlFragment ↔ DOM 双向同步。块元素、叶元素和内联标记都能往返。树中间的 childList 变化(列表、表格)会触发精确的子树重建,而不是粗粒度地重新渲染整个文档。
  • 保留选区。在每次由远程触发的重新渲染之前,基于路径捕获选区,之后再恢复——协作者在别处输入时,用户的光标保持不动。
  • 防止回声。本地编辑会被标记为 LOCAL_ORIGIN;远程处理程序会忽略它们。每次远程渲染之后都会同步清空 MutationObserver.takeRecords(),这样观察器的异步回调就无法把本地编辑的输出循环送回 Y.Doc。
  • 光标在线状态。选区位置被编码为 Y.RelativePosition 字节——一种在并发插入/删除后仍保持稳定的标识符——并发布到感知通道上;对等端收到后进行解码,把远程光标投射到自己的本地 DOM 上。
  • 按作者撤销。Y.UndoManager 配合 trackedOrigins: { LOCAL_ORIGIN }——Ctrl-Z 只撤销本地用户的编辑,绝不会撤销协作者的编辑。

宿主负责的部分

  • 工具栏、菜单、对话框、粘贴处理。
  • Yjs 提供程序的连接/身份验证(引擎本身不进行网络通信)。
  • 感知数据的组成(用户身份、颜色、名称)。
  • 绘制光标叠加层——引擎返回位置数据,由宿主负责绘制。
  • 冲突相关的用户体验(悬停时显示协作者名称、高亮显示他们的选区矩形等)。

集成代码

原生 JS

<link rel="stylesheet" href="/richtexteditor/rte_theme_default.css">
<script src="/richtexteditor/rte.js"></script>
<script src="/richtexteditor/plugins/all_plugins.js"></script>
<script src="/richtexteditor/plugins/crdt-engine.min.js"></script>

<script type="module">
    import * as Y from "https://esm.sh/yjs";
    import { WebsocketProvider } from "https://esm.sh/y-websocket";

    const editor = new RichTextEditor("#editor", {
        commentsEnabled: true,
        trackChangesEnabled: true,
    });

    const ydoc = new Y.Doc();
    const provider = new WebsocketProvider("wss://your-yjs-server", "doc-1", ydoc);

    editor.collab.attach({
        doc: ydoc,
        provider: provider,
        Y: Y,                                 // your Yjs module — lets the engine bridge a doc built by it
        textSync: "crdt",                    // ← per-node CRDT
        requireCrdt: true,                    // fail closed if the engine is missing
        user: { id: "u-1", name: "Alice", color: "#2563eb" }
    });
</script>

验证附加的模式

生产环境的集成应在附加之后检查模式。当您的 Y.Doc 来自与引擎所捆绑的不同的 Yjs 实例,并且插件正在两者之间转发更新时(向 attach 传入 Y 可启用此功能),getStatus().yjsBridge 为 true。requireCrdt: true 会在引擎无法启动时保持在线状态和审阅台账可用,但不会悄悄地把文档编辑切换到旧版快照模式。请使用 onCollabStatus 进行集中遥测或显示可见的恢复提示。

const editor = new RichTextEditor(host, {
  onCollabStatus(status) {
    if (status.fallbackReason) reportCollaborationIssue(status.fallbackReason);
  }
});

const status = editor.collab.getStatus();
if (status.textSyncMode !== "crdt") {
  reportCollaborationIssue(status.fallbackReason);
}

React

import { useEffect, useRef } from "react";
import { RichTextEditorComponent } from "@richscripts/richtexteditor/react";
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";

export function CollabEditor({ docId, user }) {
    const editorRef = useRef();

    useEffect(() => {
        const editor = editorRef.current.getEditor();
        const ydoc = new Y.Doc();
        const provider = new WebsocketProvider(
            "wss://your-yjs-server", docId, ydoc,
        );
        editor.collab.attach({
            doc: ydoc, provider, Y, textSync: "crdt", user,
        });
        return () => { provider.destroy(); ydoc.destroy(); };
    }, [docId]);

    return (
        <RichTextEditorComponent
            ref={editorRef}
            config={{ commentsEnabled: true, trackChangesEnabled: true }}
        />
    );
}

从 textSync: true 迁移

有两种途径,取决于您想要一次性切换还是并行逐步推出。

途径 A——全新开始(推荐)

用不同的键生成一个新的 Y.Doc(例如在现有文档 id 后加上 "-crdt")。对某个文档首次附加时,运行引擎提供的迁移辅助函数,用您存储的 HTML 填充新文档:

const newDoc = new Y.Doc();
window.RichTextEditorCrdt.htmlToYFragment({
    ydoc: newDoc,
    html: legacyHtmlString,
    parserDocument: document,
});
editor.collab.attach({
    doc: newDoc,
    provider: new WebsocketProvider(url, docId + "-crdt", newDoc),
    Y,
    textSync: "crdt",
});

途径 B——并行切换

在通过功能开关逐步推出期间,同时运行两种模式。重要提示:同一文档上的所有协作者必须使用同一种模式;在同一个 Y.Doc 上混用旧版快照模式和 CRDT 模式会导致状态不一致。请按租户设置这个开关。

const mode = featureFlag("rte_crdt_for_tenant_" + tenantId) ? "crdt" : true;
editor.collab.attach({ doc: ydoc, provider, textSync: mode });

弃用时间表(暂定)

日期事件
2026 年第 2 季度textSync: "crdt" 以需手动开启的开关形式提供;textSync: true 仍是默认值(没有破坏性变更)。
2026 年第 4 季度textSync: "crdt" 成为默认值;textSync: true 仍可通过旧版向后兼容垫片使用。
2027 年第 2 季度textSync: true 在运行时发出弃用警告。
2027 年第 4 季度移除 textSync: true。客户必须使用 "crdt"。

工程细节

源代码结构、测试语料和贡献者文档与引擎一起存放在内部的 RichTextEditorCloud/CrdtEngine/ 工作区中。公开的 API 通过 window.RichTextEditorCrdt.* 导出:

  • attachCollab(opts)——调度器;按 textSync 模式路由
  • attachCrdtBinding(opts)——直接的绑定入口(跳过调度器)
  • attachAwareness(opts)——光标在线状态的连接
  • attachUndoManager(opts)——按作者区分的 Y.UndoManager 包装器
  • htmlToYFragment(opts)——一次性把 HTML 迁移为按节点 Y 结构的辅助函数
  • LOCAL_ORIGIN——用于标记本地来源 Yjs 事务、防止回声的符号