语法高亮优化
2017 年 2 月 8 日 - Alexandru Dima
Visual Studio Code 1.9 版本包含一个很酷的性能改进,我们一直在努力,我想讲述它的故事。
TL;DR 在 VS Code 1.9 中,TextMate 主题将更像其作者的意图,同时渲染速度更快,内存消耗更少。
语法高亮
语法高亮通常包含两个阶段。标记被分配给源代码,然后它们成为主题的目标,被分配颜色,瞧!你的源代码就以颜色渲染了。它是将文本编辑器变成代码编辑器的功能。
VS Code(和 Monaco Editor)中的标记化逐行运行,从上到下,单次通过。标记器可以在标记化行的末尾存储一些状态,这将在标记化下一行时传回。这是许多标记化引擎(包括 TextMate 语法)使用的一种技术,它允许编辑器在用户进行编辑时仅重新标记化行的一个小子集。
大多数情况下,在一行上键入只会导致该行被重新标记化,因为标记器返回相同的结束状态,并且编辑器可以假设以下行不会获得新标记

更少见的情况是,在一行上键入会导致当前行和下面的一些行(直到遇到相同的结束状态)被重新标记化/重绘

我们过去如何表示标记
VS Code 中编辑器的代码是在 VS Code 存在之前很久编写的。它以 Monaco Editor 的形式在各种 Microsoft 项目中发布,包括 Internet Explorer 的 F12 工具。我们当时的一个要求是减少内存使用量。
过去,我们手工编写标记器(即使在今天,在浏览器中解释 TextMate 语法也没有可行的方法,但那是另一个故事)。对于下面的行,我们将从我们手工编写的标记器中获得以下标记

tokens = [
{ startIndex: 0, type: 'keyword.js' },
{ startIndex: 8, type: '' },
{ startIndex: 9, type: 'identifier.js' },
{ startIndex: 11, type: 'delimiter.paren.js' },
{ startIndex: 12, type: 'delimiter.paren.js' },
{ startIndex: 13, type: '' },
{ startIndex: 14, type: 'delimiter.curly.js' }
];
保持该标记数组在 Chrome 中占用 648 字节,因此存储这样的对象在内存方面非常昂贵(每个对象实例都必须为指向其原型、其属性列表等保留空间)。我们目前的机器确实有大量 RAM,但为 15 个字符的行存储 648 字节是不可接受的。
因此,当时,我们提出了一种二进制格式来存储标记,这种格式一直被使用到包括 VS Code 1.8。考虑到会有重复的标记类型,我们将它们收集在一个单独的映射中(每个文件),执行类似于以下操作的操作
// 0 1 2 3 4
map = ['', 'keyword.js', 'identifier.js', 'delimiter.paren.js', 'delimiter.curly.js'];
tokens = [
{ startIndex: 0, type: 1 },
{ startIndex: 8, type: 0 },
{ startIndex: 9, type: 2 },
{ startIndex: 11, type: 3 },
{ startIndex: 12, type: 3 },
{ startIndex: 13, type: 0 },
{ startIndex: 14, type: 4 }
];
然后,我们将 startIndex
(32 位)和 type
(16 位)编码到 JavaScript 数字的 53 位尾数 中的 48 位中。我们的标记数组最终将如下所示,并且 map 数组将为整个文件重用
tokens = [
// type startIndex
4294967296, // 0000000000000001 00000000000000000000000000000000
8, // 0000000000000000 00000000000000000000000000001000
8589934601, // 0000000000000010 00000000000000000000000000001001
12884901899, // 0000000000000011 00000000000000000000000000001011
12884901900, // 0000000000000011 00000000000000000000000000001100
13, // 0000000000000000 00000000000000000000000000001101
17179869198 // 0000000000000100 00000000000000000000000000001110
];
保持此标记数组在 Chrome 中占用 104 字节。元素本身应该只占用 56 字节(7 个 64 位数字),其余的可能可以通过 v8 存储数组的其他元数据来解释,或者可能是以 2 的幂分配后备存储。但是,内存节省是显而易见的,并且随着每行标记的增加而变得更好。我们对这种方法感到满意,并且自那时以来一直使用这种表示形式。
注意:可能有更紧凑的存储标记的方法,但是以二进制可搜索的线性格式存储它们,在内存使用率和访问性能方面为我们提供了最佳的权衡。
标记 <-> 主题匹配
我们认为遵循浏览器最佳实践是个好主意,例如将样式设置留给 CSS,因此在渲染上面这行时,我们将使用 map
解码二进制标记,然后使用标记类型像这样渲染它
<span class="token keyword js">function</span>
<span class="token"> </span>
<span class="token identifier js">f1</span>
<span class="token delimiter paren js">(</span>
<span class="token delimiter paren js">)</span>
<span class="token"> </span>
<span class="token delimiter curly js">{</span>
我们将 在 CSS 中编写我们的主题(例如 Visual Studio 主题)
...
.monaco-editor.vs .token.delimiter { color: #000000; }
.monaco-editor.vs .token.keyword { color: #0000FF; }
.monaco-editor.vs .token.keyword.flow { color: #AF00DB; }
...
结果非常不错,我们可以在某处翻转一个类名,并立即将新主题应用于编辑器。
TextMate 语法
对于 VS Code 的发布,我们有大约 10 个手写标记器,主要用于 Web 语言,这对于通用桌面代码编辑器来说肯定是不够的。引入 TextMate 语法,这是一种描述性的指定标记化规则的形式,已被许多编辑器采用。但是,有一个问题,TextMate 语法的工作方式与我们手写的标记器不太一样。
TextMate 语法通过使用 begin/end 状态或 while 状态,可以推送可以跨越多个标记的作用域。这是 JavaScript TextMate 语法下的相同示例(为简洁起见忽略空格)
VS Code 1.8 中的 TextMate 语法
如果我们要做一个通过作用域堆栈的部分,每个标记基本上都会获得一个作用域名称数组,我们将从标记器中获得类似以下内容

tokens = [
{ startIndex: 0, scopes: ['source.js', 'meta.function.js', 'storage.type.function.js'] },
{ startIndex: 8, scopes: ['source.js', 'meta.function.js'] },
{
startIndex: 9,
scopes: [
'source.js',
'meta.function.js',
'meta.definition.function.js',
'entity.name.function.js'
]
},
{
startIndex: 11,
scopes: [
'source.js',
'meta.function.js',
'meta.parameters.js',
'punctuation.definition.parameters.js'
]
},
{ startIndex: 13, scopes: ['source.js', 'meta.function.js'] },
{
startIndex: 14,
scopes: [
'source.js',
'meta.function.js',
'meta.block.js',
'punctuation.definition.block.js'
]
}
];
所有标记类型都是字符串,我们的代码尚未准备好处理字符串数组,更不用说对标记的二进制编码的影响了。因此,我们继续使用以下策略将作用域数组“近似”*为一个字符串
- 忽略最不具体的作用域(即
source.js
);它很少添加任何价值。 - 在
"."
上拆分每个剩余作用域。 - 删除重复的唯一片段。
- 使用稳定的排序函数对剩余片段进行排序(不一定是按字典顺序排序)。
- 在
"."
上连接片段。
tokens = [
{ startIndex: 0, type: 'meta.function.js.storage.type' },
{ startIndex: 9, type: 'meta.function.js' },
{ startIndex: 9, type: 'meta.function.js.definition.entity.name' },
{ startIndex: 11, type: 'meta.function.js.definition.parameters.punctuation' },
{ startIndex: 13, type: 'meta.function.js' },
{ startIndex: 14, type: 'meta.function.js.definition.punctuation.block' }
];
*: 我们所做的是完全错误的,“近似”是一个非常好的词 :)。
这些标记随后将“适应”并遵循与手动编写的标记器相同的代码路径(获得二进制编码),然后也将以相同的方式渲染
<span class="token meta function js storage type">function</span>
<span class="token meta function js"> </span>
<span class="token meta function js definition entity name">f1</span>
<span class="token meta function js definition parameters punctuation">()</span>
<span class="token meta function js"> </span>
<span class="token meta function js definition punctuation block">{</span>
TextMate 主题
TextMate 主题与 作用域选择器 一起工作,作用域选择器选择具有某些作用域的标记,并将主题信息(如颜色、粗体等)应用于它们。
给定一个具有以下作用域的标记
// C B A
scopes = ['source.js', 'meta.definition.function.js', 'entity.name.function.js'];
以下是一些简单的选择器,它们将匹配,按其等级排序(降序)
选择器 | C | B | A |
---|---|---|---|
source | source.js | meta.definition.function.js | entity.name.function.js |
source.js | source.js | meta.definition.function.js | entity.name.function.js |
meta | source.js | meta.definition.function.js | entity.name.function.js |
meta.definition | source.js | meta.definition.function.js | entity.name.function.js |
meta.definition.function | source.js | meta.definition.function.js | entity.name.function.js |
entity | source.js | meta.definition.function.js | entity.name.function.js |
entity.name | source.js | meta.definition.function.js | entity.name.function.js |
entity.name.function | source.js | meta.definition.function.js | entity.name.function.js |
entity.name.function.js | source.js | meta.definition.function.js | entity.name.function.js |
观察:
entity
胜过meta.definition.function
,因为它匹配的作用域更具体(分别为A
胜过B
)。
观察:
entity.name
胜过entity
,因为它们都匹配相同的作用域(A
),但entity.name
比entity
更具体。
父选择器
为了使事情更复杂一点,TextMate 主题还支持父选择器。以下是一些同时使用简单选择器和父选择器的示例(再次按其等级降序排序)
选择器 | C | B | A |
---|---|---|---|
meta | source.js | meta.definition.function.js | entity.name.function.js |
source meta | source.js | meta.definition.function.js | entity.name.function.js |
source.js meta | source.js | meta.definition.function.js | entity.name.function.js |
meta.definition | source.js | meta.definition.function.js | entity.name.function.js |
source meta.definition | source.js | meta.definition.function.js | entity.name.function.js |
entity | source.js | meta.definition.function.js | entity.name.function.js |
source entity | source.js | meta.definition.function.js | entity.name.function.js |
meta.definition entity | source.js | meta.definition.function.js | entity.name.function.js |
entity.name | source.js | meta.definition.function.js | entity.name.function.js |
source entity.name | source.js | meta.definition.function.js | entity.name.function.js |
观察:
source entity
胜过entity
,因为它们都匹配相同的作用域(A
),但source entity
也匹配父作用域(C
)。
观察:
entity.name
胜过source entity
,因为它们都匹配相同的作用域(A
),但entity.name
比entity
更具体。
注意:还有第三种选择器,一种涉及排除作用域的选择器,我们在此不讨论。我们没有添加对此类别的支持,并且我们注意到它在野外很少使用。
VS Code 1.8 中的 TextMate 主题
以下是两个 Monokai 主题规则(为了简洁起见,此处为 JSON 格式;原始格式为 XML)
...
// Function name
{ "scope": "entity.name.function", "fontStyle": "", "foreground":"#A6E22E" }
...
// Class name
{ "scope": "entity.name.class", "fontStyle": "underline", "foreground":"#A6E22E" }
...
在 VS Code 1.8 中,为了匹配我们的“近似”作用域,我们将生成以下动态 CSS 规则
...
/* Function name */
.entity.name.function { color: #A6E22E; }
...
/* Class name */
.entity.name.class { color: #A6E22E; text-decoration: underline; }
...
然后,我们将让 CSS 将“近似”作用域与“近似”规则匹配。但是,CSS 匹配规则与 TextMate 选择器匹配规则不同,尤其是在排名方面。CSS 排名基于匹配的类名称的数量,而 TextMate 选择器排名对作用域特异性有明确的规则。
这就是为什么 VS Code 中的 TextMate 主题看起来还可以,但永远不像其作者的意图那样。有时,差异很小,但有时这些差异会完全改变主题的感觉。
一些星星连成一线
随着时间的推移,我们已经逐步淘汰了手写标记器(最后一个,用于 HTML,仅在几个月前)。因此,在今天的 VS Code 中,所有文件都使用 TextMate 语法进行标记化。对于 Monaco Editor,我们已迁移到使用 Monarch(一种描述性标记化引擎,其核心与 TextMate 语法相似,但更具表现力,并且可以在浏览器中运行)来支持大多数受支持的语言,并且我们为手动标记器添加了一个包装器。总而言之,这意味着支持新的标记化格式将需要更改 3 个标记提供程序(TextMate、Monarch 和手动包装器),并且不超过 10 个。
几个月前,我们审查了 VS Code 核心中读取标记类型的所有代码,我们注意到这些使用者只关心字符串、正则表达式或注释。例如,括号匹配逻辑会忽略包含作用域 "string"
、"comment"
或 "regex"
的标记。
最近,我们从内部合作伙伴(Microsoft 内部的其他团队使用 Monaco Editor)那里获得了许可,他们不再需要在 Monaco Editor 中支持 IE9 和 IE10。
可能最重要的是,编辑器最受投票的功能是 小地图支持。为了在合理的时间内渲染小地图,我们不能使用 DOM 节点和 CSS 匹配。我们可能会使用画布,并且我们将需要知道 JavaScript 中每个标记的颜色,以便我们可以用正确的颜色绘制这些小字母。
我们最大的突破可能是我们不需要存储标记,也不需要存储它们的作用域,因为标记仅在主题匹配它们或括号匹配跳过字符串方面产生效果。
最后,VS Code 1.9 中的新功能
表示 TextMate 主题
以下是一个非常简单的主题的外观
theme = [
{ "foreground": "#F8F8F2" },
{ "scope": "var", "foreground": "#F8F8F2" },
{ "scope": "var.identifier", "foreground": "#00FF00", "fontStyle": "bold" },
{ "scope": "meta var.identifier", "foreground": "#0000FF" },
{ "scope": "constant", "foreground": "#100000", "fontStyle": "italic" },
{ "scope": "constant.numeric", "foreground": "#200000" },
{ "scope": "constant.numeric.hex", "fontStyle": "bold" },
{ "scope": "constant.numeric.oct", "fontStyle": "underline" },
{ "scope": "constant.numeric.dec", "foreground": "#300000" },
];
加载它时,我们将为主题中出现的每个唯一颜色生成一个 ID,并将其存储到颜色映射中(与我们上面对标记类型所做的一样)
// 1 2 3 4 5 6
colorMap = ["reserved", "#F8F8F2", "#00FF00", "#0000FF", "#100000", "#200000", "#300000"]
theme = [
{ "foreground": 1 },
{ "scope": "var", "foreground": 1, },
{ "scope": "var.identifier", "foreground": 2, "fontStyle": "bold" },
{ "scope": "meta var.identifier", "foreground": 3 },
{ "scope": "constant", "foreground": 4, "fontStyle": "italic" },
{ "scope": "constant.numeric", "foreground": 5 },
{ "scope": "constant.numeric.hex", "fontStyle": "bold" },
{ "scope": "constant.numeric.oct", "fontStyle": "underline" },
{ "scope": "constant.numeric.dec", "foreground": 6 },
];
然后,我们将从主题规则中生成一个 Trie 数据结构,其中每个节点都保存已解析的主题选项

观察:
constant.numeric.hex
和constant.numeric.oct
的节点包含将前景色更改为5
的指令,因为它们从constant.numeric
继承了此指令。
观察:
var.identifier
的节点保存额外的父规则meta var.identifier
,并将相应地回答查询。
当我们想知道如何对作用域进行主题化时,我们可以查询这个 trie。
例如
查询 | 结果 |
---|---|
constant | 将前景色设置为 4,字体样式设置为 italic |
constant.numeric | 将前景色设置为 5,字体样式设置为 italic |
constant.numeric.hex | 将前景色设置为 5,字体样式设置为 bold |
var | 将前景色设置为 1 |
var.baz | 将前景色设置为 1(匹配 var) |
baz | 不执行任何操作(不匹配) |
var.identifier | 如果存在父作用域 meta,则将前景色设置为 3,字体样式设置为 bold, 否则,将前景色设置为 2,字体样式设置为 bold |
标记化的更改
VS Code 中使用的所有 TextMate 标记化代码都位于一个单独的项目 vscode-textmate 中,该项目可以独立于 VS Code 使用。我们已经更改了在 vscode-textmate
中表示作用域堆栈的方式,使其成为 不可变的链表,该链表还存储完全解析的 metadata
。
当将新作用域推送到作用域堆栈上时,我们将在主题 trie 中查找新作用域。然后,我们可以根据我们从作用域堆栈继承的内容以及主题 trie 返回的内容,立即计算出作用域列表的完全解析的所需前景色或字体样式。
一些示例
作用域堆栈 | 元数据 |
---|---|
["source.js"] | 前景色为 1,字体样式为常规(没有作用域选择器的默认规则) |
["source.js","constant"] | 前景色为 4,字体样式为 italic |
["source.js","constant","baz"] | 前景色为 4,字体样式为 italic |
["source.js","var.identifier"] | 前景色为 2,字体样式为 bold |
["source.js","meta","var.identifier"] | 前景色为 3,字体样式为 bold |
当从作用域堆栈弹出时,无需计算任何内容,因为我们可以只使用与上一个作用域列表元素一起存储的元数据。
这是表示作用域列表中元素的 TypeScript 类
export class ScopeListElement {
public readonly parent: ScopeListElement;
public readonly scope: string;
public readonly metadata: number;
...
}
我们存储了 32 位的元数据
/**
* - -------------------------------------------
* 3322 2222 2222 1111 1111 1100 0000 0000
* 1098 7654 3210 9876 5432 1098 7654 3210
* - -------------------------------------------
* xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx
* bbbb bbbb bfff ffff ffFF FTTT LLLL LLLL
* - -------------------------------------------
* - L = LanguageId (8 bits)
* - T = StandardTokenType (3 bits)
* - F = FontStyle (3 bits)
* - f = foreground color (9 bits)
* - b = background color (9 bits)
*/
最后,而不是从标记化引擎发出作为对象的标记
// These are generated using the Monokai theme.
tokens_before = [
{ startIndex: 0, scopes: ['source.js', 'meta.function.js', 'storage.type.function.js'] },
{ startIndex: 8, scopes: ['source.js', 'meta.function.js'] },
{
startIndex: 9,
scopes: [
'source.js',
'meta.function.js',
'meta.definition.function.js',
'entity.name.function.js'
]
},
{
startIndex: 11,
scopes: [
'source.js',
'meta.function.js',
'meta.parameters.js',
'punctuation.definition.parameters.js'
]
},
{ startIndex: 13, scopes: ['source.js', 'meta.function.js'] },
{
startIndex: 14,
scopes: [
'source.js',
'meta.function.js',
'meta.block.js',
'punctuation.definition.block.js'
]
}
];
// Every even index is the token start index, every odd index is the token metadata.
// We get fewer tokens because tokens with the same metadata get collapsed
tokens_now = [
// bbbbbbbbb fffffffff FFF TTT LLLLLLLL
0,
16926743, // 000000010 000001001 001 000 00010111
8,
16793623, // 000000010 000000001 000 000 00010111
9,
16859159, // 000000010 000000101 000 000 00010111
11,
16793623 // 000000010 000000001 000 000 00010111
];
它们使用以下方式渲染
<span class="mtk9 mtki">function</span>
<span class="mtk1"> </span>
<span class="mtk5">f1</span>
<span class="mtk1">() {</span>
标记以 Uint32Array 的形式直接从标记器返回。我们保留后备 ArrayBuffer,对于上面的示例,它在 Chrome 中占用 96 字节。元素本身应该只占用 32 字节(8 个 32 位数字),但我们可能再次观察到一些 v8 元数据开销。
一些数字
为了获得以下测量结果,我选择了三个具有不同特征和不同语法的文件
文件名 | 文件大小 | 行数 | 语言 | 观察 |
---|---|---|---|---|
checker.ts | 1.18 MB | 22,253 | TypeScript | TypeScript 编译器中使用的实际源文件 |
bootstrap.min.css | 118.36 KB | 12 | CSS | 最小化的 CSS 文件 |
sqlite3.c | 6.73 MB | 200,904 | C | SQLite 的连接发行文件 |
我在 Windows 上的一台功能强大的桌面计算机上运行了测试(它使用 32 位 Electron)。
我必须对源代码进行一些更改,以便将苹果与苹果进行比较,例如确保在两个 VS Code 版本中使用完全相同的语法,在两个版本中都关闭丰富的语言功能,或取消 VS Code 1.8 中的 100 堆栈深度限制,而 VS Code 1.9 中不再存在该限制等等。我还必须将 bootstrap.min.css 拆分为多行,以使每行少于 20k 个字符。
标记化时间
标记化在 UI 线程上以屈服方式运行,因此我必须添加一些代码以强制它同步运行,以便测量以下时间(显示 10 次运行的中位数)
文件名 | 文件大小 | VS Code 1.8 | VS Code 1.9 | 加速 |
---|---|---|---|---|
checker.ts | 1.18 MB | 4606.80 毫秒 | 3939.00 毫秒 | 14.50% |
bootstrap.min.css | 118.36 KB | 776.76 毫秒 | 416.28 毫秒 | 46.41% |
sqlite3.c | 6.73 MB | 16010.42 毫秒 | 10964.42 毫秒 | 31.52% |

尽管标记化现在也执行主题匹配,但节省的时间可以用对每一行进行单次传递来解释。而在此之前,会有一个标记化传递、一个将作用域“近似”为字符串的辅助传递,以及一个二进制编码标记的第三次传递,现在标记直接从 TextMate 标记化引擎以二进制编码的方式生成。需要垃圾回收的生成对象数量也大大减少。
内存使用
折叠消耗大量内存,尤其是对于大型文件(这是另一个时间的优化),因此我收集了关闭折叠后的以下堆快照数字。这显示了模型持有的内存,不包括原始文件字符串
文件名 | 文件大小 | VS Code 1.8 | VS Code 1.9 | 内存节省 |
---|---|---|---|---|
checker.ts | 1.18 MB | 3.37 MB | 2.61 MB | 22.60% |
bootstrap.min.css | 118.36 KB | 267.00 KB | 201.33 KB | 24.60% |
sqlite3.c | 6.73 MB | 27.49 MB | 21.22 MB | 22.83% |

减少的内存使用率可以通过不再保留标记映射、具有相同元数据的连续标记的折叠以及使用
ArrayBuffer
作为后备存储来解释。我们可以通过始终将仅包含空格的标记折叠到上一个标记中来进一步改进此处,因为空格呈现什么颜色无关紧要(空格是不可见的)。
新的 TextMate 作用域检查器小部件
我们添加了一个新的小部件,以帮助创作和调试主题或语法:你可以使用开发者:检查编辑器标记和作用域在命令面板中运行它 (⇧⌘P (Windows, Linux Ctrl+Shift+P))。
验证更改
编辑器此组件中的更改带来了一些严重风险,因为我们的方法(在新的 trie 创建代码、新的二进制编码格式等中)中的任何错误都可能导致用户可见的巨大差异。
在 VS Code 中,我们有一个集成套件,它断言我们发布的五种主题(Light、Light+、Dark、Dark+、High Contrast)中所有编程语言的颜色。当更改我们的主题之一以及更新特定语法时,这些测试都非常有帮助。73 个集成测试中的每一个都包含一个 fixture 文件(例如 test.c)和五种主题的预期颜色 (test_c.json),它们在我们的 CI 构建 上的每次提交时运行。
为了验证标记化更改,我们使用旧的基于 CSS 的方法,从这些测试中收集了我们发布的 14 个主题(不仅是我们创作的五个主题)的着色结果。然后,在每次更改之后,我们使用新的基于 trie 的逻辑运行相同的测试,并使用自定义构建的可视差异(和补丁)工具,我们将查看每个颜色差异并找出颜色更改的根本原因。我们使用这种技术至少捕获了 2 个错误,并且我们能够更改我们的五个主题,以在 VS Code 版本之间获得最小的颜色更改
之前和之后
以下是各种颜色主题在 VS Code 1.8 中和现在在 VS Code 1.9 中的外观
Monokai 主题
Quiet Light 主题
Red 主题
结论
我希望您会欣赏升级到 VS Code 1.9 后获得的额外 CPU 时间和 RAM,并且我们可以继续使您能够以高效和愉快的方式进行编码。
编码愉快!
Alexandru Dima,VS Code 团队成员 @alexdima123