在线文档产品越来越多,多人同时编辑一段文字早已是常态。支撑这个体验的核心技术就是OT算法,也就是操作变换算法。很多人以为实现协同编辑必须上React或Vue这样的大型框架,其实用jQuery同样可以完成一套完整可用的方案,尤其适合维护存量jQuery项目、需要增量添加协同能力的场景。本文将从操作建模开始,逐步实现内容同步、光标位置修正和并发冲突处理。

一、OT算法的核心思想与操作建模
OT算法解决的本质问题是:当两个人同时编辑文档时,服务器先收到了A的操作,后收到了B的操作。如果直接把B的操作应用在最新文档上,很可能与B当时看到的文档状态对不上,导致内容错乱。OT的思路是:不传输文档本身,而是传输操作,并且在应用远程操作之前,先根据本地已发生的操作对远程操作做一次变换,让它适应当前的文档状态。
首先要定义操作的数据结构。一段文本编辑可以抽象为两种原子操作:retain(跳过n个字符)和 insert/delete(插入或删除字符)。多个原子操作按顺序组合,就构成一次完整的提交。例如在文档开头插入hello再删除末尾3个字符,可以表示为一个操作数组。
// 一个操作由一组原子变换组成
// { type: 'retain', n: 5 } 跳过5个字符
// { type: 'insert', s: 'abc' } 插入字符串abc
// { type: 'delete', n: 2 } 删除2个字符
function TextOperation() {
this.ops = [];
}
TextOperation.prototype.retain = function (n) {
if (n > 0) this.ops.push({ type: 'retain', n: n });
return this;
};
TextOperation.prototype.insert = function (s) {
if (s) this.ops.push({ type: 'insert', s: s });
return this;
};
TextOperation.prototype['delete'] = function (n) {
if (n > 0) this.ops.push({ type: 'delete', n: n });
return this;
};
// 应用到字符串上,返回新内容
TextOperation.prototype.apply = function (str) {
var newStr = '', pos = 0;
for (var i = 0; i < this.ops.length; i++) {
var op = this.ops[i];
if (op.type === 'retain') {
newStr += str.slice(pos, pos + op.n);
pos += op.n;
} else if (op.type === 'insert') {
newStr += op.s;
} else if (op.type === 'delete') {
pos += op.n;
}
}
// 补齐尾部未被跳过的字符
if (pos < str.length) newStr += str.slice(pos);
return newStr;
};
有了这个基础类,本地每一次编辑都可以转换为一个TextOperation实例。注意操作必须覆盖被编辑位置之前的所有字符,也就是编辑位置之前要用retain跳过,这样操作才携带完整的位置信息,服务器才能做后续变换。
二、监听输入并生成操作,通过WebSocket上报
在jQuery侧,我们需要监听textarea的input事件,把两次快照之间的差异转换成操作。为了简单可靠,可以维护一个上次内容的快照,每次输入后用简单的前后缀比对生成最小编辑操作。实现时要注意对IME中文输入法的compositionend事件的处理,避免拼音组合期间产生碎片操作。
var $editor = $('#editor');
var lastContent = $editor.val();
var sending = false;
var pendingOps = [];
function diffToOperation(oldStr, newStr, cursor) {
var op = new TextOperation();
var start = 0;
while (start < oldStr.length && start < newStr.length &&
oldStr[start] === newStr[start]) start++;
var oldEnd = oldStr.length, newEnd = newStr.length;
while (oldEnd > start && newEnd > start &&
oldStr[oldEnd - 1] === newStr[newEnd - 1]) {
oldEnd--; newEnd--;
}
op.retain(start);
if (newEnd > start) op.insert(newStr.slice(start, newEnd));
if (oldEnd > start) op['delete'](oldEnd - start);
if (oldEnd < oldStr.length) op.retain(oldStr.length - oldEnd);
return op;
}
$editor.on('input', function () {
var current = $editor.val();
var op = diffToOperation(lastContent, current, this.selectionStart);
lastContent = current;
pendingOps.push(op);
scheduleSend();
});
function scheduleSend() {
// 简单合并策略:150ms内的操作一起发送,减少消息量
clearTimeout(scheduleSend._t);
scheduleSend._t = setTimeout(sendOps, 150);
}
function sendOps() {
if (!pendingOps.length || !ws) return;
ws.send(JSON.stringify({
type: 'op',
docVersion: version,
ops: pendingOps
}));
pendingOps = [];
}
这里有个关键细节:每个操作都携带了客户端记录的文档版本号docVersion。服务器会用这个版本号判断该操作是基于哪个历史状态生成的,如果落后于服务器当前版本,就先做变换再落库,这正是冲突处理的入口。防抖150ms既能保证实时性,又能把连续打字合并成少量消息,显著降低服务器压力。
三、远程操作应用与光标位置同步
收到服务器广播的远程操作后,本地不能简单粗暴地替换textarea的值,否则正在打字的用户光标会跳到末尾,体验极差。正确做法分两步:先把远程操作针对本地未提交的操作做变换,得到适配当前文本的版本;再计算变换后操作对本地光标位置的偏移影响,修正光标。
光标修正的规则很简单:遍历操作中的原子项,遇到insert,如果插入点在光标之前或正好在光标处,光标右移插入长度;遇到delete,如果删除区间在光标之前,光标左移被覆盖的长度,如果删除区间跨过光标,光标收缩到删除区间起点。把这些规则封装成一个函数,应用到selectionStart和selectionEnd两个位置上。
function transformCursor(cursor, ops) {
var pos = cursor;
for (var i = 0; i < ops.length; i++) {
var op = ops[i];
if (op.type === 'retain') continue;
if (op.type === 'insert') {
pos += op.s.length; // 简化模型:插入总在光标之前发生
} else if (op.type === 'delete') {
pos = Math.max(0, pos - op.n);
}
}
return pos;
}
var ws = new WebSocket('ws://127.0.0.1:8080');
ws.onmessage = function (e) {
var msg = JSON.parse(e.data);
if (msg.type === 'remoteOp') {
var remoteOps = msg.ops;
var selStart = $editor[0].selectionStart;
var selEnd = $editor[0].selectionEnd;
// 先变换再应用
var transformed = transformAgainstPending(remoteOps, pendingOps);
var newContent = applyAll(lastContent, transformed);
lastContent = newContent;
$editor.val(newContent);
version = msg.version;
// 光标修正
var nStart = transformCursor(selStart, transformed);
var nEnd = transformCursor(selEnd, transformed);
$editor[0].setSelectionRange(nStart, nEnd);
renderPeerCursor(msg.userId, nStart, msg.userColor);
}
};
展示协作者光标是提升体验的重要一环。textarea本身无法显示彩色光标,常见方案是用一个绝对定位的div覆盖在编辑区上方,根据光标位置换算出像素坐标。换算时可以借助一个隐藏的镜像div,把文本按相同字体样式填入,测量字符宽度得到偏移量。每个协作者分配一种颜色,光标移动时通过transform属性平移,比直接改left性能更好。
四、冲突处理:变换函数与版本号管理
OT的核心是transform函数,输入两个并发操作a和b,输出变换后的a'和b',满足性质:apply(apply(doc, a), b') === apply(apply(doc, b), a')。也就是说无论操作以什么顺序到达,最终文档状态一致,这被称为收敛性。transform的实现是一个双指针逐项推进的过程,对retain、insert、delete的组合逐一处理。
function transform(a, b) {
var a1 = new TextOperation(), b1 = new TextOperation();
var ai = 0, bi = 0, apos = 0, bpos = 0;
var insA = null, insB = null;
while (ai < a.ops.length || bi < b.ops.length) {
var opA = a.ops[ai], opB = b.ops[bi];
if (opA && opA.type === 'insert') {
// b侧需跳过a插入的内容,a的插入优先保留
b1.retain(opA.s.length);
a1.insert(opA.s);
ai++; continue;
}
if (opB && opB.type === 'insert') {
a1.retain(opB.s.length);
b1.insert(opB.s);
bi++; continue;
}
// 剩余情况是retain与delete的配对,按较短的一方推进
var minLen = Math.min(
opA ? (opA.type === 'delete' ? opA.n : opA.n) : 0,
opB ? (opB.type === 'delete' ? opB.n : opB.n) : 0
);
// 双方都删除同一区域时,变换后双方都不再删
if (minLen > 0) {
if (opA.type !== 'delete') a1.retain(minLen);
if (opB.type !== 'delete') b1.retain(minLen);
apos += minLen; bpos += minLen;
if (opA.n === minLen) ai++; else opA.n -= minLen;
if (opB.n === minLen) bi++; else opB.n -= minLen;
}
if (ai >= a.ops.length && apos < lengthHint) a1.retain(lengthHint - apos);
if (bi >= b.ops.length && bpos < lengthHint) b1.retain(lengthHint - bpos);
if (minLen === 0 && ai >= a.ops.length) break;
}
return [a1, b1];
}
上面的代码展示了变换的基本骨架,实际工程中建议直接使用成熟的实现(如ot.js库的transform逻辑),自己从零写容易在边界情况上翻车,比如双方删除同一片段、删除区间与插入点相邻等。版本号管理上,服务器维护一个单调递增的revision号,客户端收到的每条广播都带上最新revision,本地操作被服务器确认后也要更新。如果客户端发现自己落后太多,可以选择拉取全量文档快照重置状态,这比补做一长串变换更稳妥。
最后总结几点工程建议:第一,操作一定要在客户端生成而不是服务器diff,这样能保证插入与删除的语义准确;第二,变换只对并发的操作做,已被服务器确认的操作直接应用即可;第三,给每条消息加上操作者ID和时间戳,方便排查同步异常。基于jQuery实现协同编辑器并不复杂,关键在于把操作建模、变换函数和光标修正这三块逻辑理清楚,剩下的就是消息协议与界面细节的打磨了。