协同编辑核心技术实现与实战踩坑经验分享
协同编辑光标乱跳?我差点被 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 在前端的实际落地经验?求分享!

暂无评论