编辑器架构

从零构建 Web 代码编辑器

构建一个功能完善的代码编辑器是前端工程中的挑战性任务。本文介绍编辑器的核心架构设计,包括数据模型、光标实现、撤销重做与命令模式等关键模块。

一、整体架构设计

编辑器采用经典的 MVC + 命令模式架构:

  • Model(模型):文档数据、选择范围、撤销栈
  • View(视图):DOM 渲染、滚动管理、高亮绘制
  • Controller(控制器):输入处理、命令分发、状态管理
/* 编辑器核心类结构 */
class Editor {
    constructor(container) {
        this.document = new TextDocument();
        this.selection = new Selection();
        this.undoManager = new UndoManager();
        this.view = new EditorView(container, this);
        this.commands = new CommandRegistry();
        this.bindEvents();
    }
}

二、文档数据模型

文档模型需要支持高效的插入、删除、查询操作。使用 rope(绳索)数据结构可以优化大文件的修改性能:

/* 简单的行数组实现 */
class TextDocument {
    constructor() {
        this.lines = [''];  /* 初始空行 */
    }

    getText() {
        return this.lines.join('\n');
    }

    insert(pos, text) {
        const {line, col} = this.resolvePos(pos);
        const newLines = text.split('\n');
        const first = this.lines[line];
        this.lines[line] = first.slice(0, col) + newLines[0];
        this.lines.splice(line + 1, 0, ...newLines.slice(1).map((l, i) =>
            l + (i === newLines.length - 1 ? first.slice(col) : '')
        );
    }

    delete(start, end) {
        /* 删除指定范围的文本 */
    }
}

三、光标与选区实现

光标位置使用行号 + 列号表示,需要处理各种边界情况:

/* 选区管理 */
class Selection {
    constructor() {
        this.anchor = {line: 0, col: 0};  /* 选区起点 */
        this.head = {line: 0, col: 0};   /* 选区终点 */
    }

    isCollapsed() {
        return this.anchor.line === this.head.line &&
               this.anchor.col === this.head.col;
    }

    normalize() {
        /* 确保 anchor 在 head 之前 */
    }

    moveTo(line, col) {
        this.anchor = {line, col};
        this.head = {line, col};
    }

    extendTo(line, col) {
        this.head = {line, col};
    }
}

光标渲染技巧

使用 CSS 实现闪烁光标效果:

/* CSS 光标动画 */
.cursor {
    position: absolute;
    width: 2px;
    background: #34d399;
    animation: blink 1s step-end infinite;
}

@keyframes blink {
    0%, 100% { opacity: 1; }
    50% { opacity: 0; }
}

四、撤销与重做系统

使用命令模式实现撤销重做,将每个操作封装为可逆的命令对象:

/* 命令基类 */
class Command {
    execute(editor) {}
    undo(editor) {}
}

/* 插入命令 */
class InsertCommand extends Command {
    constructor(pos, text) {
        super();
        this.pos = pos;
        this.text = text;
    }

    execute(editor) {
        this.savedSel = editor.selection.get();
        editor.document.insert(this.pos, this.text);
    }

    undo(editor) {
        editor.document.delete(this.pos, this.pos + this.text.length);
        editor.selection.set(this.savedSel);
    }
}

/* 撤销管理器 */
class UndoManager {
    constructor(maxSize = 100) {
        this.undoStack = [];
        this.redoStack = [];
        this.maxSize = maxSize;
    }

    execute(command, editor) {
        command.execute(editor);
        this.undoStack.push(command);
        this.redoStack = [];  /* 新命令清除重做栈 */
        if (this.undoStack.length > this.maxSize) {
            this.undoStack.shift();
        }
    }

    undo(editor) {
        const cmd = this.undoStack.pop();
        if (cmd) {
            cmd.undo(editor);
            this.redoStack.push(cmd);
        }
    }

    redo(editor) {
        const cmd = this.redoStack.pop();
        if (cmd) {
            cmd.execute(editor);
            this.undoStack.push(cmd);
        }
    }
}

五、输入处理与事件流

处理键盘输入时需要考虑组合键、输入法与移动端虚拟键盘:

/* 输入处理流程 */
class InputHandler {
    handleKeyDown(e) {
        /* 处理快捷键 */
        if (e.ctrlKey || e.metaKey) {
            return this.handleShortcut(e);
        }

        /* 导航键 */
        if (['ArrowUp', 'ArrowDown'].includes(e.key)) {
            return this.handleNavigation(e);
        }

        return false;  /* 让浏览器处理其他键 */
    }

    handleInput(text) {
        /* 处理实际输入的文本(可能包含组合字符) */
        const cmd = new InsertCommand(editor.selection.get().getHead(), text);
        editor.undoManager.execute(cmd, editor);
        editor.view.render();
    }
}

六、视图渲染优化

将视图层与数据层分离,使用 requestAnimationFrame 实现平滑渲染:

/* 视图渲染器 */
class EditorView {
    render() {
        if (this.scheduled) return;
        this.scheduled = true;
        requestAnimationFrame(() => {
            this.doRender();
            this.scheduled = false;
        });
    }

    doRender() {
        /* 只更新变化的 DOM 节点 */
    }
}
核心原则:数据驱动视图。每次数据变化后,视图根据数据重新渲染,而非直接操作 DOM。

七、总结

  • 采用 MVC + 命令模式,分离关注点
  • 文档模型使用行数组,支持高效文本操作
  • 撤销重做通过命令模式实现,支持无限撤销
  • 输入处理要区分快捷键和普通输入
  • 视图渲染使用 requestAnimationFrame 优化性能
  • 光标使用 CSS 动画,无需 JavaScript 参与