在过去的两篇文章中,我们已经谈到了linting
的很多内容,因此我认为是时候给 eslint
应有的关注了。总体而言,eslint
是如此灵活,以至于你甚至可以替换解析器为完全不同的解析器。这并不是一个罕见的情况,因为随着 JSX
和 TypeScript
的崛起,经常会这样做。拥有丰富的插件和预设生态系统,eslint
可能有适用于各种用例的规则,而且如果没有,出色的文档会指导你如何创建自己的规则。这是我想在这里强调的一点,因为它是一个经受住了时间考验的项目。
但这也给性能分析带来了问题,因为由于配置的灵活性,两个项目在进行代码检查时的性能体验可能大相径庭。然而,我们需要从某个地方开始,因此我觉得我们开始调查的最好方式是查看 eslint
库本身使用的代码检查设置!
使用 eslint
他们的存储库使用任务运行器抽象来协调常见的构建任务,但通过一点点挖掘,我们可以拼凑出用于 “lint” 任务的命令,具体用于检查 JavaScript 文件。
1 | node bin/eslint.js --report-unused-disable-directives . --ignore-pattern "docs/**" |
很好,这里有它:Eslint
使用 eslint
来检查他们的代码库!就像本系列的前两篇文章一样,我们将通过 node
内置的 --cpu-prof
参数生成一个*.cpuprofile
文件,并将其加载到Speedscope
中进行进一步的分析。几秒钟后(确切地说是 22 秒),我们准备好深入了!
通过合并相似的调用栈,我们可以更清晰地了解时间花费在哪里。这通常被称为“left-heavy”
可视化。这与标准的火焰图不同,其中 x 轴表示调用发生的时间。相反,在这种风格中,x 轴表示总时间的消耗,而不是发生的时间。对我来说,这是 Speedscope
的主要优势之一,感觉也更灵活。这一点也在意料之中,因为它是由 Figma
的几位开发人员编写的,他们以在我们行业中的工程卓越而闻名。
我们立即就能看出eslint
仓库中 linting
设置花费时间的几个关键领域。突出显示的主要事项是总时间的很大一部分花费在处理 JSDoc 的规则上,这可以从它们的函数名称推断出来。另一个有趣的方面是在 lint 任务的不同阶段运行了两个不同的解析器:esquery
和 acorn
。但是我对处理JSDoc
规则花费了这么长时间感到好奇。
其中一个 BackwardTokenCommentCursor
条目似乎很有趣,因为它是其中最大的一块。通过跟踪源代码中的附加文件位置,它似乎是一个保存文件中当前位置状态的类。作为第一措施,我添加了一个简单的计数器,每当实例化该类时递增,然后再次运行 lint
任务。
构建 2000 万次?
总的来说,这个类已经被构造了 2000 万次以上。这似乎相当多。请记住,我们实例化的任何对象或类都会占用内存,这段内存随后需要被清理。我们可以在数据中看到这一点,垃圾回收(清理内存的操作)总共需要 2.43 秒。这不太好。
在创建该类的新实例时,它调用了两个函数,两者似乎都会启动一个搜索。不过由于不知道它具体在做什么,我们可以排除第一个函数,因为它不包含任何形式的循环。根据经验,循环通常是性能问题的主要嫌疑对象,因此我通常从那里开始搜索。
不过,第二个名为 utils.search()
的函数包含一个循环。它循环遍历从当前正在 lint 的文件内容解析出的标记流。标记是编程语言的最小构建块,您可以将它们视为语言的“单词”。例如,在 JavaScript 中,“function”
通常表示为一个函数标记,逗号或单分号也是如此。在这个 utils.search()
函数中,我们似乎关心找到文件中当前位置最近的标记。
1 | exports.search = function search(tokens, location) { |
为了做到这一点,搜索是通过JavaScript
的原生 .findIndex()
方法在标记数组上进行的。该算法描述如下:
findIndex()
是一种迭代方法。它按升序索引顺序为数组中的每个元素调用一次提供的callbackFn
函数,直到callbackFn
返回一个truthy
值。
考虑到标记数组随着文件中代码的增加而增长,这听起来并不理想。我们可以使用比遍历数组的每个元素更有效的算法来搜索数组中的值。例如,将该行替换为二进制搜索可以将时间减半。
尽管减少了50%
看起来很不错,但仍然不能解决这段代码被调用 2000 万次的问题。对我来说,这才是真正的问题。我们更多地是在试图减轻症状的影响,而不是解决潜在问题。我们已经在遍历文件,所以我们应该确切地知道我们在哪里。不过,更改这一点需要进行更深入的重构,而且对于本博客文章来说可能太复杂。看到这不是一个容易的修复方案,我查看了性能剖析中还值得关注的内容。中心的长紫色条很难忽视,不仅因为它们是不同的颜色,而且因为它们占用了很多时间,并且没有深入到数百个更小的函数调用中。
选择器引擎
在 Speedscope
中的调用栈指向了一个我在此调查之前并不了解的项目,名为 esquery。这是一个旧项目,其目标是通过一个小型选择语言能够在解析的代码中找到特定的对象。如果你仔细看,你会发现它与 CSS
选择器有很大的相似之处。在这里,它们的工作方式相同,只是我们不是在 DOM
树中找到特定的 HTML
元素,而是在另一种树结构中找到一个对象。这是相同的思路。
追踪该 npm
包含有混淆过的源代码。通常是单个字符的搅乱变量名强烈提示着这样的过程正在进行。幸运的是,该包还提供了未混淆的变体,所以我只需修改 package.json
指向那个版本。再次运行后,我们看到以下数据:
好多了!需要记住的一件事是未混淆的代码执行速度大约比混淆过的慢大约 10-20%
。这是我在职业生涯中多次测量混淆与未混淆代码性能时得到的一个粗略的范围。尽管如此,相对的时间保持不变,所以它对我们的调查仍然是完美的。有了这个,getPath
函数似乎是需要一些帮助的地方。
1 | function getPath(obj, key) { |
过时的代码
如果你在 JavaScript
工具领域有一段时间了,这些函数看起来非常熟悉。几乎可以肯定,_createForOfIteratorHelper
是由他们的发布流水线插入的函数,而不是由这个库的作者插入的。当for-of
循环被添加到 JavaScript
时,它需要一段时间才能在所有地方得到支持。
对现代 JavaScript
特性进行向下转译的工具往往在保守方面出错,并以非常保守的方式重写代码。在这种情况下,我们知道我们正在将一个字符串拆分为一个字符串数组。使用完整的迭代器来循环遍历它是完全不合适的,使用一个普通的标准循环就足够了。但由于工具不知道这一点,它们选择了涵盖可能情况的变体。以下是原始代码以供比较:
1 | function getPath(obj, key) { |
在当今的世界中,for-of
循环得到了普遍支持,因此我再次修改了这个包,用源代码中的原始实现替换了函数实现。这一个简单的更改节省了大约 400 毫秒的时间。我总是对我们在浪费性能的多余填充或过时的向下转译中花费了多少 CPU 时间感到印象深刻。你可能认为这并没有多大的区别,但当你遇到像这样的情况时,数字就描绘出了一个不同的画面。值得一提的是,我还测量了使用标准的 for 循环替换 for-of
循环的情况。
1 | function getPath(obj, key) { |
令人惊讶的是,与 for-of
变体相比,这又带来了 200 毫秒的改进。我想即使在今天,对于引擎来说,for-of
循环可能仍然较难优化。这让我想起了我和 Jovi
进行的一项调查,当时 graphql
包在新发布中切换到 for-of
循环后,解析速度突然变慢。
这是 v8/gecko/webkit
工程师可以正式验证的事情,但我的假设是它仍然必须调用迭代器协议,因为该协议可能已全局覆盖,这将改变每个数组的行为。大概是这样的情况。
虽然我们从这些更改中获得了一些快速的收益,但总体而言,仍然远非理想。总体而言,该函数仍然是改进的主要候选者,因为它单独负责总时间的几秒钟。再次应用快速计数器小技巧,显示它大约被调用了 22k 次。毫无疑问,这是一种在“热”路径上的函数。在许多涉及字符串的性能密集型代码中,特别值得注意的是 String.prototype.split()
方法。这实际上会遍历所有字符,分配一个新数组,然后遍历该数组,而所有这些都可以在单次迭代中完成。
1 | function getPath(obj, key) { |
这次重写对性能产生了巨大的影响。当我们开始时,getPath
总共需要 2.7 秒,通过应用所有优化,我们成功将其降低到 486 毫秒。
继续研究 matches()
函数,我们看到由奇怪的for-of
降级转译创建的很多开销,类似于之前所见。为了节省时间,我直接在 GitHub
上复制了源代码中的函数。由于在traces
中 matches()
更为显著,仅这一更改就节省了整整 1 秒。
我们生态系统中有很多库都受到了这个问题的影响。我真希望有一种方法可以通过一次点击更新它们。也许我们需要一个反向转译器,它能检测到降级转译模式并将其转换回现代代码。
我联系了 jviide,看看我们是否能进一步优化 matches()
。通过他的额外更改,我们成功使整个选择器代码相比于原始未修改状态快了约 5 倍。基本上,他做的就是摆脱了 matches()
函数中的一堆开销,这使他能够简化一些相关的辅助函数。例如,他注意到模板字符串被降级转译得不好。
1 | // input |
他甚至更进一步,通过将每个新选择器实时解析为一系列函数调用,并缓存生成的包装函数。这个技巧为选择器引擎带来了另一个显著的加速。我强烈建议查看他的更改。我们没有提出 PR,因为目前似乎没有人在维护 esquery。
尽早中断
有时候,最好退后一步,从不同的角度解决问题。到目前为止,我们看了一些实现细节,但我们实际上正在处理什么样的选择器呢?有没有潜力提前终止其中一些选择器?为了测试这个理论,我首先需要更好地了解正在处理的选择器的类型。不出所料,大多数选择器都很短。然而,其中有一些相当复杂。例如,这是一个单一的选择器:
1 | VariableDeclaration:not(ExportNamedDeclaration > .declaration) > VariableDeclarator.declarations:matches( |
这绝对是一个让我觉得我们离题有点远的例子。当这个选择器没有正确匹配时,我不想成为必须调试它的人。这是我对任何形式的自定义领域特定语言的主要不满。它们通常根本不提供工具支持。相反,如果我们留在 JavaScript 领域,我们可以使用适当的调试器随时检查任何步骤的值。虽然先前的字符串选择器示例有点极端,但大多数选择器看起来像这样:
BinaryExpression
ORBinaryExpression
就是这样。大多数选择器只是想知道当前 AST 节点是否是某种类型。仅此而已。对于这种情况,我们实际上不需要一个完整的选择器引擎。如果我们为此引入了一种快速路径,并完全绕过了选择器引擎,会怎样呢?
1 | class NodeEventGenerator { |
由于我们已经绕过了选择器引擎,我对将一个字符串选择器与一个作为纯 JavaScript 函数编写的选择器进行比较产生了兴趣。我的直觉告诉我,作为简单 JavaScript 条件编写的选择器会更容易被引擎优化。
重构选择器
如果你需要在不同语言之间传递遍历命令,比如在浏览器中使用 CSS,选择器引擎就非常有用。然而,选择器引擎从来都不是免费的,因为选择器引擎始终需要解析选择器以拆解我们应该执行的操作,然后动态构建一些逻辑来执行该解析的内容。
但在eslint
中,我们并未跨越任何语言屏障。我们仍然在JavaScript
领域内。因此,通过将查询指令转换为选择器并将其解析回我们可以再次运行的形式,我们在性能上并没有得到任何好处。相反,我们花费了整个linting
时间的约 25% 用于解析和执行选择器。我们需要一种新的方法。
然后,我恍然大悟。
选择器在概念上不过是一个“描述”,以根据它所持有的条件查找元素。这可以是对树或平面数据结构(如数组)的查找。如果你仔细思考,即使在标准 Array.prototype.filter()
调用中的回调函数也是一个选择器。我们正在从一组项目(=Array)
中选择值,并仅挑选我们关心的值。我们使用 esquery
所做的事情正是相同的。在一堆对象(=AST 节点)
中,我们挑选出符合某些条件的对象。这就是选择器!那么,如果我们避开选择器解析逻辑,改用纯 JavaScript
函数会怎样呢?
1 | // String based esquery selector |
让我们试一下!我编写了一些基准测试来测量这两种方法的时间差异。过了一会儿,数据出现在我的屏幕上。
|项目 |foo.substr(1, 2) ops/sec|
|–|–|
|esquery|422,848.208|
|esquery(优化后)|3,036,384.255|
|纯 JavaScript 函数|66,961,066.5239|
看起来纯 JavaScript
函数变体很容易胜过基于字符串的选择器。它明显更强大。即使在花费了所有这些时间使 esquery
变得更快之后,它仍然远远不及 JavaScript
变体。在选择器不匹配且引擎可以更早退出的情况下,它仍然比纯函数慢 30 倍。这个小实验确认了我的猜想,即我们为选择器引擎付出了相当多的时间。
第三方插件和预处理的影响
尽管在 eslint
的设置中从 profile
中可见有更多的优化空间,我开始怀疑我是否在优化正确的东西。在其他 linting
设置中是否也出现了我们在 eslint 自己的 linting
设置中看到的相同问题?eslint
的一个关键优势一直是其对第三方 linting 规则的灵活性和支持。回顾一下,我参与的几乎每个项目都有一些定制的 linting 规则和安装了约 2-5 个额外的 eslint
插件或预设。但更重要的是,它们完全切换了解析器。快速查看 npm 下载统计数据突显了替换 eslint
内置解析器的趋势:
|Package| npm downloads (weekly) |%|
|–|–|–|
|eslint |31.718.905 100%|
|@typescript-eslint/parser| 23.192.767| 73%|
|@babel/eslint-parser| 6.057.110 |19%|
如果这些数字可信,那么这意味着只有 8% 的 eslint
用户使用内置解析器。它还展示了 TypeScript 已经变得非常普遍,占据了 eslint
总用户数的大部分,达到了 73%。我们没有关于使用 babel 解析器的用户是否也用于 TypeScript
的数据。我猜其中一部分用户是这样做的,总的 TypeScript
用户数量实际上可能更高。
在对各种开源存储库中的几种不同设置进行性能分析后,我选择了 vite 的设置,其中包含其他配置文件中存在的许多模式。它的代码库是用 TypeScript
编写的,eslint 的解析器已经相应地替换了。
与之前一样,我们可以在性能分析中找到各种领域,显示时间花费在哪里。有一个区域暗示了从 TypeScript 格式转换为 eslint
理解的格式需要相当长的时间。配置加载也发生了一些奇怪的事情,因为它在这里花费的时间不应该那么多。我们还发现了一个老朋友,eslint-import-plugin
和 eslint-plugin-node
,它们似乎启动了大量的模块解析逻辑。
然而,这里有趣的是,选择器引擎的开销并没有显示出来。有一些 applySelector
函数的实例被调用,但在更大的图景中几乎不消耗任何时间。
总是会出现的两个第三方插件,即 eslint-plugin-import
和 eslint-plugin-node
,总是需要相当长的时间来执行。每当这两个插件中的一个或两个激活时,在性能分析数据中确实显示出来。这两个插件引起了大量的文件系统流量,因为它们尝试解析一堆模块,但不缓存结果。在本系列的第二部分中,我们已经详细介绍了这方面的内容,所以我不会进一步详细说明。
转换所有的 AST 节点
我们将从一开始发生的 TypeScript 转换开始。我们的工具将我们提供给它们的代码解析成一种称为抽象语法树(AST)的数据结构。简而言之,你可以将其视为所有我们工具使用的构建块。它提供了诸如:“嘿,在这里我们声明了一个变量,它有这个名称和那个值”或“这里是一个具有此条件的 if 语句,它保护了那个代码块”等信息。
1 | // `const foo = 42` in AST form is something like: |
你可以亲自查看我们的工具在 AST Explorer 页面上是如何解析代码的。我强烈推荐访问该页面并尝试使用各种代码片段。这可以让你很好地了解我们的工具的 AST 格式是多么相似或者经常有多大的不同。
然而,在 eslint 的情况下,存在一个问题。我们希望规则能够在我们选择的任何解析器上工作。当我们激活no-console
规则时,我们希望它在所有解析器上都能起作用,而不是强迫每个规则为每个解析器单独重写。基本上,我们需要一个共享的 AST 格式,我们都可以达成一致。这正是 eslint
所做的。它期望每个 AST 节点都符合 estree
规范,该规范规定了每个 AST 节点应该是什么样子。这个规范已经存在了很长时间,许多 JavaScript 工具最初就是基于它构建的。甚至 babel 也是基于它构建的,但后来有一些文档记录的偏差。
但问题就在于,当你使用 TypeScript 时就出现了问题。TypeScript 的 AST 格式非常不同,因为它还需要考虑到表示类型本身的节点。一些结构在内部表示方式上也有所不同,因为这样对 TypeScript 本身更方便。这意味着必须将每个 TypeScript AST 节点转换为 eslint 可理解的格式。这种转换需要时间。在这个分析中,这占据了总时间的约 22%。它花费时间之所以如此长,并不仅仅是遍历本身,还因为每次转换时我们都会分配新对象。基本上,我们在内存中有两份不同的 AST 格式的副本。也许 babel 的解析器更快?如果我们将 @typescript-eslint/parser
替换为 @babel/eslint-parser
呢?
|What |Parse time|
|–|–|
|@typescript-eslint/parser |2.1s|
|@babel/eslint-parser + @babel/preset-typescript |0.6s|
结果表明,仅仅这样做就为我们节省了相当多的时间。有趣的是,这个更改还大大减少了配置加载的时间。配置加载时间的改进可能是因为 babel 的解析器分布在较少的文件中。
请注意,尽管 babel
解析器(截至本文撰写时)明显更快,但它不支持类型感知的 linting
。这是 @typescript-eslint/parser
专有的功能。这为一些规则提供了可能性,比如他们的 no-for-in-array
规则,该规则可以检测在for-in
循环中迭代的变量是否实际上是对象而不是数组。因此,您可能希望继续使用 @typescript-eslint/parser
。但如果您确信自己没有使用它们的任何规则,只是希望 eslint
能够理解 TypeScript
的语法,并且更快速地进行 linting
,那么切换到babel
的解析器是一个不错的选择。
奖励环节:理想的 linter 应该是什么样的?
在这一点上,我偶然发现了一个有关 eslint
未来的讨论,其中性能是首要任务之一。在这里提出了一些建议,尤其是关于引入会话概念以允许完整的程序 linting,与当前的基于每个文件的方式不同。考虑到至少 73% 的 eslint 用户将其用于 lint TypeScript
代码,更紧密的集成需要较少的 AST 转换,从性能的角度来看也将是巨大的。
还有一些关于使用Rust
进行移植的讨论,这引起了我的好奇心,想知道当前基于 Rust
的 JavaScript
的 linters
有多快。似乎唯一一个在某种程度上已经准备就绪,能够解析 TypeScript
语法大部分内容的是 rslint
。
除了 rslint
,我还开始思考纯 JavaScript 中一个简单 linter
会是什么样子。一个不具备选择器引擎、不需要不断进行 AST 转换的 linter,只需解析代码并在其上检查各种规则的必需内容。因此,我用一个非常简单的 API 包装了 babel 的解析器,并添加了自定义遍历逻辑来遍历 AST 树。我没有选择 babel 自己的遍历函数,因为它们在每次迭代时会导致大量的分配,并且构建在生成器上,这比不使用生成器要慢一些。我还尝试了一些我自己几年前编写的自定义 JavaScript/TypeScript
解析器,这些解析器最初是从几年前将 esbuild 的解析器移植到 JavaScript
中而来。
话虽如此,在 vite 仓库(144 个文件)上运行它们时的数据如下:
|whar|time|
|–|–|
|eslint(JavaScript)|5.85 秒|
|自定义 linter(JavaScript)|0.52 秒|
|rslint(基于 Rust)|0.45 秒|
根据这些数字,我相当有信心通过这个小实验,只使用 JavaScript 就可以接近 Rust 的性能。
结论
总体而言,eslint
项目前景光明。它是最成功的开源项目之一,并且已经找到了获取大量资金的秘诀。我们研究了一些可以使 eslint
更快的方法,还有很多领域没有涉及到。
“eslint 的未来”讨论中包含了许多优秀的想法,可以使 eslint
更好,可能也更快。我认为棘手的部分在于避免试图一次性解决所有问题,因为在我的经验中,这通常注定会失败。重写同样如此。相反,我认为当前的代码库是一个完全不错的起点,可以被塑造成更出色的东西。
从外部人的角度看,有一些关键的决策需要做。比如,目前是否有意义继续支持基于字符串的选择器?如果是的话,eslint
团队是否有能力承担 esquery 的维护工作并给予它一些急需的关注?对于原生 TypeScript
支持又如何,鉴于 npm 下载计数显示 73% 的eslint
用户是 TypeScript
用户?
无论发生什么,我对他们的团队和执行他们愿景的能力有着坚定的信心。我为 eslint 的未来感到兴奋!
原文:https://marvinh.dev/blog/speeding-up-javascript-ecosystem-part-3/