现已推出!阅读 10 月份的新功能和修复。

语法高亮优化

2017 年 2 月 8 日 - Alexandru Dima

Visual Studio Code 版本 1.9 包含一个很酷的性能改进,我们一直在努力,我想讲讲它的故事。

TL;DR 在 VS Code 1.9 中,TextMate 主题将看起来更像作者的本意,同时渲染速度更快,内存消耗更少。


语法高亮

语法高亮通常包括两个阶段。标记被分配给源代码,然后它们被主题定位,分配颜色,然后,您的源代码就会用颜色渲染。它是将文本编辑器变成代码编辑器的唯一功能。

VS Code 中的标记化(以及 Monaco 编辑器)逐行运行,从上到下,单次通过。标记器可以在标记行结束时存储一些状态,这些状态将在标记下一行时传递回来。这是许多标记化引擎(包括 TextMate 语法)使用的一种技术,它允许编辑器在用户进行编辑时仅重新标记一小部分行。

大多数情况下,在一行上打字只会导致该行被重新标记,因为标记器返回相同的状态,并且编辑器可以假设后续行不会获得新的标记

Tokenization Single Line

较少的情况下,在一行上打字会导致当前行和下面的一些行(直到遇到相同的结束状态)被重新标记/重绘。

Tokenization Multiple Lines

我们过去如何表示标记

VS Code 中编辑器的代码是在 VS Code 存在之前很久就写好的。它以 Monaco 编辑器 的形式交付,用于各种 Microsoft 项目,包括 Internet Explorer 的 F12 工具。我们有一个要求,那就是减少内存使用量。

过去,我们手动编写标记器(即使在今天,也没有可行的方法来解释浏览器中的 TextMate 语法,但那是另一个故事)。对于下面的行,我们将从我们手写的标记器中获得以下标记

Line offsets
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 位。我们的标记数组最终将如下所示,映射数组将被整个文件重复使用

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">&nbsp;</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">&nbsp;</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 语法通过使用开始/结束状态或 while 状态,可以推送跨越多个标记的范围。以下是 JavaScript TextMate 语法下的同一个示例(为了简洁起见,忽略空格)

TextMate Scopes


VS Code 1.8 中的 TextMate 语法

如果我们要对范围栈进行分段,每个标记基本上都会获得一个范围名称数组,并且我们将从标记器中获得如下所示的内容

Line offsets
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">&nbsp;</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">&nbsp;</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.nameentity 更具体。

父选择器

为了让事情更复杂一些,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.nameentity 更具体。

注意:还有第三种选择器,它涉及排除范围,我们在这里不做讨论。我们没有添加对这种类型的支持,并且我们注意到它在野外很少使用。


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 匹配。我们可能会使用画布,并且需要在 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 数据结构,其中每个节点都包含解析后的主题选项

Theme Trie

观察:constant.numeric.hexconstant.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">&nbsp;</span>
<span class="mtk5">f1</span>
<span class="mtk1">()&nbsp;{</span>

TextMate Scopes

词法直接从词法分析器以 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 中不再存在于 VS Code 1.9 中的 100 个堆栈深度限制等等。我还不得不将 bootstrap.min.css 分成多行,以便每行小于 20k 个字符。

词法分析时间

词法分析在 UI 线程上以一种生成方式运行,因此我不得不添加一些代码来强制它同步运行,以便测量以下时间(显示 10 次运行的中位数)

文件名 文件大小 VS Code 1.8 VS Code 1.9 提速
checker.ts 1.18 MB 4606.80 ms 3939.00 ms 14.50%
bootstrap.min.css 118.36 KB 776.76 ms 416.28 ms 46.41%
sqlite3.c 6.73 MB 16010.42 ms 10964.42 ms 31.52%
Tokenization times

虽然词法分析现在也执行主题匹配,但节省的时间可以通过对每行进行单遍扫描来解释。以前,会进行词法分析一遍、第二遍将范围“近似”为字符串,以及第三遍对词法进行二进制编码,而现在,词法可以直接以二进制编码的方式从 TextMate 词法分析引擎中生成。需要进行垃圾回收的生成对象数量也大幅减少。

内存使用

折叠会占用大量内存,尤其是在大型文件的情况下(这将在以后进行优化),因此我收集了以下关闭折叠后的堆快照数据。这显示了 Model 占用的内存,不包括原始文件字符串

文件名 文件大小 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%
Memory usage

内存使用量减少的原因是,不再保留词法映射、将具有相同元数据的连续词法合并以及使用 ArrayBuffer 作为支持存储。我们可以通过始终将空白词法合并到前面的词法中来进一步改进,因为空白词法的渲染颜色并不重要(空白词法是不可见的)。

新的 TextMate 范围检查器小部件

我们添加了一个新的小部件来帮助创作和调试主题或语法:您可以在**命令面板**(⇧⌘P(Windows、Linux Ctrl+Shift+P))中运行它,方法是使用**开发者:检查编辑器词法和范围**。

TextMate scope inspector

验证更改

对编辑器的这个组件进行更改存在着一些严重的风险,因为我们的方法中的任何错误(在新的 Trie 创建代码中、在新二进制编码格式中等等)都可能导致巨大的用户可见差异。

在 VS Code 中,我们有一个集成套件,用于断言我们为五种主题(浅色、浅色+、深色、深色+、高对比度)提供的我们所有编程语言的颜色。这些测试在更改我们的一种主题时以及更新某个语法时都非常有用。每个集成测试都包含一个固定文件(例如 test.c)以及五种主题的预期颜色(test_c.json),它们会在我们的 CI 构建 中的每次提交时运行。

为了验证词法分析更改,我们收集了这些测试中使用旧的基于 CSS 的方法获得的所有 14 种主题(不仅仅是我们自己提供的五种主题)的颜色结果。然后,在每次更改后,我们使用新的基于 Trie 的逻辑运行相同的测试,并使用一个自定义的视觉差异(和补丁)工具来查看每个颜色差异并找出颜色更改的根本原因。我们使用这种技术至少发现了 2 个错误,并且能够更改我们的五种主题,以使 VS Code 版本之间的颜色变化最小化。

Tokenization validation

前后对比

以下是 VS Code 1.8 和现在 VS Code 1.9 中各种颜色主题的外观

Monokai 主题

Monokai before

Monokai after

安静浅色主题

Quiet Light before

Quiet Light after

红色主题

Red before

Red after

总结

我希望您能欣赏升级到 VS Code 1.9 后获得的额外 CPU 时间和 RAM,并且我们能够继续为您提供高效和愉快的编码体验。

祝您编码愉快!

Alexandru Dima,VS Code 团队成员 @alexdima123