我正在参加「掘金·启航计划」
摘要:通过分析piecetable数据结构,了解vscode文本编辑器目前的文本编辑模型是如何实现的。并且是如何通过一步步的优化,来打造高性能的产品
直觉上的文本模型
一般编辑器都是基于行来处理文本,所以很容易想到使用字符串数组来表示文本存储的内存结构,其中每一个数组元素表示一行。
lines = [
'第一行数据', // 第一行的文本内容
'第二行数据' // 第二行的文本内容
]
当文件体积不大时,一切都表现正常
但一旦遇到大文件,由于性能问题引起的卡顿甚至程序奔溃就很明显了。
例如文本数据有几十万行,在中间插入一行都需要在内存里面移动后面所有行。为了构造行数组,也要进行分行操作,这些都需要损耗部分性能。
进阶版的文本模型实现 piece table
- 假如有一个文件,里面保存的文本为“实践是标准”,文本编辑器加载该文件后,初始的结构数据如下
{
original: '实践是标准', // 初始内容
add: '', // 用户添加的内容
pieces: [
{ start: 0, length: 5, source: 'original' }
]
}
使用buffer存储所有文本内容,有两种类型的buffer:
- original :当文件打开后,初始加载到内存中的文本内容会存放在这个buffer中,该buffer对应的数据类型为只读不可改的字符串对象
- add:该buffer是append-only 模式,初始时是空字符串。无论在原来文档的哪个位置输入文本,内容都只会追加到这个buffer尾部
pieces数组里面的每一项,表示获取指定的buffer中的对应子字符串作为文档内容一部分。通过遍历整个pieces数组,即可组成完整的文档内容。
- 如果编辑文本,在“标准”的前面再插入“检验真理的”,这时候对应的结构数据如下
{
original: '实践是标准',
add: '检验真理的',
pieces: [
{ start: 0, length: 3, source: 'original' },
{ start: 0, length: 5, source: 'add' },
{ start: 3, length: 2, source: 'original' }
]
}
新增的内容只能追加到add buffer。
往piece对应内容的中间插入文本时,该piece会被分成两部分,并创建一个新的piece插入piece table里面。这时候piece table会从一个piece变成3个piece。
vscode 版优化后的piece table实现
原始的piece table实现也包含了一些缺点
- 要获取具体某一行的内容,都必须从头遍历piece表来查找
- 部分语言的实现,对字符串对象的长度是有最大限制的,若文件内容太大时,只有两个buffer不够存储这些文本
因此vscode版本的piece table做了以下优化
- 记录换行符位置
- 使用buffer列表,而不是只有add和original类型的两个buffer
- 使用红黑树表示piece列表数据结构,减少查找文本的时间复杂度
class TreeNode {
parent: TreeNode;
left: TreeNode;
right: TreeNode;
color: NodeColor;
piece: Piece; // 字符串在buffer中的映射位置
size_left: number; //左子树包含的字符总数
lf_left: number // 左子树包含的文本行数
}
enum NodeColor {
Black = 0,
Red = 1
}
class Piece {
readonly bufferIndex: number;
readonly start: BufferCursor;
readonly end: BufferCursor;
readonly length: number;
readonly lineFeedCnt: number;
}
class PieceTreeBase {
root: TreeNode;
protected _buffers: StringBuffer[]
}
class StringBuffer {
buffer: string;
// 存储了换行符位置的数组
lineStarts: Uint32Array | Uint16Array | number[];
}
由于算法中用到了红黑树来保持二叉树的平衡,这里将piece table改名为piece tree更符合此算法精髓。
上面定义的结构的优点
- 使用StringBuffer的lineStarts数组可以快速获取字符在buffer中的偏移量,例如
// { line: 1, column: 1 } 表示第二行第一列
offset = stringBuffer.lineStarts[line] + columnn
- 每个节点存储的是本节点的行数和左子树行数,因此可以在log(N)的时间复杂度里面找到期望的某一行。同时只有左节点或父节点是左节点的行数发生变化,才需要更新祖先节点直到根节点,减少了不必要的节点更新
插入操作
insert(pos, text) 表示往文档的pos位置插入文本text
执行如下编辑步骤
- insert(0, '12345')
- insert(2, '678')
- insert(8, 'abc')
piece tree的变化过程
- insert(0, '12345')
- **insert(2, '678')
** - **insert(8, 'abc')
**
function insert (pos, txt) {
// 1. 获取待插入位置所在树节点和待插入位置在该树节点的偏移量
const { node, remainder } = nodeAt(pos)
// 2. 获取插入位置在buffer中的偏移量
const insertPosInBuffer = positionInBuffer(node, remainder);
// 3. 基于插入的位置有以下三种不同的节点操作
// a. 往节点插入左节点
// b. 往节点插入右节点
// c. 节点的文本开头或文本末尾添加文本,此时不需要增加节点
...
}
查找某一行
getLineContent(lineNumber: number) 获取某一行内容
执行如下编辑步骤后
- insert(0, '第二行数据\n')
- insert(0, '第一行数据\n')
- insert(12, '第三行数据')
编辑器显示内容、buffer存储内容、piece tree展示如下
调用getLineContent(2)获取第二行的文本内容:
由于每个树节点都存储了左子树的行数(lf_left)和当前树节点的行数(lineFeedCnt),因此可以通过这两个值与待获取内容的行数作比较来获取某一行的内容的起始与结尾的偏移量。
总结
本文介绍了普通字符串数组、piecetable数据结构,再到vscode团队优化后的piecetable数据结构,借以学习他们是如何通过不断的优化数据结构来打造高性能的产品。
同时也可看到piecetable这种数据结构是如何优雅的处理文本编辑问题。