代码编辑器协同编辑完全指南

编辑器核心协同编辑

协同编辑是现代代码编辑器迈向团队协作的关键能力,让多位开发者能同时编辑同一份文档而互不干扰。从操作转换(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去中心化但元数据开销更大。选择哪种方案取决于具体场景的需求权衡。