尝试以扩展 VS Code 中的代理模式!

语法高亮优化

2017 年 2 月 8 日 - Alexandru Dima

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

TL;DR 在 VS Code 1.9 中,TextMate 主题将更接近作者的预期,同时渲染速度更快,内存消耗更少。


语法高亮

语法高亮通常由两个阶段组成。首先,标记被分配给源代码;然后,它们被主题定位,分配颜色,瞧!您的源代码就以彩色呈现。正是这个功能将文本编辑器变成了代码编辑器。

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

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

Tokenization Single Line

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

Tokenization Multiple Lines

我们过去如何表示标记

VS Code 中编辑器的代码在 VS Code 诞生之前就已编写。它以 Monaco Editor 的形式在各种微软项目中发布,包括 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 字节,因此存储这样一个对象在内存方面相当昂贵(每个对象实例必须保留指向其原型、属性列表等的空间)。我们目前的机器确实有很多内存,但对于一个 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 x 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 语法通过使用 begin/end 状态或 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 Editor,我们已经迁移到使用 Monarch(一种描述性标记化引擎,其核心与 TextMate 语法相似,但更具表达力且可以在浏览器中运行)来处理大多数受支持的语言,并且我们还为手动标记器添加了一个包装器。总而言之,这意味着支持一种新的标记化格式将需要更改 3 个标记提供程序(TextMate、Monarch 和手动包装器),而不是超过 10 个。

几个月前,我们审查了 VS Code 核心中所有读取标记类型的代码,我们注意到那些消费者只关心字符串、正则表达式或注释。例如,括号匹配逻辑会忽略包含范围 "string""comment""regex" 的标记。

最近,我们收到了内部合作伙伴(微软内部其他使用 Monaco Editor 的团队)的确认,他们不再需要在 Monaco Editor 中支持 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(字典树)数据结构,其中每个节点都保存着解析后的主题选项:

Theme Trie

观察:constant.numeric.hexconstant.numeric.oct 的节点包含将前景色更改为 5 的指令,因为它们继承constant.numeric 的这条指令。

观察:var.identifier 的节点保留了额外的父规则 meta var.identifier,并将据此响应查询。

当我们想知道一个范围应该如何被主题化时,我们可以查询这个 Trie。

例如

查询 结果
constant 将前景色设为 4,字体样式设为 斜体
constant.numeric 将前景色设为 5,字体样式设为 斜体
constant.numeric.hex 将前景色设为 5,字体样式设为 粗体
var 将前景色设为 1
var.baz 将前景色设为 1(匹配 var
baz 无操作(不匹配)
var.identifier 如果存在父范围 meta,则将前景色设为 3,字体样式设为 粗体
否则,将前景色设为 2,字体样式设为 粗体

标记化变更

VS Code 中使用的所有 TextMate 标记化代码都位于一个单独的项目 vscode-textmate 中,该项目可以独立于 VS Code 使用。我们已更改了在 vscode-textmate 中表示范围堆栈的方式,使其成为一个不可变链表,该链表还存储完全解析的 metadata

当将新范围推送到范围堆栈时,我们将在主题 Trie 中查找新范围。然后,我们可以根据从范围堆栈继承的内容以及主题 Trie 返回的内容,立即计算出范围列表的完全解析的所需前景色或字体样式。

一些示例

范围堆栈 元数据
["source.js"] 前景色为 1,字体样式为常规(不带范围选择器的默认规则)
["source.js","constant"] 前景色为 4,字体样式为 斜体
["source.js","constant","baz"] 前景色为 4,字体样式为 斜体
["source.js","var.identifier"] 前景色为 2,字体样式为 粗体
["source.js","meta","var.identifier"] 前景色为 3,字体样式为 粗体

从范围堆栈中弹出时,无需计算任何内容,因为我们只需使用存储在先前范围列表元素中的元数据即可。

这是表示范围列表中元素的 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 x 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 中已不存在于 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 毫秒 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%
Tokenization times

尽管现在标记化也进行主题匹配,但时间节省可以通过对每行进行单次遍历来解释。以前,会有一个标记化过程,一个用于将范围“近似”为字符串的次级过程,以及一个用于二进制编码标记的第三个过程;现在,标记直接从 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%
Memory usage

内存使用量减少可以通过不再持有标记映射、具有相同元数据的连续标记的折叠以及使用 ArrayBuffer 作为后备存储来解释。我们可以通过始终将仅包含空白字符的标记折叠到前一个标记中来进一步改进此处,因为空白字符渲染成什么颜色并不重要(空白字符是不可见的)。

新的 TextMate 范围检查器小部件

我们添加了一个新的小部件,以帮助编写和调试主题或语法:您可以在**命令面板**中运行它,命令为**开发人员:检查编辑器标记和范围**(⇧⌘P(Windows、Linux Ctrl+Shift+P)。

TextMate scope inspector

验证变更

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

在 VS Code 中,我们有一个集成测试套件,用于断言我们随附的所有编程语言在我们编写的五种主题(Light、Light+、Dark、Dark+、High Contrast)下的颜色。这些测试在更改我们的某个主题以及更新特定语法时都非常有用。这 73 个集成测试中的每一个都包含一个固定文件(例如 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 主题

Quiet Light before

Quiet Light after

Red 主题

Red before

Red after

总结

我希望您能体会到升级到 VS Code 1.9 后获得的额外 CPU 时间和内存,并且我们能继续让您以高效愉快的方式进行编码。

编程愉快!

Alexandru Dima,VS Code 团队成员 @alexdima123