语法高亮优化
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(包括 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 位。我们的令牌数组最终会像这样,并且映射数组将为整个文件重用
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 编辑器,我们已经迁移到使用 Monarch(一种描述性的词法分析引擎,其核心与 TextMate 语法类似,但更具表现力,并且可以在浏览器中运行)来支持大多数受支持的语言,并且我们添加了手动词法分析器的包装器。总而言之,这意味着支持新的词法分析格式将需要更改 3 个令牌提供程序(TextMate、Monarch 和手动包装器),而不是 10 个以上。
几个月前,我们回顾了 VS Code 核心中所有读取令牌类型的代码,我们注意到这些使用者只关心字符串、正则表达式或注释。例如,括号匹配逻辑会忽略包含作用域 "string"
、"comment"
或 "regex"
的令牌。
最近,我们从内部合作伙伴(微软内部使用 Monaco 编辑器的其他团队)那里得到了确认,他们不再需要在 Monaco 编辑器中支持 IE9 和 IE10。
可能最重要的是,编辑器最受投票的功能是 小地图支持。为了在合理的时间内渲染小地图,我们不能使用 DOM 节点和 CSS 匹配。我们可能会使用 canvas,并且我们需要知道 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 上的一台功能强大的桌面机器上运行了测试(该机器使用 Electron 32 位)。
为了比较苹果和苹果,我不得不对源代码进行一些更改,例如确保在两个 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 中,我们有一个集成套件,用于断言我们发布的五种主题(浅色、浅色+、深色、深色+、高对比度)中所有编程语言的颜色。这些测试在更改我们的主题之一以及更新特定语法时都非常有用。73 个集成测试中的每一个都包含一个固定文件(例如 test.c)和五个主题的预期颜色(test_c.json),并且它们在我们的 CI 构建上的每次提交时运行。
为了验证词法分析的更改,我们使用旧的基于 CSS 的方法从这些测试中收集了所有 14 个我们发布的主题(不仅仅是我们编写的五个主题)的着色结果。然后,在每次更改后,我们使用新的基于 trie 的逻辑运行相同的测试,并使用自定义构建的可视化差异(和补丁)工具,我们会查看每个颜色差异并找出颜色更改的根本原因。我们使用这种技术至少捕获了 2 个错误,并且我们能够更改我们的五个主题,从而在 VS Code 版本之间获得最小的颜色更改。
之前和之后
以下是各种颜色主题在 VS Code 1.8 中以及现在在 VS Code 1.9 中的外观:
Monokai 主题
Quiet Light 主题
红色主题
总结
我希望您会感谢从升级到 VS Code 1.9 中获得的额外 CPU 时间和 RAM,并且我们能够继续帮助您以高效且愉快的方式进行编码。
编码愉快!
Alexandru Dima,VS Code 团队成员 @alexdima123