前端框架 精选推荐

构建高性能富文本编辑器:从架构设计到性能优化完全指南

HTMLPAGE 团队
18 分钟阅读

深入剖析富文本编辑器的核心架构,探讨虚拟滚动、协同编辑、撤销重做等关键技术的实现原理与优化策略,帮助开发者构建流畅的编辑体验。

#富文本编辑器 #性能优化 #前端架构 #ContentEditable #虚拟DOM

构建高性能富文本编辑器:从架构设计到性能优化完全指南

富文本编辑器是现代 Web 应用中最复杂的组件之一。从简单的博客编辑到专业的文档协作平台,编辑器的性能直接影响用户体验。本文将深入探讨构建高性能编辑器的核心技术与最佳实践。

为什么编辑器开发如此困难

在开始技术实现之前,我们需要理解富文本编辑器面临的独特挑战:

1. ContentEditable 的历史包袱

浏览器原生的 contenteditable 属性看似提供了开箱即用的编辑能力,但实际上充满陷阱:

跨浏览器不一致性:同样的操作在不同浏览器中产生不同的 HTML 结构。例如,按下 Enter 键,Chrome 可能生成 <div>,而 Firefox 生成 <br>

Selection API 复杂性:光标位置和选区的处理在不同浏览器中行为各异,需要大量兼容代码。

DOM 操作不可预测:浏览器会自动"优化" HTML 结构,导致内容模型难以维护。

2. 性能与功能的平衡

编辑器需要实时响应用户输入,同时处理:

  • 复杂的格式化操作
  • 撤销/重做历史管理
  • 实时协同编辑
  • 大文档的渲染和滚动

任何一个环节的性能问题都会导致输入延迟,严重影响用户体验。

3. 内容模型的设计抉择

编辑器的内容模型决定了整个架构:

  • 基于 HTML:直接操作 DOM,实现简单但难以维护
  • 自定义数据结构:如 Slate、ProseMirror 的文档模型,灵活但学习成本高
  • CRDT/OT:支持协同编辑,但实现复杂度极高

核心架构设计

一个高性能编辑器通常采用分层架构:

┌─────────────────────────────────────┐
│         用户界面层 (UI Layer)        │
│  工具栏、菜单、快捷键、拖拽处理        │
├─────────────────────────────────────┤
│        视图层 (View Layer)           │
│  虚拟 DOM、渲染引擎、选区管理         │
├─────────────────────────────────────┤
│        模型层 (Model Layer)          │
│  文档模型、Schema 验证、Transform     │
├─────────────────────────────────────┤
│        历史层 (History Layer)        │
│  撤销/重做、操作合并、状态快照         │
├─────────────────────────────────────┤
│        协同层 (Collaboration Layer)  │
│  OT/CRDT、冲突解决、实时同步          │
└─────────────────────────────────────┘

文档模型设计

文档模型是编辑器的基础。以下是一个类似 Slate.js 的设计:

// 基础节点类型定义
interface BaseNode {
  type: string;
  children?: Node[];
}

// 文本节点 - 编辑器的叶子节点
interface TextNode {
  text: string;
  // 格式化标记
  bold?: boolean;
  italic?: boolean;
  underline?: boolean;
  code?: boolean;
  link?: string;
}

// 元素节点 - 可包含子节点
interface ElementNode extends BaseNode {
  type: 'paragraph' | 'heading' | 'blockquote' | 'code-block' | 'list-item';
  level?: number; // 用于标题级别
  language?: string; // 用于代码块
  children: (ElementNode | TextNode)[];
}

// 编辑器状态
interface EditorState {
  document: ElementNode[];
  selection: Selection | null;
  history: HistoryState;
}

// 选区表示
interface Selection {
  anchor: Point; // 选区起点
  focus: Point;  // 选区终点(可能在 anchor 之前)
}

interface Point {
  path: number[]; // 节点路径,如 [0, 2, 1] 表示第一个块的第三个子节点的第二个子节点
  offset: number; // 文本偏移量
}

为什么采用这种设计?

  1. 树形结构:自然映射到 DOM,便于渲染
  2. 路径寻址:通过 path 数组精确定位任意节点
  3. 不可变数据:便于实现撤销重做和性能优化
  4. Schema 可验证:确保文档结构始终有效

操作(Operations)系统

所有对文档的修改都通过标准化的操作来完成:

// 操作类型定义
type Operation = 
  | InsertTextOperation
  | DeleteTextOperation
  | InsertNodeOperation
  | RemoveNodeOperation
  | SetNodeOperation
  | MergeNodeOperation
  | SplitNodeOperation
  | MoveNodeOperation
  | SetSelectionOperation;

// 插入文本操作
interface InsertTextOperation {
  type: 'insert_text';
  path: number[];
  offset: number;
  text: string;
}

// 删除文本操作
interface DeleteTextOperation {
  type: 'delete_text';
  path: number[];
  offset: number;
  text: string; // 被删除的文本,用于撤销
}

// 设置节点属性
interface SetNodeOperation {
  type: 'set_node';
  path: number[];
  properties: Partial<ElementNode>;
  newProperties: Partial<ElementNode>;
}

// 操作应用函数
function applyOperation(state: EditorState, op: Operation): EditorState {
  switch (op.type) {
    case 'insert_text':
      return applyInsertText(state, op);
    case 'delete_text':
      return applyDeleteText(state, op);
    case 'set_node':
      return applySetNode(state, op);
    // ... 其他操作类型
  }
}

// 操作反转 - 用于撤销
function inverseOperation(op: Operation): Operation {
  switch (op.type) {
    case 'insert_text':
      return {
        type: 'delete_text',
        path: op.path,
        offset: op.offset,
        text: op.text
      };
    case 'delete_text':
      return {
        type: 'insert_text',
        path: op.path,
        offset: op.offset,
        text: op.text
      };
    // ... 其他反转逻辑
  }
}

操作系统的优势

  1. 原子性:每个操作都是最小单位,便于组合和回滚
  2. 可序列化:操作可以通过网络传输,支持协同编辑
  3. 可预测性:相同的操作序列总是产生相同的结果

高性能渲染策略

虚拟 DOM 与差量更新

编辑器不应该在每次修改后重新渲染整个文档。我们需要精确计算变更并只更新受影响的部分:

interface VNode {
  type: string | Component;
  props: Record<string, any>;
  children: (VNode | string)[];
  key?: string;
  el?: HTMLElement; // 对应的真实 DOM 元素
}

class EditorRenderer {
  private container: HTMLElement;
  private vdom: VNode | null = null;

  render(state: EditorState): void {
    const newVdom = this.stateToVdom(state);
    
    if (this.vdom) {
      // 差量更新
      this.patch(this.container, this.vdom, newVdom);
    } else {
      // 首次渲染
      this.container.appendChild(this.createDom(newVdom));
    }
    
    this.vdom = newVdom;
  }

  private stateToVdom(state: EditorState): VNode {
    return {
      type: 'div',
      props: { 
        class: 'editor-content',
        contenteditable: 'true',
        spellcheck: 'false' // 禁用浏览器拼写检查,提升性能
      },
      children: state.document.map((block, index) => 
        this.nodeToVdom(block, [index])
      )
    };
  }

  private nodeToVdom(node: ElementNode | TextNode, path: number[]): VNode | string {
    if ('text' in node) {
      // 文本节点
      return this.renderText(node);
    }

    // 元素节点
    return {
      type: this.getTagName(node.type),
      props: {
        'data-path': path.join(','),
        class: this.getNodeClass(node)
      },
      children: node.children.map((child, index) => 
        this.nodeToVdom(child as ElementNode | TextNode, [...path, index])
      ),
      key: path.join('-')
    };
  }

  private renderText(node: TextNode): VNode | string {
    let content: VNode | string = node.text;
    
    // 按格式包装文本
    if (node.bold) {
      content = { type: 'strong', props: {}, children: [content] };
    }
    if (node.italic) {
      content = { type: 'em', props: {}, children: [content] };
    }
    if (node.code) {
      content = { type: 'code', props: {}, children: [content] };
    }
    if (node.link) {
      content = { 
        type: 'a', 
        props: { href: node.link, target: '_blank' }, 
        children: [content] 
      };
    }
    
    return content;
  }

  private patch(parent: HTMLElement, oldVNode: VNode, newVNode: VNode): void {
    // 类型不同,完全替换
    if (oldVNode.type !== newVNode.type) {
      const newEl = this.createDom(newVNode);
      parent.replaceChild(newEl, oldVNode.el!);
      return;
    }

    // 复用 DOM 元素
    const el = oldVNode.el!;
    newVNode.el = el;

    // 更新属性
    this.patchProps(el, oldVNode.props, newVNode.props);

    // 更新子节点(使用 key 优化)
    this.patchChildren(el, oldVNode.children, newVNode.children);
  }

  private patchChildren(
    parent: HTMLElement, 
    oldChildren: (VNode | string)[], 
    newChildren: (VNode | string)[]
  ): void {
    // 使用 key 进行高效的子节点对比
    // 这里简化处理,实际应实现完整的 diff 算法
    const keyedOld = new Map<string, VNode>();
    
    oldChildren.forEach(child => {
      if (typeof child !== 'string' && child.key) {
        keyedOld.set(child.key, child);
      }
    });

    newChildren.forEach((newChild, index) => {
      if (typeof newChild === 'string') {
        // 文本节点直接更新
        const textNode = parent.childNodes[index];
        if (textNode?.textContent !== newChild) {
          textNode.textContent = newChild;
        }
        return;
      }

      const oldChild = newChild.key ? keyedOld.get(newChild.key) : oldChildren[index];
      
      if (oldChild && typeof oldChild !== 'string') {
        this.patch(parent, oldChild, newChild);
      } else {
        parent.insertBefore(this.createDom(newChild), parent.childNodes[index] || null);
      }
    });
  }
}

长文档虚拟滚动

当文档包含数千个段落时,渲染所有内容会导致严重的性能问题。虚拟滚动只渲染可见区域内的内容:

interface VirtualScrollState {
  scrollTop: number;
  viewportHeight: number;
  itemHeights: Map<string, number>; // 缓存每个块的高度
  estimatedItemHeight: number;
}

class VirtualScrollEditor {
  private state: VirtualScrollState;
  private container: HTMLElement;
  private content: HTMLElement;
  private document: ElementNode[];

  constructor(container: HTMLElement) {
    this.container = container;
    this.state = {
      scrollTop: 0,
      viewportHeight: container.clientHeight,
      itemHeights: new Map(),
      estimatedItemHeight: 24 // 默认行高估算
    };

    this.setupScrollListener();
  }

  private setupScrollListener(): void {
    // 使用 passive 事件监听器提升滚动性能
    this.container.addEventListener('scroll', () => {
      // 使用 requestAnimationFrame 避免过度渲染
      requestAnimationFrame(() => {
        this.state.scrollTop = this.container.scrollTop;
        this.updateVisibleRange();
      });
    }, { passive: true });
  }

  private getVisibleRange(): { start: number; end: number } {
    const { scrollTop, viewportHeight, itemHeights, estimatedItemHeight } = this.state;
    
    // 计算可见区域的起始和结束索引
    let accumulatedHeight = 0;
    let startIndex = 0;
    let endIndex = this.document.length;

    for (let i = 0; i < this.document.length; i++) {
      const itemHeight = itemHeights.get(String(i)) || estimatedItemHeight;
      
      if (accumulatedHeight + itemHeight >= scrollTop && startIndex === 0) {
        // 添加缓冲区,提前渲染上方内容
        startIndex = Math.max(0, i - 5);
      }
      
      if (accumulatedHeight >= scrollTop + viewportHeight) {
        // 添加缓冲区,多渲染下方内容
        endIndex = Math.min(this.document.length, i + 10);
        break;
      }
      
      accumulatedHeight += itemHeight;
    }

    return { start: startIndex, end: endIndex };
  }

  private updateVisibleRange(): void {
    const { start, end } = this.getVisibleRange();
    
    // 计算占位高度
    const topPadding = this.getHeightBeforeIndex(start);
    const bottomPadding = this.getHeightAfterIndex(end);

    // 更新内容区域的 padding 来保持滚动位置
    this.content.style.paddingTop = `${topPadding}px`;
    this.content.style.paddingBottom = `${bottomPadding}px`;

    // 只渲染可见范围内的块
    this.renderBlocks(start, end);
  }

  private renderBlocks(start: number, end: number): void {
    const fragment = document.createDocumentFragment();
    
    for (let i = start; i < end; i++) {
      const block = this.document[i];
      const blockEl = this.renderBlock(block, i);
      fragment.appendChild(blockEl);
    }

    // 使用 replaceChildren 一次性更新,减少重排
    this.content.replaceChildren(fragment);

    // 渲染后测量实际高度并缓存
    requestAnimationFrame(() => {
      this.measureRenderedBlocks(start, end);
    });
  }

  private measureRenderedBlocks(start: number, end: number): void {
    const children = this.content.children;
    
    for (let i = 0; i < children.length; i++) {
      const actualIndex = start + i;
      const height = children[i].getBoundingClientRect().height;
      this.state.itemHeights.set(String(actualIndex), height);
    }
  }
}

虚拟滚动的关键优化点

  1. 高度缓存:避免重复测量 DOM
  2. 缓冲区渲染:在可见区域外多渲染一些内容,避免快速滚动时出现空白
  3. 滚动节流:使用 requestAnimationFrame 合并滚动事件
  4. 批量 DOM 操作:使用 DocumentFragment 减少重排次数

输入处理与性能优化

用户输入是编辑器性能的关键瓶颈。每次按键都需要:

  1. 捕获输入事件
  2. 更新文档模型
  3. 重新渲染
  4. 恢复光标位置

输入事件处理

class InputHandler {
  private editor: Editor;
  private compositionActive = false; // 输入法组合状态
  private pendingOperations: Operation[] = [];
  private flushTimeout: number | null = null;

  constructor(editor: Editor) {
    this.editor = editor;
    this.setupEventListeners();
  }

  private setupEventListeners(): void {
    const el = this.editor.element;

    // 处理输入法组合输入(中文、日文等)
    el.addEventListener('compositionstart', () => {
      this.compositionActive = true;
    });

    el.addEventListener('compositionend', (e: CompositionEvent) => {
      this.compositionActive = false;
      // 组合输入结束后处理完整文本
      this.handleTextInput(e.data);
    });

    // beforeinput 事件提供了更好的输入控制
    el.addEventListener('beforeinput', (e: InputEvent) => {
      if (this.compositionActive) return; // 组合输入时跳过
      
      e.preventDefault(); // 阻止默认行为,自己处理
      this.handleInputEvent(e);
    });

    // 处理粘贴
    el.addEventListener('paste', (e: ClipboardEvent) => {
      e.preventDefault();
      this.handlePaste(e);
    });
  }

  private handleInputEvent(e: InputEvent): void {
    const { inputType, data } = e;

    switch (inputType) {
      case 'insertText':
        this.handleTextInput(data || '');
        break;
      case 'insertParagraph':
      case 'insertLineBreak':
        this.handleEnterKey();
        break;
      case 'deleteContentBackward':
        this.handleBackspace();
        break;
      case 'deleteContentForward':
        this.handleDelete();
        break;
      case 'deleteByCut':
        this.handleCut();
        break;
      case 'formatBold':
        this.toggleFormat('bold');
        break;
      case 'formatItalic':
        this.toggleFormat('italic');
        break;
      // ... 更多输入类型
    }
  }

  private handleTextInput(text: string): void {
    const { selection } = this.editor.state;
    if (!selection) return;

    // 如果有选区,先删除选中内容
    if (!this.isCollapsedSelection(selection)) {
      this.deleteSelection(selection);
    }

    // 插入文本
    const op: InsertTextOperation = {
      type: 'insert_text',
      path: selection.anchor.path,
      offset: selection.anchor.offset,
      text
    };

    // 批量处理操作,避免频繁渲染
    this.queueOperation(op);
  }

  // 操作批处理 - 关键性能优化
  private queueOperation(op: Operation): void {
    this.pendingOperations.push(op);

    // 清除之前的定时器
    if (this.flushTimeout) {
      clearTimeout(this.flushTimeout);
    }

    // 使用 microtask 在当前事件循环结束时批量应用操作
    this.flushTimeout = window.setTimeout(() => {
      this.flushOperations();
    }, 0);
  }

  private flushOperations(): void {
    if (this.pendingOperations.length === 0) return;

    const ops = this.pendingOperations;
    this.pendingOperations = [];
    this.flushTimeout = null;

    // 批量应用操作
    this.editor.applyOperations(ops);
  }

  private handlePaste(e: ClipboardEvent): void {
    const clipboardData = e.clipboardData;
    if (!clipboardData) return;

    // 优先处理 HTML 内容
    const html = clipboardData.getData('text/html');
    if (html) {
      const fragment = this.parseHtml(html);
      this.insertFragment(fragment);
      return;
    }

    // 纯文本回退
    const text = clipboardData.getData('text/plain');
    if (text) {
      this.handleTextInput(text);
    }
  }

  private parseHtml(html: string): ElementNode[] {
    // 创建安全的解析环境
    const parser = new DOMParser();
    const doc = parser.parseFromString(html, 'text/html');
    
    // 清理危险内容
    this.sanitizeHtml(doc.body);
    
    // 转换为编辑器的文档模型
    return this.domToNodes(doc.body);
  }

  private sanitizeHtml(element: HTMLElement): void {
    // 移除脚本标签
    element.querySelectorAll('script').forEach(el => el.remove());
    
    // 移除事件属性
    element.querySelectorAll('*').forEach(el => {
      Array.from(el.attributes).forEach(attr => {
        if (attr.name.startsWith('on')) {
          el.removeAttribute(attr.name);
        }
      });
    });
    
    // 移除 style 标签中的 expression()(IE 漏洞)
    element.querySelectorAll('style').forEach(el => el.remove());
  }
}

性能优化技巧

1. 输入延迟测量与监控

class PerformanceMonitor {
  private inputLatencies: number[] = [];
  private renderTimes: number[] = [];

  measureInputLatency(callback: () => void): void {
    const start = performance.now();
    
    callback();
    
    // 等待渲染完成后测量
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        const latency = performance.now() - start;
        this.inputLatencies.push(latency);
        
        // 如果延迟过高,发出警告
        if (latency > 100) {
          console.warn(`High input latency detected: ${latency.toFixed(2)}ms`);
        }
        
        // 只保留最近 100 次测量
        if (this.inputLatencies.length > 100) {
          this.inputLatencies.shift();
        }
      });
    });
  }

  getAverageLatency(): number {
    if (this.inputLatencies.length === 0) return 0;
    const sum = this.inputLatencies.reduce((a, b) => a + b, 0);
    return sum / this.inputLatencies.length;
  }

  getP95Latency(): number {
    if (this.inputLatencies.length === 0) return 0;
    const sorted = [...this.inputLatencies].sort((a, b) => a - b);
    const index = Math.floor(sorted.length * 0.95);
    return sorted[index];
  }
}

2. 智能防抖与节流

class SmartThrottle {
  private lastCall = 0;
  private timeout: number | null = null;
  private pendingArgs: any[] | null = null;

  constructor(
    private fn: (...args: any[]) => void,
    private options: {
      minInterval: number;  // 最小调用间隔
      maxWait: number;      // 最大等待时间
      leading: boolean;     // 首次立即执行
    }
  ) {}

  call(...args: any[]): void {
    const now = Date.now();
    const elapsed = now - this.lastCall;

    // 首次调用或间隔足够长
    if (this.options.leading && (this.lastCall === 0 || elapsed >= this.options.minInterval)) {
      this.execute(args);
      return;
    }

    // 保存最新参数
    this.pendingArgs = args;

    // 设置延迟执行
    if (!this.timeout) {
      const delay = Math.min(
        this.options.minInterval - elapsed,
        this.options.maxWait
      );

      this.timeout = window.setTimeout(() => {
        if (this.pendingArgs) {
          this.execute(this.pendingArgs);
          this.pendingArgs = null;
        }
        this.timeout = null;
      }, Math.max(0, delay));
    }
  }

  private execute(args: any[]): void {
    this.lastCall = Date.now();
    this.fn(...args);
  }

  cancel(): void {
    if (this.timeout) {
      clearTimeout(this.timeout);
      this.timeout = null;
    }
    this.pendingArgs = null;
  }
}

// 使用示例:优化自动保存
const autoSave = new SmartThrottle(
  (content) => saveToServer(content),
  {
    minInterval: 2000,  // 最少 2 秒保存一次
    maxWait: 10000,     // 最多等待 10 秒
    leading: false       // 不立即保存,等用户停止输入
  }
);

撤销重做系统

撤销重做是编辑器的核心功能之一,直接影响用户体验。

基于操作的历史管理

interface HistoryState {
  undoStack: OperationBatch[];
  redoStack: OperationBatch[];
}

interface OperationBatch {
  operations: Operation[];
  timestamp: number;
  selectionBefore: Selection | null;
  selectionAfter: Selection | null;
}

class HistoryManager {
  private undoStack: OperationBatch[] = [];
  private redoStack: OperationBatch[] = [];
  private currentBatch: Operation[] = [];
  private batchTimeout: number | null = null;
  private lastOperationTime = 0;

  // 批处理时间窗口(毫秒)
  private readonly BATCH_WINDOW = 300;
  // 最大历史记录数
  private readonly MAX_HISTORY = 100;

  addOperation(op: Operation, selection: Selection | null): void {
    const now = Date.now();
    
    // 如果距离上次操作超过时间窗口,创建新批次
    if (now - this.lastOperationTime > this.BATCH_WINDOW && this.currentBatch.length > 0) {
      this.commitBatch();
    }

    this.currentBatch.push(op);
    this.lastOperationTime = now;

    // 清空重做栈(新操作后无法重做)
    this.redoStack = [];

    // 设置批次提交定时器
    if (this.batchTimeout) {
      clearTimeout(this.batchTimeout);
    }
    this.batchTimeout = window.setTimeout(() => {
      this.commitBatch();
    }, this.BATCH_WINDOW);
  }

  private commitBatch(): void {
    if (this.currentBatch.length === 0) return;

    const batch: OperationBatch = {
      operations: [...this.currentBatch],
      timestamp: Date.now(),
      selectionBefore: null, // 实际实现中需要记录
      selectionAfter: null
    };

    this.undoStack.push(batch);
    this.currentBatch = [];

    // 限制历史记录数量
    if (this.undoStack.length > this.MAX_HISTORY) {
      this.undoStack.shift();
    }

    if (this.batchTimeout) {
      clearTimeout(this.batchTimeout);
      this.batchTimeout = null;
    }
  }

  undo(editor: Editor): boolean {
    // 先提交当前未完成的批次
    this.commitBatch();

    if (this.undoStack.length === 0) return false;

    const batch = this.undoStack.pop()!;
    
    // 反转操作顺序并应用
    const inverseOps = batch.operations
      .slice()
      .reverse()
      .map(op => inverseOperation(op));

    editor.applyOperations(inverseOps);

    // 移动到重做栈
    this.redoStack.push(batch);

    // 恢复选区
    if (batch.selectionBefore) {
      editor.setSelection(batch.selectionBefore);
    }

    return true;
  }

  redo(editor: Editor): boolean {
    if (this.redoStack.length === 0) return false;

    const batch = this.redoStack.pop()!;
    
    // 重新应用原始操作
    editor.applyOperations(batch.operations);

    // 移回撤销栈
    this.undoStack.push(batch);

    // 恢复选区
    if (batch.selectionAfter) {
      editor.setSelection(batch.selectionAfter);
    }

    return true;
  }

  // 合并相似操作(如连续输入)
  mergeSimilarOperations(ops: Operation[]): Operation[] {
    const merged: Operation[] = [];
    
    for (const op of ops) {
      const last = merged[merged.length - 1];
      
      // 合并连续的文本插入
      if (
        last &&
        last.type === 'insert_text' &&
        op.type === 'insert_text' &&
        this.arePathsEqual(last.path, op.path) &&
        last.offset + last.text.length === op.offset
      ) {
        last.text += op.text;
        continue;
      }
      
      merged.push(op);
    }
    
    return merged;
  }

  private arePathsEqual(a: number[], b: number[]): boolean {
    return a.length === b.length && a.every((v, i) => v === b[i]);
  }
}

智能操作合并

用户通常希望撤销"一次编辑",而不是每个字符。智能合并让撤销更符合用户预期:

class SmartHistoryManager extends HistoryManager {
  // 判断是否应该开始新的撤销批次
  shouldBreakBatch(currentOp: Operation, lastOp: Operation | null): boolean {
    if (!lastOp) return false;

    // 操作类型不同
    if (currentOp.type !== lastOp.type) return true;

    // 不同位置的操作
    if (!this.arePathsEqual(currentOp.path, lastOp.path)) return true;

    // 输入了特殊字符(空格、标点、换行)
    if (currentOp.type === 'insert_text') {
      const specialChars = /[\s.,!?;:'"()\[\]{}]/;
      if (specialChars.test(currentOp.text)) return true;
    }

    // 删除操作方向改变
    if (currentOp.type === 'delete_text' && lastOp.type === 'delete_text') {
      // 如果删除方向从后向前变成从前向后,开始新批次
      // 这里需要更复杂的逻辑来判断
    }

    return false;
  }

  // 自定义合并策略
  getMergeStrategy(opType: string): 'always' | 'smart' | 'never' {
    switch (opType) {
      case 'insert_text':
        return 'smart'; // 智能合并连续输入
      case 'delete_text':
        return 'smart'; // 智能合并连续删除
      case 'set_node':
        return 'never'; // 格式变更不合并
      default:
        return 'smart';
    }
  }
}

实战:完整的编辑器组件

将以上概念整合成一个可用的编辑器组件:

// editor-core.ts
class Editor {
  private container: HTMLElement;
  private state: EditorState;
  private renderer: EditorRenderer;
  private inputHandler: InputHandler;
  private history: HistoryManager;
  private plugins: Plugin[] = [];

  constructor(container: HTMLElement, options: EditorOptions = {}) {
    this.container = container;
    this.state = this.createInitialState(options.initialContent);
    this.renderer = new EditorRenderer(container);
    this.inputHandler = new InputHandler(this);
    this.history = new HistoryManager();

    // 初始化插件
    this.plugins = (options.plugins || []).map(Plugin => new Plugin(this));

    // 首次渲染
    this.render();
  }

  private createInitialState(content?: ElementNode[]): EditorState {
    return {
      document: content || [
        {
          type: 'paragraph',
          children: [{ text: '' }]
        }
      ],
      selection: null,
      history: {
        undoStack: [],
        redoStack: []
      }
    };
  }

  // 应用操作
  applyOperations(ops: Operation[]): void {
    let newState = this.state;

    for (const op of ops) {
      newState = applyOperation(newState, op);
      this.history.addOperation(op, newState.selection);
    }

    this.state = newState;
    
    // 通知插件
    this.plugins.forEach(plugin => plugin.onStateChange?.(this.state));

    // 重新渲染
    this.render();
  }

  // 渲染
  private render(): void {
    this.renderer.render(this.state);
    
    // 渲染后恢复选区
    if (this.state.selection) {
      this.restoreSelection(this.state.selection);
    }
  }

  // 撤销
  undo(): void {
    this.history.undo(this);
  }

  // 重做
  redo(): void {
    this.history.redo(this);
  }

  // 获取内容
  getContent(): ElementNode[] {
    return this.state.document;
  }

  // 获取 HTML
  getHtml(): string {
    return this.renderer.toHtml(this.state.document);
  }

  // 设置选区
  setSelection(selection: Selection): void {
    this.state = { ...this.state, selection };
    this.restoreSelection(selection);
  }

  private restoreSelection(selection: Selection): void {
    const domSelection = window.getSelection();
    if (!domSelection) return;

    try {
      const anchorNode = this.pathToDomNode(selection.anchor.path);
      const focusNode = this.pathToDomNode(selection.focus.path);

      if (anchorNode && focusNode) {
        const range = document.createRange();
        range.setStart(anchorNode, selection.anchor.offset);
        range.setEnd(focusNode, selection.focus.offset);

        domSelection.removeAllRanges();
        domSelection.addRange(range);
      }
    } catch (e) {
      console.error('Failed to restore selection:', e);
    }
  }

  private pathToDomNode(path: number[]): Node | null {
    let node: Element | null = this.container.querySelector('[data-path="' + path.slice(0, -1).join(',') + '"]');
    
    if (!node) return null;

    // 找到对应的文本节点
    const textIndex = path[path.length - 1];
    let textNodeIndex = 0;
    
    for (const child of node.childNodes) {
      if (child.nodeType === Node.TEXT_NODE) {
        if (textNodeIndex === textIndex) {
          return child;
        }
        textNodeIndex++;
      }
    }

    return node.firstChild;
  }

  // 销毁
  destroy(): void {
    this.plugins.forEach(plugin => plugin.destroy?.());
    this.inputHandler.destroy?.();
  }
}

// 使用示例
const editor = new Editor(document.getElementById('editor')!, {
  initialContent: [
    {
      type: 'heading',
      level: 1,
      children: [{ text: '欢迎使用高性能编辑器' }]
    },
    {
      type: 'paragraph',
      children: [{ text: '开始编写您的内容...' }]
    }
  ],
  plugins: [
    MarkdownShortcutsPlugin,
    AutoSavePlugin,
    CollaborationPlugin
  ]
});

性能调优清单

在发布编辑器之前,请确保完成以下优化:

渲染性能

  • 使用虚拟 DOM 进行差量更新
  • 大文档启用虚拟滚动
  • 避免强制同步布局(读写分离)
  • 使用 will-change: transform 优化滚动层
  • 禁用不必要的浏览器功能(拼写检查、自动填充)

输入性能

  • 操作批处理减少渲染次数
  • 使用 beforeinput 替代 keydown/keypress
  • 正确处理输入法组合输入
  • 粘贴内容异步解析

内存管理

  • 限制历史记录数量
  • 大文档使用惰性加载
  • 及时清理事件监听器
  • 避免闭包导致的内存泄漏

用户体验

  • 输入延迟 < 100ms
  • 滚动流畅(60fps)
  • 撤销重做符合用户预期
  • 提供加载状态反馈

总结

构建高性能富文本编辑器是前端开发中最具挑战性的任务之一。本文介绍了:

  1. 文档模型设计:采用树形结构和不可变数据,便于操作和维护
  2. 操作系统:所有修改通过标准化操作完成,支持撤销重做和协同编辑
  3. 高效渲染:虚拟 DOM 差量更新和虚拟滚动处理大文档
  4. 输入优化:操作批处理、输入法支持、性能监控
  5. 历史管理:智能操作合并,提供自然的撤销体验

编辑器开发没有银弹,需要根据具体场景在功能、性能和复杂度之间取得平衡。建议从成熟的开源项目(如 Slate、ProseMirror、Lexical)入手,理解其设计思想后再进行定制开发。

延伸阅读