前端知库

文本编辑器为什么不能只用一个字符串保存全文?

比较字符串、数组、Piece Table 和 Rope 在插入删除、撤销、内存和渲染上的取舍,说明编辑数据结构与 DOM 渲染、协同同步是不同层次的问题。

参考答案

简答

  • 用单个字符串保存全文,每次在中间插入都要复制整段文本,大文件下代价很高。
  • Piece Table 保留原始缓冲区和只追加的新增缓冲区,用一组 piece 描述当前文档。
  • 插入和删除只修改 piece 序列,不复制原有文本。
  • Rope 用平衡树组织文本块,适合频繁的大范围编辑。

文本框只有几百字时,value = value.slice(0, index) + insert + value.slice(index) 很简单;当文件达到几十 MB、用户持续输入、需要撤销和语法分析时,每次修改都复制整串内容会产生越来越大的分配和 GC 压力。

编辑器要同时处理文本存储、光标位置、撤销历史、行索引、增量解析和可视区域渲染。没有一种数据结构能自动解决所有问题,先明确哪种操作最频繁,再选择表示。

常见表示各有代价

表示优点主要问题
单个字符串简单、查找和序列化直接中间插入/删除需要复制大量内容
字符数组局部修改直观单个字符的数组元素和移动成本很高
Piece Table原始文本不可变,修改只追加描述片段查找和索引需要维护片段结构
Rope用树组织字符串片段,中间修改可控制实现、平衡、行列索引和序列化更复杂

JavaScript 字符串按 UTF-16 码元索引,用户看到的字素簇、编辑器列号和字节偏移不是同一个单位。中文、Emoji、组合字符和换行符都要在编辑模型中明确约定,不能直接把 string.length 当作用户光标列。

Piece Table 为什么适合编辑器

Piece Table 保存一份不可变的原始缓冲区和一份只追加的新文本缓冲区,当前文档由一串 piece 描述:每个 piece 指向哪个缓冲区、起点和长度。插入操作不复制旧文本,只追加新文本并调整 piece 序列;删除通常只是拆分或移除描述。

JavaScript
class PieceTable {
  constructor(text = '') {
    if (typeof text !== 'string') throw new TypeError('初始内容必须是字符串');
    this.original = text;
    this.added = '';
    this.pieces = text ? [{ buffer: 'original', start: 0, length: text.length }] : [];
  }

  read() {
    return this.pieces.map((piece) => {
      const source = piece.buffer === 'original' ? this.original : this.added;
      return source.slice(piece.start, piece.start + piece.length);
    }).join('');
  }

  insert(offset, text) {
    if (!Number.isSafeInteger(offset) || offset < 0 || offset > this.length()) {
      throw new RangeError('插入位置超出范围');
    }
    if (typeof text !== 'string') throw new TypeError('插入内容必须是字符串');
    if (text.length === 0) return;

    const addStart = this.added.length;
    this.added += text;
    const inserted = { buffer: 'added', start: addStart, length: text.length };
    this.splitAt(offset);
    const index = this.boundaryIndex(offset);
    this.pieces.splice(index, 0, inserted);
  }

  delete(offset, count) {
    const total = this.length();
    if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(count) ||
        offset < 0 || count < 0 || offset + count > total) {
      throw new RangeError('删除范围超出文本');
    }
    if (count === 0) return;

    // 在两端切开后,移除中间整段 piece;原始与追加缓冲区都不改动。
    this.splitAt(offset);
    this.splitAt(offset + count);
    const start = this.boundaryIndex(offset);
    const end = this.boundaryIndex(offset + count);
    this.pieces.splice(start, end - start);
  }

  length() {
    return this.pieces.reduce((total, piece) => total + piece.length, 0);
  }

  boundaryIndex(offset) {
    let cursor = 0;
    for (let index = 0; index < this.pieces.length; index += 1) {
      if (cursor === offset) return index;
      cursor += this.pieces[index].length;
    }
    return this.pieces.length;
  }

  splitAt(offset) {
    let cursor = 0;
    for (let index = 0; index < this.pieces.length; index += 1) {
      const piece = this.pieces[index];
      const end = cursor + piece.length;
      if (offset <= cursor || offset >= end) {
        cursor = end;
        continue;
      }
      const left = { ...piece, length: offset - cursor };
      const right = { ...piece, start: piece.start + left.length, length: end - offset };
      this.pieces.splice(index, 1, left, right);
      return;
    }
  }
}

这段代码用字符串拼接实现 read(),是教学实现而不是生产编辑器;它展示了原始缓冲区和追加缓冲区的所有权关系。真实 Piece Table 通常还会用树或索引结构,让按偏移查找和修改不必每次扫描全部 piece。

这里 offset、start 和 length 全部按 UTF-16 码元计量,不会阻止调用方把插入位置放在代理对或字素簇中间。编辑器输入层必须先把合法光标位置映射到这一单位。不要改成 Array.from(text).length 后继续使用 slice 的码元索引,两套单位混用反而会损坏位置。

此外,added += text 在 JavaScript 中仍然是字符串拼接,具体复制与内部表示由引擎决定。本例并没有实现真正分块、只追加而不复制的底层缓冲,因此不能拿它证明大量编辑是常数时间。length、splitAt、boundaryIndex 和数组 splice 也会扫描或移动 piece;只演示结构关系,不宣称实现了 VS Code 的平衡树性能。

不能只看插入复杂度

即使 piece 修改本身很快,以下工作仍可能成为瓶颈:把片段拼成完整字符串、按行建立索引、语法高亮、诊断、搜索、撤销记录和发送协同操作。编辑器通常维护增量行索引和可视区缓存,避免每次输入都重新解析整份文件。

撤销历史可以保存操作(位置、删除文本、插入文本)或保存结构快照。只保存完整字符串会复制大量内存;只保存操作则要定义合并连续输入、光标移动、撤销边界和历史清理。历史记录不是无上限日志,应设置大小、时间和用户操作边界。

Rope 适合频繁的大范围文本操作

Rope 用平衡树组织多个字符串叶子,插入或删除通常只影响从根到相关叶子的路径,读取整篇文本再拼接时则需要遍历树。树不平衡、叶子过小或过大都会影响性能;光标偏移、行号和 UTF-16/字素簇索引需要额外维护。

选择 Rope 不是为了让每一个小输入都更快。短文本的树管理开销可能比原生字符串更大;应根据文件规模、编辑频率、搜索和解析需求做基准测试。

存储模型和 DOM 渲染必须分开

编辑器不应把每个字符都渲染成一个 DOM 节点。常见做法是只渲染可视行,使用行缓存和测量结果;文本输入可以使用隐藏 textarea 或受控输入层,把光标和选区映射到编辑模型。虚拟化会影响浏览器查找、复制、打印和屏幕阅读器,需要提供合理降级。

输入法组合、Emoji、双向文字和换行规范化都要经过模型处理。compositionstart 到 compositionend 期间不要把每个中间值都当成最终编辑操作,否则会打断中文输入或产生错误撤销记录。

协同编辑又是另一层

Piece Table 或 Rope 解决本地文本编辑效率,不自动解决多用户并发。协同还需要操作 ID、版本、OT/CRDT、离线队列、权限和冲突恢复。把本地 piece 数组直接广播给其他客户端,既浪费带宽,也没有定义并发合并语义。

最终选型应同时测量:插入/删除延迟、长文件内存、撤销成本、搜索和语法解析、可视区滚动、输入法、复制打印和协同同步。编辑器的数据结构是内核的一部分,不能只因为一个字符串示例更容易写,就把整份真实文档复制到每次按键的事件里。