协同编辑实战中的Operational Transformation与CRDT技术选型对比
优化前:卡得不行
项目上线前两周,协同编辑模块一开多人实时编辑就掉帧——不是“有点卡”,是光标移动都延迟半秒,输入法回显错位,撤回操作经常丢指令。最夸张一次,三人同时编辑一个 200 行的 Markdown 文档,页面直接卡死 3 秒,控制台疯狂报 RangeError: Maximum call stack size exceeded。用户反馈里写:“打字像在拨号上网”。我盯着 Performance 面板里那条持续飙红的主线程,心想这哪是协同编辑,这是协同卡顿。
找到瘼颈了!
先用 Chrome DevTools 的 Performance 录制 10 秒操作:输入、光标跳转、接收远程 OT 操作。结果很明确——90% 的耗时堆在 文本 diff + DOM 同步 这一块。我们当时用的是 diff-match-patch 做行级 diff,再遍历所有 DOM 节点做 patch。问题在于:每次收到一个 OT 操作(哪怕只是插入一个字母),都会触发全量 re-diff,然后调用 document.execCommand(后来换成 contenteditable 的原生 API)去更新,导致 layout thrashing 严重。
另外,我们把所有 OT 操作都塞进一个 requestIdleCallback 队列里顺序执行,但没做合并——比如用户快速连按 5 次 backspace,会生成 5 条独立操作,每条都走一遍 diff → patch → render 流程。这就是典型的“小操作高频次,大开销低收益”。
核心优化:OT 批量合并 + DOM 更新节流
试了几种方案:改用 quill 底层?太重;换 yjs?时间不够;手写 CRDT?别闹了,我连 Y.Text 的 type-check 都还没搞懂。最后决定从最痛的地方下手:砍掉无效 diff,压平 DOM 更新节奏。
第一步,加了个简单的 OT 合并器。只要两条操作发生在同一行、时间间隔 <50ms,就合并成一条(比如连续插入 “hello” → 合成单次插入 “hello”)。关键代码就这几行:
// 合并相邻 OT 操作(简化版)
function mergeOps(ops) {
if (ops.length <= 1) return ops;
const merged = [];
let current = { ...ops[0] };
for (let i = 1; i < ops.length; i++) {
const op = ops[i];
// 同一行、且是 insert/delete 类型、时间接近 → 合并
if (
current.line === op.line &&
current.type === op.type &&
op.timestamp - current.timestamp < 50
) {
if (op.type === 'insert') {
current.text += op.text;
} else if (op.type === 'delete') {
current.length += op.length;
}
} else {
merged.push(current);
current = { ...op };
}
}
merged.push(current);
return merged;
}
第二步,DOM 更新不再“来一条干一条”,而是攒一批,用 queueMicrotask 统一做一次 diff + patch。我们把原本每次 OT 都调用的 applyOperation() 改成了缓冲模式:
let pendingOps = [];
let isApplying = false;
function queueOp(op) {
pendingOps.push(op);
if (!isApplying) {
isApplying = true;
queueMicrotask(() => {
const batch = [...pendingOps];
pendingOps = [];
applyBatch(batch); // 这里才真正 diff & patch
isApplying = false;
});
}
}
function applyBatch(ops) {
const content = getCurrentContent(); // 获取当前 DOM 文本快照
const newText = transformContent(content, ops); // 应用所有操作到文本
const diff = diffText(content, newText); // 只 diff 一次
patchDom(diff); // 只 patch 一次 DOM
}
这里注意:我踩过好几次坑,queueMicrotask 比 setTimeout(0) 更稳,因为不会被 UI 线程阻塞;但千万别用 requestAnimationFrame —— 它会在每一帧都触发,反而更容易抖动。
顺手优化:光标位置计算缓存
另一个隐藏性能杀手是光标定位。每次 patch 后都要重新算光标在新 DOM 中的位置(用 range.getClientRects()),这个 API 在长文档里慢得离谱。解决方案很简单:只在用户真实点击/聚焦时更新光标位置,其他时候复用上一次的 offset 和 node 引用。加个 flag 就搞定:
let cachedCursor = null;
let isUserDrivenCursorUpdate = false;
function updateCursor() {
if (isUserDrivenCursorUpdate) {
cachedCursor = getCursorPosition();
}
// 其他逻辑...
}
// 在 mouseup / focus 事件里设 flag
editor.addEventListener('mouseup', () => {
isUserDrivenCursorUpdate = true;
});
优化后:流畅多了
改完跑了一轮压测:3 人同时编辑 500 行文档,输入延迟从平均 420ms 降到 65ms,FPS 稳定在 58–60,主线程 block 时间从 1200ms/10s 降到 80ms/10s。最明显的是——用户终于能正常用中文输入法了,不会再出现“打‘你好’出来‘你’字闪两次”这种鬼事。
当然,不是完美。比如极端情况下(10+人编辑同一段落),还是会有轻微延迟,但我们评估后认为:这时候该提示“文档正在被多人编辑”,而不是硬扛。技术选型要务实,不是越炫酷越好。
性能数据对比
- 单次 OT 操作处理耗时:优化前平均 85ms → 优化后 9ms(↓90%)
- 10 秒内 DOM 更新次数:优化前 142 次 → 优化后 23 次(↓84%)
- 内存占用峰值:优化前 186MB → 优化后 92MB(主要因减少临时 DOM 节点)
- 首屏可交互时间(含协同初始化):5.2s → 0.8s(去掉冗余的 OT 初始化校验)
顺带一提:我们还关掉了 console.log 里所有 OT 操作详情输出,这玩意儿在调试时爽,上线后占 CPU 3% —— 这个细节很多人忽略,但它真会影响性能。
以上是我的优化经验,有更好的方案欢迎交流
这个方案不是最优解,但它是我们在两周 deadline 下最稳、最易维护的选择。没有用 Yjs 或 Automerge 是因为团队对 CRDT 理解不深,强行上容易翻车;也没引入 Web Worker 做 diff 是因为移动端兼容性风险高,且实测收益不如批量合并明显。
如果你也在搞协同编辑,欢迎评论区聊聊你们怎么处理 OT 冲突、光标同步、或服务端吞吐瓶颈。也欢迎甩链接,让我看看别人家是怎么做的。毕竟,我踩过的坑,能帮你少踩一个,就算值了。
(P.S. 我们有个测试接口在 https://jztheme.com/api/ot-batch,需要 mock 数据时可以 curl 一把)

暂无评论