协同编辑是现代代码编辑器迈向团队协作的关键能力,让多位开发者能同时编辑同一份文档而互不干扰。从操作转换(OT)算法到无冲突复制数据类型(CRDT),从冲突检测与解决到实时同步架构,本文系统介绍编辑器协同编辑的完整实现方案。
协同编辑概述
协同编辑的核心挑战
协同编辑要解决的根本问题是:如何让多个客户端在没有全局锁的情况下,独立编辑同一份文档,并最终达到一致的状态。这涉及以下几个核心挑战:
- 并发操作 - 多个用户同时编辑同一位置,操作顺序不可预测
- 网络延迟 - 操作的传播存在延迟,用户可能基于过时状态进行编辑
- 因果一致性 - 操作之间存在因果关系,需要保证因果顺序
- 意图保持 - 合并操作时尽量保留用户的原始编辑意图
协同编辑模型分类
// 协同编辑模型分类
const collaborationModels = {
// 集中式 - 所有操作经过服务器转换后分发
centralized: {
algorithm: 'OT (Operational Transformation)',
server: '权威服务器,负责操作排序与转换',
consistency: '强一致性',
examples: ['Google Docs', 'VS Code Live Share']
},
// 去中心化 - 各客户端独立合并操作
decentralized: {
algorithm: 'CRDT (Conflict-free Replicated Data Type)',
server: '消息中继,不做转换',
consistency: '最终一致性',
examples: ['Figma', 'Automerge', 'Yjs']
}
};
OT操作转换算法
操作模型定义
// OT操作的基础模型
class TextOperation {
constructor() {
this.ops = []; // 操作序列
this.baseLength = 0; // 操作前文档长度
this.targetLength = 0; // 操作后文档长度
}
// 保留n个字符(跳过)
retain(n) {
if (n <= 0) return this;
this.baseLength += n;
this.targetLength += n;
this.ops.push(n);
return this;
}
// 插入字符串
insert(str) {
if (str.length === 0) return this;
this.targetLength += str.length;
this.ops.push(str);
return this;
}
// 删除n个字符
delete(n) {
if (n <= 0) return this;
this.baseLength += n;
this.ops.push(-n);
return this;
}
// 应用操作到文档
apply(doc) {
const result = [];
let index = 0;
for (const op of this.ops) {
if (typeof op === 'number') {
if (op > 0) {
// retain
result.push(doc.substring(index, index + op));
index += op;
} else {
// delete
index -= op;
}
} else {
// insert
result.push(op);
}
}
return result.join('');
}
}
操作转换核心
// OT的核心:transform函数
// 给定两个基于同一版本的操作a和b,
// 返回 a' 和 b',使得 apply(apply(doc, a), b') === apply(apply(doc, b), a')
function transform(operation1, operation2) {
const ops1 = operation1.ops;
const ops2 = operation2.ops;
const i1 = new OperationIterator(ops1);
const i2 = new OperationIterator(ops2);
const prime1 = new TextOperation();
const prime2 = new TextOperation();
while (i1.hasNext() || i2.hasNext()) {
const op1 = i1.peek();
const op2 = i2.peek();
if (op1 === null) {
// operation1已结束,operation2剩余部分放入prime1
if (typeof op2 === 'string') {
prime1.insert(op2);
} else {
prime1.retain(op2);
}
i2.next();
continue;
}
if (op2 === null) {
// operation2已结束,operation1剩余部分放入prime2
if (typeof op1 === 'string') {
prime2.insert(op1);
} else {
prime2.retain(op1);
}
i1.next();
continue;
}
// 两个操作都是retain
if (typeof op1 === 'number' && op1 > 0 &&
typeof op2 === 'number' && op2 > 0) {
const minLen = Math.min(op1, op2);
prime1.retain(minLen);
prime2.retain(minLen);
i1.consume(minLen);
i2.consume(minLen);
}
// operation1是insert
else if (typeof op1 === 'string') {
prime2.insert(op1);
i1.next();
}
// operation2是insert
else if (typeof op2 === 'string') {
prime1.insert(op2);
i2.next();
}
// 两个操作都是delete - 互相抵消
else if (typeof op1 === 'number' && op1 < 0 &&
typeof op2 === 'number' && op2 < 0) {
const minLen = Math.min(-op1, -op2);
i1.consume(minLen);
i2.consume(minLen);
}
// operation1是delete,operation2是retain
else if (typeof op1 === 'number' && op1 < 0) {
const minLen = Math.min(-op1, op2);
prime1.delete(minLen);
i1.consume(minLen);
i2.consume(minLen);
}
// operation1是retain,operation2是delete
else {
const minLen = Math.min(op1, -op2);
prime2.delete(minLen);
i1.consume(minLen);
i2.consume(minLen);
}
}
return [prime1, prime2];
}
OT服务器实现
// OT协同服务器
class OTServer {
constructor() {
this.documents = new Map(); // docId → 文档状态
this.revisions = new Map(); // docId → 修订历史
}
async handleOperation(docId, clientId, operation, revision) {
const doc = this.documents.get(docId);
const revs = this.revisions.get(docId);
// 客户端的操作基于revision版本
// 需要将其转换到当前最新版本
let transformedOp = operation;
for (let i = revision; i < revs.length; i++) {
const [prime1, prime2] = transform(
transformedOp, revs[i].operation
);
transformedOp = prime1;
}
// 应用转换后的操作到文档
doc.content = transformedOp.apply(doc.content);
doc.version++;
// 记录修订
revs.push({
operation: transformedOp,
clientId,
version: doc.version,
timestamp: Date.now()
});
return {
operation: transformedOp,
version: doc.version
};
}
}
CRDT无冲突复制数据类型
CRDT基础概念
CRDT(Conflict-free Replicated Data Type)是一种数学上保证最终一致性的数据结构。与OT不同,CRDT不需要服务器参与操作转换,每个客户端可以独立合并任何顺序的操作,只要操作最终都到达了所有节点,最终状态必然一致。
// RGA (Replicated Growable Array) - 用于文本的CRDT
class RGA {
constructor(siteId) {
this.siteId = siteId;
this.clock = 0; // 本地逻辑时钟
this.nodes = new Map(); // id → 节点
this.left = []; // 按插入顺序的节点ID链
// 插入哨兵节点
const sentinel = {
id: { site: 'root', clock: 0 },
char: '',
deleted: false,
originLeft: null
};
this.nodes.set('root:0', sentinel);
this.left.push(sentinel.id);
}
// 生成唯一ID
generateId() {
this.clock++;
return { site: this.siteId, clock: this.clock };
}
// 本地插入操作
localInsert(index, char) {
const id = this.generateId();
const leftId = this.getVisibleNodeAt(index - 1);
const node = {
id,
char,
deleted: false,
originLeft: leftId
};
this.insertNode(node);
return { type: 'insert', node };
}
// 本地删除操作
localDelete(index) {
const targetId = this.getVisibleNodeAt(index);
const node = this.nodes.get(this.idToKey(targetId));
node.deleted = true;
return { type: 'delete', targetId };
}
// 远程操作合并
applyRemote(operation) {
if (operation.type === 'insert') {
// 更新本地时钟
this.clock = Math.max(
this.clock, operation.node.id.clock
);
// 插入节点(RGA会根据ID排序自动找到正确位置)
this.insertNode(operation.node);
} else if (operation.type === 'delete') {
const node = this.nodes.get(
this.idToKey(operation.targetId)
);
if (node) node.deleted = true;
}
}
// 插入节点到正确位置
insertNode(node) {
const key = this.idToKey(node.id);
this.nodes.set(key, node);
// 找到originLeft节点,在其后插入
// 如果有并发插入到同一位置,按ID的clock降序排列
const leftIdx = this.left.findIndex(
id => this.idEquals(id, node.originLeft)
);
let insertIdx = leftIdx + 1;
// 跳过同位置上clock更大的并发节点
while (insertIdx < this.left.length) {
const existingNode = this.nodes.get(
this.idToKey(this.left[insertIdx])
);
if (existingNode?.originLeft &&
this.idEquals(existingNode.originLeft, node.originLeft) &&
existingNode.id.clock > node.id.clock) {
insertIdx++;
} else {
break;
}
}
this.left.splice(insertIdx, 0, node.id);
}
// 获取文档文本
getText() {
return this.left
.map(id => this.nodes.get(this.idToKey(id)))
.filter(n => n && !n.deleted)
.map(n => n.char)
.join('');
}
idToKey(id) { return `${id.site}:${id.clock}`; }
idEquals(a, b) { return a.site === b.site && a.clock === b.clock; }
getVisibleNodeAt(index) {
let count = -1;
for (const id of this.left) {
const node = this.nodes.get(this.idToKey(id));
if (!node.deleted) {
count++;
if (count === index) return id;
}
}
return this.left[this.left.length - 1];
}
}
Yjs风格CRDT优化
// Yjs风格的YText CRDT实现(简化版)
class YText {
constructor(doc) {
this.doc = doc;
this.start = null; // 双向链表头
this.end = null; // 双向链表尾
this.length = 0;
}
// 插入文本
insert(index, text) {
const chunks = this.splitIntoChunks(text);
let pos = this.findPosition(index);
for (const chunk of chunks) {
const item = this.doc.createItem(chunk, {
left: pos.item,
right: pos.item?.right,
origin: pos.item?.id,
rightOrigin: pos.item?.right?.id
});
this.integrateItem(item);
pos.item = item;
}
}
// 删除文本
delete(index, len) {
let pos = this.findPosition(index);
let remaining = len;
while (remaining > 0 && pos.item) {
if (!pos.item.deleted) {
const deleteLen = Math.min(
remaining, pos.item.length - pos.offset
);
pos.item.markDeleted(pos.offset, deleteLen);
remaining -= deleteLen;
this.length -= deleteLen;
}
pos = this.nextPosition(pos);
}
}
// 合并远程操作
integrateItem(item) {
// 根据origin和rightOrigin找到正确的插入位置
let left = item.left ? this.doc.getItem(item.left) : null;
let right = item.right ? this.doc.getItem(item.right) : null;
// 处理并发插入:扫描同级节点找到正确位置
if (left && left.right !== right) {
let scanning = left.right;
while (scanning && scanning !== right) {
if (this.doc.compareIds(scanning.id, item.id) < 0) {
left = scanning;
}
scanning = scanning.right;
}
}
// 插入到链表中
item.left = left;
item.right = right;
if (left) left.right = item;
else this.start = item;
if (right) right.left = item;
else this.end = item;
this.length += item.length;
}
// 获取文本内容
toString() {
const parts = [];
let current = this.start;
while (current) {
if (!current.deleted) {
parts.push(current.content);
}
current = current.right;
}
return parts.join('');
}
}
冲突检测与解决
向量时钟与因果关系
// 向量时钟 - 检测操作的因果关系
class VectorClock {
constructor(siteId) {
this.siteId = siteId;
this.clock = new Map(); // siteId → clock
}
// 本地事件递增
increment() {
const current = this.clock.get(this.siteId) || 0;
this.clock.set(this.siteId, current + 1);
}
// 合并远程时钟
merge(other) {
for (const [site, time] of other.clock) {
const local = this.clock.get(site) || 0;
this.clock.set(site, Math.max(local, time));
}
}
// 判断是否在other之前(因果关系)
happenedBefore(other) {
let atLeastOneLess = false;
for (const [site, time] of this.clock) {
const otherTime = other.clock.get(site) || 0;
if (time > otherTime) return false;
if (time < otherTime) atLeastOneLess = true;
}
return atLeastOneLess;
}
// 判断是否并发
isConcurrentWith(other) {
return !this.happenedBefore(other) &&
!other.happenedBefore(this);
}
}
// 冲突检测器
class ConflictDetector {
constructor() {
this.pendingOps = new Map(); // siteId → 操作队列
}
detectConflict(op1, op2) {
const clock1 = op1.vectorClock;
const clock2 = op2.vectorClock;
// 有因果关系,不冲突
if (clock1.happenedBefore(clock2) ||
clock2.happenedBefore(clock1)) {
return null;
}
// 并发操作,检查是否影响重叠区域
if (this.rangesOverlap(op1.range, op2.range)) {
return {
type: 'concurrent-edit',
operations: [op1, op2],
overlapRange: this.computeOverlap(op1.range, op2.range)
};
}
return null;
}
// 冲突解决策略
async resolveConflict(conflict) {
const strategies = {
// 策略1:最后写入者获胜
'last-writer-wins': () => {
const sorted = conflict.operations.sort(
(a, b) => b.timestamp - a.timestamp
);
return sorted[0];
},
// 策略2:用户选择
'user-choice': async () => {
return await this.showConflictUI(conflict);
},
// 策略3:自动合并(基于CRDT,不需要额外处理)
'auto-merge': () => {
return conflict.operations; // 两个操作都保留
}
};
const strategy = this.getStrategy(conflict.type);
return strategies[strategy]();
}
}
实时同步架构
WebSocket同步服务
// WebSocket协同同步服务
class CollabSyncService {
constructor(docId, userId) {
this.docId = docId;
this.userId = userId;
this.ws = null;
this.pendingOps = []; // 待发送的操作
this.ackTimeout = 5000; // 确认超时
this.reconnectDelay = 1000;
this.maxReconnectDelay = 30000;
}
// 连接到协同服务器
async connect(serverUrl) {
this.ws = new WebSocket(
`${serverUrl}/collab?doc=${this.docId}&user=${this.userId}`
);
this.ws.onopen = () => {
this.flushPendingOps();
this.emit('connected');
};
this.ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
this.handleMessage(msg);
};
this.ws.onclose = () => {
this.emit('disconnected');
this.scheduleReconnect(serverUrl);
};
}
// 发送本地操作
sendOperation(operation) {
const message = {
type: 'operation',
docId: this.docId,
userId: this.userId,
operation,
timestamp: Date.now()
};
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
} else {
this.pendingOps.push(message);
}
}
// 处理服务器消息
handleMessage(msg) {
switch (msg.type) {
case 'operation':
// 远程操作,应用到本地文档
if (msg.userId !== this.userId) {
this.emit('remote-operation', msg.operation);
}
break;
case 'ack':
// 操作确认
this.emit('ack', msg.operationId);
break;
case 'cursor':
// 其他用户的光标位置
this.emit('cursor-update', {
userId: msg.userId,
cursor: msg.cursor,
selection: msg.selection
});
break;
case 'user-join':
this.emit('user-joined', msg.user);
break;
case 'user-leave':
this.emit('user-left', msg.userId);
break;
case 'sync-start':
// 服务器发起的同步请求
this.handleSyncStart(msg);
break;
}
}
// 断线重连
scheduleReconnect(serverUrl) {
const delay = Math.min(
this.reconnectDelay,
this.maxReconnectDelay
);
this.reconnectDelay *= 2;
setTimeout(() => {
this.connect(serverUrl);
}, delay);
}
flushPendingOps() {
while (this.pendingOps.length > 0) {
const msg = this.pendingOps.shift();
this.ws.send(JSON.stringify(msg));
}
}
}
光标与选区同步
// 光标同步管理
class CursorSyncManager {
constructor(syncService) {
this.syncService = syncService;
this.cursors = new Map(); // userId → 光标信息
this.debounceTimer = null;
this.colors = [
'#ff6b6b', '#4ecdc4', '#45b7d1',
'#96ceb4', '#feca57', '#ff9ff3'
];
this.colorIndex = 0;
syncService.on('cursor-update', (data) => {
this.updateRemoteCursor(data);
});
syncService.on('user-left', (userId) => {
this.cursors.delete(userId);
this.renderCursors();
});
}
// 本地光标变更通知
onLocalCursorChange(cursor, selection) {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
this.syncService.send({
type: 'cursor',
cursor,
selection
});
}, 50); // 50ms防抖
}
updateRemoteCursor(data) {
if (!this.cursors.has(data.userId)) {
this.cursors.set(data.userId, {
color: this.colors[this.colorIndex++ % this.colors.length]
});
}
const cursorInfo = this.cursors.get(data.userId);
cursorInfo.cursor = data.cursor;
cursorInfo.selection = data.selection;
this.renderCursors();
}
renderCursors() {
// 渲染远程用户的光标和选区到编辑器
for (const [userId, info] of this.cursors) {
renderCursorDecoration(userId, {
position: info.cursor,
selection: info.selection,
color: info.color,
label: info.userName || userId.slice(0, 4)
});
}
}
}
实战:构建协同编辑原型
整合OT与WebSocket
// 完整的协同编辑客户端
class CollaborativeEditor {
constructor(editorElement, options) {
this.editor = new TextEditor(editorElement);
this.sync = new CollabSyncService(options.docId, options.userId);
this.cursorSync = new CursorSyncManager(this.sync);
this.revision = 0;
this.pendingOp = null;
this.setupEventListeners();
}
setupEventListeners() {
// 监听编辑器变更
this.editor.onChange((change) => {
const op = this.changeToOperation(change);
if (!this.pendingOp) {
this.pendingOp = op;
} else {
// 合并连续操作以减少网络传输
this.pendingOp = this.compose(this.pendingOp, op);
}
this.sendPendingOp();
});
// 监听远程操作
this.sync.on('remote-operation', (op) => {
if (this.pendingOp) {
// 如果有待确认的本地操作,需要先转换
const [clientPrime, serverPrime] = transform(
this.pendingOp, op
);
this.pendingOp = clientPrime;
op = serverPrime;
}
// 应用远程操作到编辑器
this.editor.applyOperation(op, { source: 'remote' });
this.revision++;
});
// 监听光标变更
this.editor.onCursorChange((cursor, selection) => {
this.cursorSync.onLocalCursorChange(cursor, selection);
});
}
// 发送待确认的操作(带防抖)
sendPendingOp() {
clearTimeout(this.sendTimer);
this.sendTimer = setTimeout(() => {
if (this.pendingOp) {
this.sync.sendOperation(this.pendingOp, this.revision);
}
}, 100); // 100ms防抖
}
changeToOperation(change) {
const op = new TextOperation();
if (change.rangeOffset > 0) {
op.retain(change.rangeOffset);
}
if (change.rangeLength > 0) {
op.delete(change.rangeLength);
}
if (change.text) {
op.insert(change.text);
}
const remaining = this.editor.getLength() -
change.rangeOffset - change.text.length;
if (remaining > 0) op.retain(remaining);
return op;
}
// 合并两个连续操作
compose(op1, op2) {
// op1和op2是连续的操作,合并为一个等价操作
const result = new TextOperation();
// ... 合并逻辑
return result;
}
}
总结
- 协同编辑概述 - 多人同时编辑面临并发操作、网络延迟、因果一致性和意图保持四大挑战
- OT操作转换算法 - 通过transform函数将并发操作转换为可顺序执行的操作,保证强一致性,需要服务器参与
- CRDT无冲突复制数据类型 - 通过数学结构保证最终一致性,无需服务器转换,RGA和YText是常用实现
- 冲突检测与解决 - 向量时钟判断因果关系,并发操作通过LWW、用户选择或自动合并策略解决
- 实时同步架构 - WebSocket双向通信、操作防抖、断线重连、光标选区同步构成完整的实时协作体验
- 实战原型 - 将OT/CRDT与编辑器变更事件、远程操作应用和光标同步整合为完整的协同编辑系统
协同编辑是编辑器从个人工具走向团队协作的桥梁。OT和CRDT提供了两种不同的理论基础,各有优劣:OT需要服务器参与但意图保持更好,CRDT去中心化但元数据开销更大。选择哪种方案取决于具体场景的需求权衡。