这种模式是什么
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 事务、防止回声的符号