协同编辑实战中的Operational Transformation与CRDT技术选型对比

萌新.宝玲 交互 阅读 2,737
赞 44 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

项目上线前两周,协同编辑模块一开多人实时编辑就掉帧——不是“有点卡”,是光标移动都延迟半秒,输入法回显错位,撤回操作经常丢指令。最夸张一次,三人同时编辑一个 200 行的 Markdown 文档,页面直接卡死 3 秒,控制台疯狂报 RangeError: Maximum call stack size exceeded。用户反馈里写:“打字像在拨号上网”。我盯着 Performance 面板里那条持续飙红的主线程,心想这哪是协同编辑,这是协同卡顿。

协同编辑实战中的Operational Transformation与CRDT技术选型对比

找到瘼颈了!

先用 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
}

这里注意:我踩过好几次坑,queueMicrotasksetTimeout(0) 更稳,因为不会被 UI 线程阻塞;但千万别用 requestAnimationFrame —— 它会在每一帧都触发,反而更容易抖动。

顺手优化:光标位置计算缓存

另一个隐藏性能杀手是光标定位。每次 patch 后都要重新算光标在新 DOM 中的位置(用 range.getClientRects()),这个 API 在长文档里慢得离谱。解决方案很简单:只在用户真实点击/聚焦时更新光标位置,其他时候复用上一次的 offsetnode 引用。加个 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 一把)

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论