一、介绍
性能优化常常是软件开发人员绕不开的话题,对于前端开发人员来说更是如此。在此篇文章中,我们将探讨如何通过增量解析和Web Worker来优化Vscode编辑器(Monaco Editor)的性能。
二、代码解析的作用
在编辑器中,比如VsCode,IDEA等,将文本呈现给用户只是第一步,为了给用户良好的输入体验,我们需要对代码文本进行分析,根据代码的语法结构给用户准确的帮助和提示。具体来说,编辑器需要一个具有语言解析能力的解析器做支撑,通常来说至少会包括以下几个部分:
- 代码高亮:通过不同颜色的高亮来给用户良好的观看体验
- 语法校验:纠正用户的代码输入错误
- 代码折叠:实现代码折叠,换行缩进计算
- 代码提示:快捷插入代码片段
下图展示了JavaScript代码以及对应的代码解析结果
从上图可以看出,一个符合规范的JavaScript的代码同时也代表了一个抽象的树状结构,树的第一层为Program代表着程序的入口,它的第一层body(内容)是一个VariableDeclaration,它代表左图中的let tips,这是一个变量声明。而没有实际含义的注释内容(1-6行代码)在这个解析器中被忽略了。
三、代码解析对性能的影响
JavaScript有一个特点,它的执行是单线程的,意味着我们在使用JavaScript开发软件的时候需要避免某个任务过于消耗性能导致软件崩溃。VsCode编辑器会遇到一个典型的问题就是如何处理大文本数据。
我们以代码高亮解析的过程为例。通常来说,代码的解析过程往往是从上到下一行一行解析,直到最后一行。这种方式对于大部分场景来说是很通用的,但是对于一些比较复杂的代码,尤其是对于大文本量的代码,解析带来的消耗往往不可忽略。
下面展示了分别使用了一个流行的高亮库(HighLight.js)解析代码以及使用Monaco-Editor解析代码来实现代码高亮的时间消耗。
从上面的代码中可以看出,随着代码量的增加,使用常见词法解析库解析代码的时间会随着代码量的增加而线性增加,甚至会导致网页崩溃。对于编辑器来说,它的性能确几乎不受到代码量的增加影响,始终保持在一个不错的水平(660ms)。
四、性能优化的方式
4.1、使用增量解析避免主线程阻塞
一次性解析10万行或者100万行不可避免需要消耗大量的CPU计算,导致主线程阻塞, VsCode编辑器为了避免一次性解析带来的性能影响,主要通过将代码解析的步骤进行解耦,通过增量解析来实现避免过多一次的性能消耗。
基于行的解析
在VsCode解析高亮的过程中,它是以行为单位进行处理,我们在Monaco-Editor解析完一行代码后进行打印,看看他是如何处理10万行代码的?
Vscode通过一行一行解析代码
HighLightjs等其他代码解析工具,通常是基于“字符”解析,从第一个字符到最后一个字符解析完整之后返回所有的结果。与其不同的是,VsCode编辑器将“行”作为一个解析单位,并且解析是可以随时停止的,即使当你的当前的token状态需要跨越多个行的时候(比如JavaScript中的多行注释和多行字符串),它也可以通过保存当前行的状态,停止代码解析,直到下一次解析的开始,会继续当前的解析过程。这就像一个开关一样,可以随时停止或打开解析的步骤。
在requestIdleCallback中解析行
有了优化的方式,这时候我们该考虑何时开始进行代码解析。 在JavaScript中,我们通常将耗时的任务放到异步队列进行延迟执行,比如使用Promise,但是这不能完全解决处理大文本问题。我们必须找到其他更合适的处理方式。
requestIdelCallback是一个浏览器的API,我们每一次调用它,浏览器都会分配给用户一个短暂的空闲时间执行指定代码,常常是10ms以内,不要小看这个10ms,因为JavaScript的执行速度是很快的,浏览器的执行引擎可以在10ms内做很多事情。比如在上面的例子中平均10ms就可以解析完500行代码。
VsCode将行作为最小的解析单位,目前来看,VsCode不过是从第一个字符解析到最后一个字符变成了从第一行解析到最后一行,本质仍需要进行全量处理。解析一行代码是很快的,我们可以利用编辑器的解析特性(可以随时暂停并重启行), 将每一行的解析都放到一个新建的requestIdleCallback中,这样,就能有效避免全量处理带来的主线程阻塞。
接下来是VsCode编辑器中对这个部分的处理:
开始执行代码高亮,会执行一个runWhenIdle,这是一个兼容性的处理方法,如果当前的浏览器不支持requestIdleCallback,就使用定时器来模拟一个异步任务
_beginBackgroundTokenization() {
runWhenIdle((deadline) => {
this._isScheduled = false;
this._backgroundTokenizeWithDeadline(deadline);
});
}
deadline.timeRemaining获取浏览器给你分配的空闲时间。
可以看到这个函数的最后又递归调用了,就是在代码高亮未结束时,不断执行runWhenIdle。这样就可以保证不阻塞用户的其他行为。
class Example {
/**
*
* 不断循环解析操作,直到没有空闲的时间,如果没有空闲的时间,也至少会执行1-2ms
*/
_backgroundTokenizeWithDeadline(deadline) {
// 获取浏览器分配的空闲的时间截止时间
const endTime = Date.now() + deadline.timeRemaining();
const execute = () => {
if (
this._isDisposed ||
!this._tokenizerWithStateStore._textModel.isAttachedToEditor() ||
!this._hasLinesToTokenize()
) {
// 编辑器被销毁或者没有多余的行需要处理,就返回
return;
}
// 具体执行token解析的函数,下面会讲到
this._backgroundTokenizeForAtLeast1ms();
if (Date.now() < endTime) {
// 浏览器的空闲时间还有剩余,继续抛给浏览器继续执行
setTimeout0(execute);
} else {
// 没有多余的空闲时间,安排一个新的空闲回调
this._beginBackgroundTokenization();
}
};
execute();
}
}
开始执行代码高亮, backgroundTokenizeForAtLeast1ms表示每次执行代码高亮解析,至少需要间隔1ms
stopWatch也是一个polyfiil(填充库),通过performance的api来判断间隔是否超过了1ms
/**
* Tokenize for at least 1ms.
*/
_backgroundTokenizeForAtLeast1ms() {
const lineCount = this._tokenizerWithStateStore._textModel.getLineCount();
const builder = new ContiguousMultilineTokensBuilder();
const sw = StopWatch.create(false);
do {
// sw.elapsed()表示从StopWatch对象创建以来经过的时间,单位为毫秒。
// 通过比较sw.elapsed()和1,如果sw.elapsed()超过1毫秒,则跳出循环。
if (sw.elapsed() > 1) {
break;
}
const tokenizedLineNumber = this._tokenizeOneInvalidLine(builder);
if (tokenizedLineNumber >= lineCount) {
break;
}
} while (this._hasLinesToTokenize());
this._backgroundTokenStore.setTokens(builder.finalize());
this.checkFinished();
}
- 补充:为何是1ms?
上面说到,js的执行速度是很快的。并且requestIdleCallback分配给的用户的空闲时间通常是几毫秒,所以虽然是1ms,但是也占据了约大于1/10的空闲时间。由于是基于行的解析,并且,解析的时间可能太短,为了避免短时间大量创建新的解析函数,所以这里有至少1ms的限制。
以上就是代码解析的主要流程,但是我们还需要考虑一些边界case,比如一个使用打包工具构建的文件可能被压缩成一行代码,这时候单行解析就等于全量解析了,大部分情况下用户不需要去解析一个被打包压缩的代码,这时候,可以设置最大的解析限制来避免编辑器对于这种无效代码进行解析。
最后,再来看一次Vscode的解析过程:
一个10万行内容的编辑器,需要展示给用户的部分(前22行),很快就被解析完成并呈现给用户,其他99.99%未解析的内容都在递归调用的requestIdleCallback进行处理了。和算法中的分而治之,编辑器将冗长的解析拆分为一个个小的任务处理,最后合并将结果进行合并处理,得到完整的代码信息。
4.2 使用Web Woker实现并行解析
requestIdleCallback的限制
就像在开头提到的那样,一个完整的代码编辑器不止需要高亮代码,也需要完成编程语言的语法高亮,代码块的缩进分析等。
在上面的示例中,我们将代码高亮解析这个部分放在requestIdelBack中。利用浏览器的空闲时间执行高亮分析,可以有效地解决这用户的输入问题。然而用户的每一个编辑器操作,都需要更新当前的所有状态,因为每一个requestIdleCallback所分配的空闲时间是有限的,如果将其他能力的解析执行也放到这个中requestIdleCallback中,我们无法保证其可以在较小的时间内同时也完成额外分配的任务。
同时,JavaScript是单线程的,意味着我们无法创建其他requestIdelCallback来执行语法分析或者代码嵌套等分析。为了实现多个解析并行的操作而不阻塞用户的操作,我们可以利用web worker的多线程能力来完成这个步骤。
web worker 简介
在传统的JavaScript单线程模型中, JavaScript运行在主线程上,会阻塞用户界面的刷新,同时对CPU或网络资源的高强度占用(如复杂的图形计算、大文件的读取等)也会导致网页表现出“卡死”或者反应过慢的现象。而Web Worker的出现,可以为JavaScript创建一个单独的运行环境,使得JavaScript可以在工作线程中运行,解放主线程,避免了影响到用户界面的刷新和交互。
编辑器中woker实现
语言服务会创建 Web Worker,以便在用户界面线程之外计算繁重的工作。它们几乎不会耗费任何资源开销,只要能正常工作,就不需要太担心它们。
现在,我们可以将完整的解析过程交给其他线程,以JSON语言为例,在一个json-worker中,我们可能需要处理以下的语言服务:
// 用于解析和请求 JSON schema,确定 JSON 对象的模式匹配。
var jsonSchemaService = new JSONSchemaService(params.schemaRequestService, params.workspaceContext, promise);
jsonSchemaService.setSchemaContributions(schemaContributions);
// 用于基于 JSON schema 提供自动填充建议。
var jsonCompletion = new JSONCompletion(jsonSchemaService, params.contributions, promise, params.clientCapabilities);
// 用于提供鼠标悬停提示。
var jsonHover = new JSONHover(jsonSchemaService, params.contributions, promise);
var jsonDocumentSymbols = new JSONDocumentSymbols(jsonSchemaService);
// 用于验证 JSON 数据。
var jsonValidation = new JSONValidation(jsonSchemaService, promise);
4.3、编程语言中的增量解析
通过优化的requestIdleCallback以及使用web woker,可以保证编辑器的优秀性能的同时不阻塞用户的输入。就像Vue或者React的虚拟dom的作用一样,在编程语言解析的过程中,我们可以通过他的一些语法特性或者语法规则,在不同的语言服务中进行进一步的解析优化。
一个典型的例子就是当用户的对代码进行编辑,当用户每进行一次删除,插入等操作时,都会改变当前的内容,也就意味我们需要重新进行代码解析。
这里以代码高亮解析为例,用户的编辑操作通常只会影响到较少的行数,如果我们只处理出现“异常”的代码块部分,就可减少很多的额外工作。如下我们有一个10w+的代码如下:
/*
这里代表前方有5w行代码
*/
// 第50000行代码
const foo = () => {}
/*
后续的5万行代码...
*/
现在,我们将第5000行代码进行如下的改动(只需更新第5000行的token):
const foo = () => {};
如果我们现在只更新第5000行的token,那是没问题的。但是,现在如果将第5000行的代码改动如下:
const foo = () => `str
在其他行的代码没有改动的情况下,可以将这行视为一个 因为`可以跨过多行,这时候,在Monaco中,他会将当前的反引号作为一个新的开头,对后续的token都视为一个多行注释,不断更新所有代码的状态直到最后一行。
结语
Vscode作为一款优秀的开源软件,以上只是列举了一些常见的性能优化方法。然而Vscode需要面临的问题不止如此,比如如何实现一个高效的数据结构来存储和搜索文本,如何解决字符串溢出等问题,这些都是一款编辑器绕不开的问题。