Skip to the content.

VSCode 中的代码高亮

VSCode 使用 Token 来代表 TextModel 中的高亮数据(比 Lexer 生成的词法单元包含更多的数据)。要讨论 VSCode 中的高亮机制,我们必须要回答三个问题:

  1. 高亮的数据从哪来?
  2. 编辑器该何时获取/生成高亮数据?
  3. 编辑器如何根据高亮数据调整展示给用户的文本?

让我们跟着 VSCode 的源码一点点地回答这些问题。


相关文件及类:


TextModel 是 VSCode 编辑器的核心,我们不妨从它开始着手。

在 VSCode 的 TextModel 中,和 Tokenization 相关的数据有:

VSCode 的早期版本中仅提供了基于语法的高亮,这会导致用户通过高亮能获得的信息远少于编译器前端能够生成的;在某一次版本更新中,VSCode 添加了对于 语义 Token 的支持,即能够在 LSP 的支持下向代码中添加基于语义的高亮(例如能够区分不同 identifier 之间的区别);在代码里,这两种 Tokenize 结果也分别存在 TokensStoreTokensStore2 中。语法高亮相对于语义高亮来说对于响应速度的要求会更高。

语法高亮

我们先来讲 语法高亮——即 TokensStore 中的数据 是怎么来的。VSCode 中大量使用了依赖注入的方式进行行为与调用的解耦,Tokenize 也不例外。Tokenize 的行为通过接口 ITokenizationSupport 进行了定义:

export interface ITokenizationSupport {
	getInitialState(): IState;
	tokenize(line: string, hasEOL: boolean, state: IState, offsetDelta: number): TokenizationResult;
	tokenize2(line: string, hasEOL: boolean, state: IState, offsetDelta: number): TokenizationResult2;
}

值得一提的是,tokenizetokenize2 提供的是(语义上)相同的 Tokenize 结果,而它们的区别仅仅在于 Token 的表示形式:tokenize 是早期 Monaco Editor 使用的 Tokenize 接口,并在 Monaco Editor 以 VSCode 的子模块进行开发后仍然在使用这个接口。而在 Monaco Editor 中,Token 使用一个二元组来表示:

export class Token {
	public readonly startIndex: number;
	public readonly type: string;
}

let 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' }
];

这样的表示会占据大量的额外空间,VSCode 开发者在一次优化中通过一个整数代替了上述的字符串 Token 类型表示;为了兼容旧代码,使用了 tokenize2 这种形式避免了名字冲突。

回到 ITokenizationSupport,它提供的 Tokenize 接口实际上是以行为单位的增量方法:调用者可以通过 getInitialState 生成初始状态,并逐步通过 tokenize2 方法获取当前行中的 Token 以及解析完成后的状态(用于下一次解析)。

ITokenizationSupport 的实现者——即语法高亮提供者,在 VSCode 中(而非 Monaco Editor)以 TextMate 实现,对应于 TMTokenizationSupport

TMTokenizationSupportTMTokenization 的一层封装,并添加了对于超过 Tokenize 阈值的行的处理;而 TMTokenization 则添加了对于语言嵌套的支持(例如 Markdown 中的 Java 代码),并把 Tokenize 请求转发给 IGrammer 的实例。

而 TextMate 对应的所有 Tokenize 以及注册管理则通过 TextMateService 进行管理,它会通过 TMGrammarFactory 获取到 TMScopeRegistry 中保存的 IValidGrammarDefinition,并调用 loadGrammarWithConfiguration 以获得对应语言的 IGrammar 实例。

至此,我们已经知道了 Tokenize 行为的由来;那么我们来看下一个问题:编辑器是如何获取这些数据的?

我们从 TextModelTokenization 开始,它提供的方法有:

那么增量 Tokenization 是怎么做的?

之前我们提到 IState 是用于表示 Tokenize 中间状态的数据,并能够用于增量 Tokenize;而 TextModelTokenization 中的 TokenizationStateStore 则保存了每一行可能的 IState 以及这些缓存的可用性;在检查 isCheapToTokenize 时会检查缓存中的数据是否已经包括了当前需要的行、tokenizeViewport 也会找到最近的保存了 Tokenize 状态的行开始重新对 Viewport 里的内容进行计算。

虽然 TextModelTokenization 提供了直接获取某段文本的 Tokenize 结果的方法,但它同时也会异步地对全文进行 Tokenize 计算:

当 Token 被 reset、TextModel 中的内容发生了全量变更或语言进行了切换时,TextModelTokenization 会启动后台 Tokenize 过程:

class TetxModelTokenization {
    private _beginBackgroundTokenization(): void {
        if (this._textModel.isAttachedToEditor() && this._hasLinesToTokenize()) {
            platform.setImmediate(() => {
                if (this._isDisposed) {
                    // disposed in the meantime
                    return;
                }
                this._revalidateTokensNow();
            });
        }
    }
}

如果当前的 TextModel 绑定在编辑器上 且 Tokenize 并没有全部完成,那么在下一个 Tick 中请求一次 _revalidateTokensNow

class TetxModelTokenization {
    private _revalidateTokensNow(toLineNumber: number = this._textModel.getLineCount()): void {
        const MAX_ALLOWED_TIME = 1;
        const builder = new MultilineTokensBuilder();
        const sw = StopWatch.create(false);

        while (this._hasLinesToTokenize()) {
            if (sw.elapsed() > MAX_ALLOWED_TIME) {
                break;
            }
            const tokenizedLineNumber = this._tokenizeOneInvalidLine(builder);
            if (tokenizedLineNumber >= toLineNumber) {
                break;
            }
        }
        this._beginBackgroundTokenization();
        this._textModel.setTokens(builder.tokens);
    }
}

_revalidateTokensNow 会试图在 1ms 内完成尽量多逻辑行的 Tokenize 结果,并通过 MultilineTokens 的形式添加到 TextModel 中;在此后继续试图不阻塞地对文本进行 Tokenize。

上面的 Tokenize 行为相当于某种非阻塞的全局 Tokenize 进程,而 forceTokenizationtokenizeViewport 则是给 TextModel 一个主动进行 Tokenize 的方式。

forceTokenization 主要用于在必须使用到 Token 信息的场合,例如:缩进的计算需要当前的 Token 信息、光标移动、括号补全等;而 tokenizeViewport 则是作为 ViewModel 的 Tokenize 方法的具体实现,在滚动区域发生变化、或者 Tokenizer 提供者发生变化时,会以 50ms 的延迟作为异步任务被完成,从而使得用户能够及时地获取视野中的代码高亮。

不过和正常的全文代码高亮不同的是,tokenizeViewport 如果不能直接使用到缓存的 Tokenize 结果及状态,则会使用一个轻量级的 Tokenize 策略:找到保存了 Tokenize 状态中里当前 Viewport 最近的保存了状态的逻辑行,然后从该行开始收集文本中包含了非空格字符的行并进行 Tokenize 直到遇见视野内的逻辑行,并完成实际的 viewport Tokenize 流程;不过这里生成的内容可能是错误的,所以不会在缓存中保存下这些 Token

语义高亮

在前文中我们提到,语义高亮的数据均保存在 TokenStore2 里,那么它的数据是哪来的呢?

根据 setTokens2 的调用关系,很容易发现直接调用者是 ModelSemanticColoring。在ModelSemanticColoring 中会保存一个 Scheduler,这个 Scheduler 会在 300ms 后调用一次 fetchDocumentSemanticTokensNow,其逻辑如下:

fetchDocumentSemanticTokens 会在以下场合被调用,没有特殊声明则会经过默认 300ms 的延时:

那么 ModelSemanticColoring 又是如何被创建的呢?

我们注意到,VSCode 的依赖注入中存在一个用于进行 TextModel 管理的 Service:ModelService,它提供了 TextModel 的创建、内容更新、模式选择等 API,同时也控制了一个 SemanticColoringFeature 作为它的子模块。SemanticColoringFeatureModelService 通知其关于 TextModel 的创建等事件时,在满足能够使用语义 Token 的情况下则会对这个 TextModel 添加对应的 ModelSemanticColoring 实例,用于提供上述的 schedule 语义 Token 等功能。

类似于语法高亮,语义高亮也会对 Viewport 中的内容做单独的优化。TextModel 中提供了 setPartialSemanticTokens 来对某个区间内部的语义 Token 进行更新,而这个 API 的调用者是 ViewportSemanticTokensContribution,它提供了 _tokenizeViewportNow 用于对 Viewport 内的文本进行部分的语义高亮,其逻辑如下:

数据存储

上面我们讨论的是数据的来源,那么这些数据又是如何表示、并最终成为我们看到的 DOM 结点的呢?

先来看语法 Token。它们以 TokensStore 的形式保存在 TextModel 中,而 TokensStore 里是用 Uint32Array[] 来存储 Token 的。

VSCode 里的 Token 一般是使用两个 32 位整数来表示:Token 结束位置对应的 utf16 文本长度和一个元数据。其中元数据的表示如下:

3322 2222 2222 1111 1111 1100 0000 0000
1098 7654 3210 9876 5432 1098 7654 3210
bbbb bbbb bfff ffff ffFF FTTT LLLL LLLL

每个 Uint32Array 用来表示一行内的 Token,每一行对应于一个数组。而 Token 中的偏移量也仅仅是逻辑行内的文本偏移量。这样的表示使得用于表示 Token 的内存占用可以变得非常小,不过基于行的数组也使得这种方式无法支持过于倾斜的文本(过长的行或者过多的行):因为对于文本的删除需要处理其跨越的所有逻辑行,对于二维数组而言开销相对较大。

而对于语义 Token 而言,语言服务并不会对所有的文本都添加语义 Token,即它们可能是不连续的。这些不连续的 Token 保存在 TokensStore2 中的 MultilineTokens2 里。在 MultilineTokens2SparseEncodedTokens 中。每个语义 Token 对应于一个 Uint32Array 数组中的 4 个整数:相对于开始的逻辑行的逻辑行偏移、起始位置相对于当前逻辑行的开始的偏移量、结束位置相对于当前逻辑行的偏移量、Token 元数据。由于需要保存更多的数据,语义 Token 在遇到文本编辑时需要进行更加精细的调整。

对于 TokensStoreTokensStore2 的查询往往只通过 TextModel 的 getLineTokens。此方法首先从 TokensStore 获取特定逻辑行的语法 Token(使用包含了 Uint32ArrayLineTokens 表示),然后再从 TokensStore2 获取语义 Token 并添加到此结果上。语义 Token 相对于语法 Token 具有更高的优先级,VSCode 在实现时会按照顺序同时遍历语法 Token 和语义 Token,根据语义 Token 覆盖的范围对语法 Token 进行切割,然后使用语义 Token 元数据中的样式覆盖原本的 Token 样式得到最终的生成结果。

总结

不难看出,VSCode 中对于高亮持有的是一种非常实用主义的态度(能用就行)。相较于 IDEA 中统一使用 RangeMarker 进行管理的形式相比,直接使用二维数组的 Token 表示在一定程度上节省了内存开销和对于大部分正常代码编辑的高亮更新使劲按,同时,VSCode 采取的异步非阻塞式语法高亮策略避免了多进程通信问题和复杂高亮计算可能导致的编辑器 UI 响应迟缓;但 VSCode 也因此放弃了对于逻辑行内部的增量 Tokenize 等功能,使得对于部分场景下的文本编辑性能较差。

Reference:

https://code.visualstudio.com/blogs/2017/02/08/syntax-highlighting-optimizations

https://code.visualstudio.com/api/language-extensions/semantic-highlight-guide

https://github.com/microsoft/vscode