文本编辑器为什么不能只用一个字符串保存全文?
比较字符串、数组、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 序列;删除通常只是拆分或移除描述。
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 数组直接广播给其他客户端,既浪费带宽,也没有定义并发合并语义。
最终选型应同时测量:插入/删除延迟、长文件内存、撤销成本、搜索和语法解析、可视区滚动、输入法、复制打印和协同同步。编辑器的数据结构是内核的一部分,不能只因为一个字符串示例更容易写,就把整份真实文档复制到每次按键的事件里。