协同编辑核心技术实现与实战踩坑经验分享

Dev · 爱丹 交互 阅读 1,231
赞 10 收藏
二维码
手机扫码查看
反馈

协同编辑光标乱跳?我差点被 OT 算法整崩溃

上周做个多用户实时文档编辑功能,本来以为用个现成的库就完事了,结果一上线测试,光标位置各种乱跳,删着删着别人的字没了,自己打的字跑到别人段落里……简直灾难。折腾了三天,头发都快薅秃了,最后发现问题出在「操作转换」(Operational Transformation)的实现细节上。

协同编辑核心技术实现与实战踩坑经验分享

一开始我以为是 WebSocket 顺序问题

最开始怀疑是服务端广播消息顺序不对。毕竟多个用户同时编辑,如果 A 的插入和 B 的删除到达客户端顺序不一致,那肯定乱套。于是我加了一堆日志,把每个操作的时间戳、用户 ID、操作类型全打出来,结果发现顺序其实是对的——服务端按接收顺序广播,客户端也按顺序处理。那问题在哪?

后来试了下把所有操作先存队列,等确认服务端 ack 了再 apply,结果更糟:延迟高到没法用,而且还是有冲突。这时候我才意识到,问题不在网络,而在本地如何“合并”远程操作。

踩坑:直接 apply 远程操作 = 自找麻烦

我最初的做法特别 naive:收到远程操作,直接调用 editor.apply(remoteOp)。这在单人编辑时没问题,但多人同时改同一段文字,比如我在位置 5 插入 “hello”,同时你在位置 3 删除一个字符,这两个操作在各自本地是合法的,但直接 apply 到对方文档上,位置就错位了。

举个具体例子:

  • 初始文本:”abcde”
  • 我在位置 2 插入 “X” → “abXcde”
  • 你在位置 3 删除 “c” → “abde”

如果我不转换你的删除操作,直接在 “abXcde” 上删位置 3 的字符,会删掉 “X”,变成 “abcde” —— 完全错误!正确结果应该是 “abXde”。

这里我踩了个大坑:以为只要服务端保证顺序,客户端就能直接 apply。其实不是,每个客户端必须根据本地已应用的操作,对远程操作做「位置转换」

OT 算法核心:transform 函数

查了 Yjs、ShareDB 的文档,才搞明白关键是一个叫 transform 的函数。它接收两个操作 op1 和 op2,返回两个新操作 op1′ 和 op2’,使得:
apply(apply(doc, op1), op2') == apply(apply(doc, op2), op1')

也就是说,无论先 apply 哪个,最终结果一致。这才是协同编辑的基石。

但自己从头实现 OT 太复杂,我选了轻量级方案:用 ot-text 这个 npm 包,它专门处理文本操作的转换。

核心代码就这几行(但调试花了半天)

下面是我最终的处理逻辑。关键点在于:**本地操作要先 transform 所有 pending 的远程操作,然后再发给服务端;收到远程操作时,也要 transform 掉本地未提交的操作**。

// 初始化
import TextOperation from 'ot-text';

let localPendingOps = []; // 本地已 apply 但未 ack 的操作
let remotePendingOps = []; // 已收到但未 apply 的远程操作(按序)

// 本地输入时
function onLocalChange(op) {
  // 1. 先 apply 到本地文档(立即反馈)
  editor.apply(op);
  
  // 2. transform 掉所有待处理的远程操作
  let transformedOp = op;
  remotePendingOps.forEach(remoteOp => {
    const [newTransformedOp, _] = TextOperation.transform(transformedOp, remoteOp);
    transformedOp = newTransformedOp;
  });
  
  // 3. 发送 transform 后的操作给服务端
  socket.send({ type: 'operation', op: transformedOp });
  
  // 4. 记录到本地 pending
  localPendingOps.push(op);
}

// 收到远程操作
function onRemoteOperation(remoteOp) {
  // 1. transform 掉所有本地 pending 操作
  let transformedRemoteOp = remoteOp;
  localPendingOps.forEach(localOp => {
    const [_, newTransformedRemoteOp] = TextOperation.transform(localOp, transformedRemoteOp);
    transformedRemoteOp = newTransformedRemoteOp;
  });
  
  // 2. apply 到本地
  editor.apply(transformedRemoteOp);
  
  // 3. 清理已 ack 的本地操作(假设服务端 ack 了前面所有操作)
  // 实际中需要更精细的 ack 机制,这里简化
  if (isAckForAllLocalOps) {
    localPendingOps = [];
  }
}

注意:上面的 remotePendingOps 其实可以不用显式存,因为 WebSocket 保证顺序,我们按序处理就行。但 localPendingOps 必须维护,因为本地操作可能还没发出去,或者发出去了但没 ack,这时候来的远程操作必须基于“包含这些本地操作”的状态来转换。

光标同步的额外坑

搞定文本同步后,光标位置又出问题。比如我在位置 10,你在我前面插入了 5 个字,我的光标应该自动移到 15,但实际没动,导致我接着打字插在了旧位置。

解决方案是:每次 apply 远程操作后,遍历所有其他用户的光标位置,用同样的 OT 转换逻辑更新他们的光标坐标。

// 假设 cursors 是 { userId: { position: number } }
function updateRemoteCursors(remoteOp) {
  for (const userId in cursors) {
    if (userId === myUserId) continue;
    // 用 remoteOp 转换光标位置
    const newPosition = TextOperation.transformCursor(cursors[userId].position, remoteOp);
    cursors[userId].position = newPosition;
  }
}

注意 transformCursor 不是 ot-text 自带的,得自己写。其实就是判断光标在操作的前面、中间还是后面,然后加减偏移量。比如插入操作,如果光标位置 >= 插入位置,就 + 插入长度;删除操作则相反。

还有一点小瑕疵(但能忍)

现在基本稳了,但极端情况下(比如两人同时在同一个位置疯狂输入),偶尔会有 1-2 个字符顺序颠倒。不过概率极低,且不影响内容正确性,用户也很难察觉。要彻底解决得上 CRDT,但成本太高,现阶段 OT + 良好 UX 提示(比如高亮冲突区域)就够了。

总结:别自己造轮子,但得懂原理

这次最大的教训是:协同编辑看着简单,底层水很深。直接用 Quill + ShareDB 或 Yjs 当然省事,但一旦出问题,不懂 OT 原理根本没法 debug。花两天啃文档、写 demo,比盲目调 API 强多了。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有更轻量的 OT 库?或者 CRDT 在前端的实际落地经验?求分享!

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

暂无评论