(飘飘翻译分享)加快JavaScript生态系统的速度系列最后一篇

  前言

  加快 JavaScript 生态系统的速度系列最后一篇。今日前端早读课文章由 @飘飘翻译分享。

  正文从这开始~~

  tl;dr: Linting 是在代码中查找可能导致错误或确保一致的阅读体验的模式行为。它是许多 JavaScript/TypeScript 项目的核心部分。我们发现在他们的选择器引擎和 AST 转换过程中有很多节省时间的潜力,并用 JS 编写的完美的 linter 将能够达到亚秒级的运行时间。

  在这个系列的过去两篇文章中,我们已经谈了很多关于 linting 的内容,所以我想现在是时候给 eslint 以应有的关注了。总的来说,eslint 是如此的灵活,你甚至可以把解析器换成一个完全不同的。这与 JSX 和 TypeScript 的兴起一样常见。借助健康的插件和预设的生态系统,每个用例可能都有一个规则,如果没有,优秀的文档会指导你如何创建自己的规则。这是我想在这里强调的一件事,因为它是一个经得起时间考验的项目。

  但是,这也给性能分析带来了问题,因为由于配置的灵活性很广,当涉及到 linting 性能时,两个项目会有截然不同的体验。不过我们需要从某处开始,所以我想有什么比看看 eslint 仓库本身使用的 inting 设置更好的方式来开始我们的调查呢?

  使用 eslint 对 eslint 进行 lint

  他们的仓库使用了一个任务运行程序抽象来协调常见的构建任务,但只要稍加挖掘,我们就可以拼凑出为 "lint" 任务运行的命令,特别是对 JavaScript 文件进行检查。

  <pre tabindex="0" style="box-sizing: border-box;font-variant-numeric: normal;font-variant-east-asian: normal;font-stretch: normal;font-size: 13.6px;line-height: 1.45;font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;margin-bottom: 16px;padding: 10px;overflow: auto;background: rgb(40, 44, 52);border-radius: 3px;overflow-wrap: normal;text-align: start;"> node bin/eslint.js --report-unused-disable-directives . --ignore-pattern "docs/**"</pre>

  很好,在这里你可以看到。Eslint 正在使用 eslint 来对他们的代码库进行检查。像本系列的前两篇文章一样,我们将通过 node 内置的-cpu-prof参数生成一个*.cpuprofile,我们将把它加载到 Speedscope 中进行进一步分析。几秒钟后(确切的说是 22 秒),我们就可以开始行动了

  js替换指定位置的字符_js替换所有字符_js替换字符串中的字符

  通过将类似的调用堆栈合并在一起,我们可以更清楚地了解时间花在哪里。这通常被称为 "左重" 的可视化。这不要与你的标准火焰图混淆,后者的 X 轴代表一个调用发生的时间。相反,在这种风格中,X 轴代表的是总时间的消耗,而不是它发生的时间。对我来说,这是 Speedscope 的主要好处之一,而且感觉也更快了。因为它是由 Figma 的几个开发人员编写的,他们在我们的行业中以其卓越的工程设计而闻名,所以我不会期望它有任何不足。

  我们可以立即发现 eslint 资源库中的 linting 设置在一些关键区域花费了时间。最突出的是,总时间的很大一部分花在了处理 JSDoc 的规则上,正如从它们的函数名称推断的那样。另一个有趣的方面是,在 lint 任务的不同时间有两个不同的解析器在运行:esquery 和 acorn。但是 JSDoc 规则花了这么长时间激起了我的好奇心。

  js替换所有字符_js替换指定位置的字符_js替换字符串中的字符

  一个特别的BackwardTokenCommentCursor条目似乎很有意义,因为它是最大的一个块。在源的附加文件位置之后,它似乎是一个保持我们在文件中的位置状态的类。作为第一项措施,我添加了一个普通的计数器,每当该类被实例化并再次运行 lint 任务时该计数器就会递增。

  关于迷路 2000 万次

  总而言之,这个类已经被构造了超过 2000 万次。这似乎相当多了。记住,我们实例化的任何对象或类都会占用内存,而这些内存后来需要被清理。我们可以从数据中看到这个结果,垃圾收集(清理内存的行为)总共需要 2.43 秒。这可不是什么好事。

  在创建该类的一个新实例时,它调用了两个函数,这两个函数似乎都是为了启动搜索。在不知道更多关于它在做什么的情况下,第一个函数可以被排除在外,因为它不包含任何形式的循环。根据经验,循环通常是调查性能的主要怀疑对象,所以我通常从那里开始搜索。

  第二个函数,叫做utils.search(),包含一个循环。它循环遍历从我们当时正在检查的文件内容中解析出来的 token 流。token 是编程语言中最小的构建块,你可以把它们看作是语言的 "单词"。例如,在 JavaScript 中,"function"这个词通常表示为一个函数标记,而逗号或单个分号也是如此。在这个utils.search()函数中js替换字符串中的字符,我们似乎关注的是找到离我们在文件中的当前位置最近的标记。

  <pre tabindex="0" style="box-sizing: border-box;font-variant-numeric: normal;font-variant-east-asian: normal;font-stretch: normal;font-size: 13.6px;line-height: 1.45;font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;margin-bottom: 16px;padding: 10px;overflow: auto;background: rgb(40, 44, 52);border-radius: 3px;overflow-wrap: normal;text-align: start;"> exports.search = function search(tokens, location) { const index = tokens.findIndex(el => location Identifier.id</pre>

  这个例子让我觉得我们有点偏离了轨道。我不想成为那个在它不能正确匹配时去调试它的人。这是我对任何形式的自定义领域特定语言的主要抱怨。它们通常没有任何工具支持。如果我们留在 JavaScript 领域,我们可以在任何时候用适当的调试器来检查这个值。虽然前面的字符串选择器的例子有点极端,但大多数选择器都是这样的。

  <pre tabindex="0" style="box-sizing: border-box;font-variant-numeric: normal;font-variant-east-asian: normal;font-stretch: normal;font-size: 13.6px;line-height: 1.45;font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;margin-bottom: 16px;padding: 10px;overflow: auto;background: rgb(40, 44, 52);border-radius: 3px;overflow-wrap: normal;text-align: start;"> BinaryExpression</pre>

  or:

  <pre tabindex="0" style="box-sizing: border-box;font-variant-numeric: normal;font-variant-east-asian: normal;font-stretch: normal;font-size: 13.6px;line-height: 1.45;font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;margin-bottom: 16px;padding: 10px;overflow: auto;background: rgb(40, 44, 52);border-radius: 3px;overflow-wrap: normal;text-align: start;"> VariableDeclaration</pre>

  就这样了。大多数选择器只是想知道当前 AST 节点是否属于某种类型。仅此而已。为此,我们并不真的需要整个选择器引擎。如果我们为此引入一个快速路径,并完全绕过选择器引擎,会怎么样?

  <pre tabindex="0" style="box-sizing: border-box;font-variant-numeric: normal;font-variant-east-asian: normal;font-stretch: normal;font-size: 13.6px;line-height: 1.45;font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;margin-bottom: 16px;padding: 10px;overflow: auto;background: rgb(40, 44, 52);border-radius: 3px;overflow-wrap: normal;text-align: start;"> class NodeEventGenerator { // ... isType = new Set([ "IfStatement", "BinaryExpression", // ...etc ]); applySelector(node, selector) { // Fast path, just assert on type if (this.isType.has(selector.rawSelector)) { if (node.type === selector.rawSelector) { this.emitter.emit(selector.rawSelector, node); } return; } // Fallback to full selector engine matching if ( esquery.matches( node, selector.parsedSelector, this.currentAncestry, this.esqueryOptions ) ) { this.emitter.emit(selector.rawSelector, node); } } }</pre>

  既然我们已经在选择器引擎上做了手脚,我开始好奇一个字符串化的选择器和一个写成普通 JavaScript 函数的选择器相比会有什么不同。我的直觉告诉我,用简单的 JavaScript 条件的选择器会更容易被引擎优化。

  重新思考选择器

  如果你需要跨越语言障碍传递遍历命令,比如我们在浏览器中使用 CSS,那么选择器引擎就非常有用。但它从来都不是免费的,因为选择器引擎总是需要解析选择器来解构我们应该做什么,然后动态构建一些逻辑来执行这些解析的东西。

  但在 eslint 内部,我们没有跨越任何语言障碍。我们呆在 JavaScript 领域。因此,通过将查询指令转换为选择器并将其解析为我们可以再次运行的东西,我们不会获得任何性能上的提升。相反,我们在解析和执行选择器的过程中消耗了大约 25% 的 linting 时间。我们需要一种新的方法。

  然后我想到了。

  一个选择器在概念上只不过是一个 "描述",用于根据它所持有的标准来获得一个查找元素。这可能是一个树状的查找,也可能是一个像数组一样的平面数据结构。如果你仔细想想,甚至标准Array.prototype.filter()调用中的回调函数也是一个选择器。我们正在从一个项的集合(=Array)中选择值,并且只选择我们关心的值。我们用 esquery 所做的是完全相同的事情。在一堆对象(=AST 节点)中,我们要挑选出符合特定条件的对象。这就是一个选择器!那么,如果我们避免选择器的解析逻辑,而使用一个普通的 JavaScript 函数呢?

  <pre tabindex="0" style="box-sizing: border-box;font-variant-numeric: normal;font-variant-east-asian: normal;font-stretch: normal;font-size: 13.6px;line-height: 1.45;font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;margin-bottom: 16px;padding: 10px;overflow: auto;background: rgb(40, 44, 52);border-radius: 3px;overflow-wrap: normal;text-align: start;"> // String based esquery selector const esquerySelector = type="CallExpression"callee.computed!=true:matches([callee.property.name="substr"], [callee.property.name="substring"]); // The same selector as a plain JS function function jsSelector(node) { return ( node.type === "CallExpression" && node.callee.type === "MemberExpression" && !node.callee.computed && node.callee.property.type === "Identifier" && (node.callee.property.name === "substr" || node.callee.property.name === "substring") ); }</pre>

  让我们试一试吧!我写了几个基准来测量这两种方法的时间差。过了一会儿,数据在我的屏幕上弹了出来。

  js替换指定位置的字符_js替换字符串中的字符_js替换所有字符

  看起来纯 JavaScript 函数的变体很容易胜过基于字符串的变体。它是巨大的优势。即使花了这么多时间让esquery更快,它也远远比不上 JavaScript 的变体。在选择器不匹配的情况下,引擎可以提前跳出,它仍然比普通函数慢 30 倍。这个小实验证实了我的假设,我们为选择器引擎付出了相当多的时间。

  第三方插件和预设的影响

  虽然从 eslint 的设置中可以看到有更多的优化空间,但我开始怀疑我是否在花时间优化正确的东西。到目前为止,我们在 eslint 自己的 linting 设置中看到的问题是否也发生在其他 linting 设置中?eslint 的关键优势之一一直是它的灵活性和对第三方提示规则的支持。回顾过去,几乎每一个我工作过的项目都有几个自定义的提示规则和大约 2-5 个额外的 eslint 插件或预置安装。但更重要的是,他们完全换掉了解析器。快速浏览一下 npm 的下载统计,就会发现替换 eslint 内置解析器的趋势。

  js替换指定位置的字符_js替换字符串中的字符_js替换所有字符

  如果这些数字是可信的,那就意味着只有 8% 的 eslint 用户使用内置解析器。这也表明 TypeScript 已经变得非常普遍,在 eslint 的总用户群中占了 73% 的份额。我们没有关于 babel 解析器的用户是否也使用 TypeScript 的数据。我的猜测是,他们中的一部分会这样做,TypeScript 用户的总数实际上甚至更高。

  在对各种开源资源库中的一些不同设置进行分析后,我决定采用 vite 的设置,它包含了其他配置文件中的大量模式。它的代码库是用 TypeScript 编写的,eslint 的解析器也相应地被替换了。

  js替换所有字符_js替换字符串中的字符_js替换指定位置的字符

  像以前一样,我们可以在配置文件中找出各种区域,显示时间花在哪里。有一个区域暗示了从 TypeScript 的格式转换到 eslint 所理解的格式需要相当多的时间。配置加载也发生了一些奇怪的事情,因为它不应该像这里一样占用那么多时间。我们发现了一个老朋友,eslint-import-plugin和eslint-plugin-node,它们似乎启动了一系列的模块解析逻辑。

  但有趣的是,选择器引擎的开销并没有显示出来。有一些applySelector函数被调用的例子,但它几乎没有消耗任何时间。

  似乎总是弹出并花费相当长时间执行的两个第三方插件分别是eslint-plugin-import和eslint-plugin-node。每当这些插件中的一个或两个处于活动状态时,就会在分析数据中显示出来。这两个插件都会导致大量的文件系统流量,因为它们试图解析大量模块,但并不对结果进行缓存。我们在本系列的第二部分写了很多关于这个问题的内容,所以我不会在这方面做更多的细节。

  转换所有的 AST 节点

  我们将从最开始的 TypeScript 转换开始。我们的工具将我们提供给它们的代码解析成一种数据结构,这种结构被称为抽象语法树,简称:AST。你可以把它看成是我们所有工具工作的基础模块。它告诉你这样的信息:"嘿,我们在这里声明一个变量,它有这个名字和那个值",或者 "这里有一个带有这个条件的 if 语句,它守护着这个代码块",等等。

  <pre tabindex="0" style="box-sizing: border-box;font-variant-numeric: normal;font-variant-east-asian: normal;font-stretch: normal;font-size: 13.6px;line-height: 1.45;font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;margin-bottom: 16px;padding: 10px;overflow: auto;background: rgb(40, 44, 52);border-radius: 3px;overflow-wrap: normal;text-align: start;"> // const foo = 42 in AST form is something like: { type: "VariableDeclaration", kind: "const", declarations: [ { kind: "VariableDeclarator", name: { type: "Identifier", name: "foo", }, init: { type: "NumericLiteral", value: 42 } ] }</pre>

  你可以在优秀的 AST Explorer 页面上看到我们的工具是如何解析代码的。我强烈建议你访问该网页,并玩一玩各种代码片段。它可以让你很好地了解我们的工具的 AST 格式是多么的相似或经常不同。

  然而,在 eslint 的案例中,这有一个问题。我们希望无论我们选择什么样的解析器,规则都能发挥作用。当我们激活no-console规则时,我们希望它能跨所有规则工作,而不是强制每个规则为每个解析器重写。从本质上讲,我们需要的是一个大家都能认同的共享 AST 格式。而这正是 eslint 所做的。它希望每个 AST 节点都能与 eslint 规范相匹配,该规范规定了每个 AST 节点应该是什么样子。这是一个已经存在了相当长一段时间的规范,许多 JavaScript 工具都是从它开始的。即使是 babel 也是建立在这个基础上的,但从那时起就有一些记录的偏差。

  但这就是使用 TypeScript 时问题的症结所在。TypeScript 的 AST 格式是非常不同的,因为它也需要考虑到代表类型本身的结点。一些构造在内部也有不同的表示方式,因为它使 TypeScript 本身变得更容易。这意味着每一个 TypeScript 的 AST 节点都必须转换为 eslint 理解的格式。这种转换需要时间。在这个配置文件中,这占到了总时间的 22%。之所以需要这么长的时间,不只是单纯的遍历,还因为每次转换我们都要分配新的对象。我们基本上在内存中有两个不同 AST 格式的副本。

  也许 babel 的解析器更快?如果我们把@typescript-eslint/parser换成@babel/eslint-parser呢?

  js替换指定位置的字符_js替换字符串中的字符_js替换所有字符

  事实证明,仅仅这样做就为我们节省了不少的时间。有趣的是,这个更改还大大减少了配置加载时间。对配置加载时间的改善可能是由于 babel 的解析器被分散在更少的文件中。

  js替换所有字符_js替换指定位置的字符_js替换字符串中的字符

  请注意,虽然 babel 解析器(在写这篇文章的时候)明显更快,但它不支持类型感知的 linting。那是@typescript-eslint/parser独有的功能。这为像他们的no-for-in-array规则提供了可能性,该规则可以检测你在for-in循环中迭代的变量是否真的是一个 object 而不是一个 array。所以你可能想继续使用@typescript-eslint/parser。如果你确信你不使用他们的任何规则,而你只是想让 eslint 能够理解 TypeScript 的语法,再加上更快的 lint,那么切换到 babel 的解析器是一个不错的选择。

  额外奖励:一个理想的 linter 是什么样子的?

  在这一点上,我偶然发现了一个关于 eslint 未来的讨论,其中性能是最优先的事项之一。其中提出了一些很好的想法,特别是关于引入会话的概念,以实现完整的程序提示,而不是像今天这样以每个文件为单位。考虑到至少有 73% 的 eslint 用户使用它来检查 TypeScript 代码,更紧密的整合需要更少的 AST 转换,这对性能来说也是巨大的。

  也有一些关于锈蚀移植的讨论,这激起了我的好奇心,目前基于锈蚀的 JavaScript 引导器的速度如何。唯一一个似乎已经准备好并能够解析大部分 TypeScript 语法的产品是 rslint。

  除了 rslint 之外,我也开始想知道一个纯 JavaScript 的简单 linter 会是什么样子。一个没有选择器引擎,不需要持续的 AST 转换,只需要解析代码并检查各种规则。所以我用一个非常简单的 API 包装了 babel 的解析器,并添加了自定义的遍历逻辑来行走 AST 树。我没有选择 babel 自己的遍历函数,因为它们每次迭代都会导致大量的分配,而且是建立在生成器上的,比不使用生成器要慢一些。我还用我自己多年来写的一些定制的 JavaScript/TypeScript 解析器进行了尝试,这些解析器源于几年前将 esbuild 的解析器移植到 JavaScript。

  说了这么多,下面是在 vite 资源库(144 个文件)上运行它们时的数据。

  js替换字符串中的字符_js替换所有字符_js替换指定位置的字符

  基于这些数字,我相当有信心,在这个小实验的基础上,我们只用 JavaScript 就可以获得非常接近 rust 的性能。

  结论

  总的来说,eslint 项目有一个非常光明的未来。它是最成功的开放源码软件项目之一,它已经找到了获得大量资金的秘密。我们看了一些可以使 eslint 更快的东西,还有很多这里没有涉及的领域需要研究。

  "eslint 的未来" 的讨论包含了很多伟大的想法,这些想法将使 eslint 变得更好,而且可能更快。我认为最棘手的是避免一次解决所有的问题,因为根据我的经验,这往往注定要失败。重写也是如此。相反,我认为目前的代码库是一个很好的起点,准备将其塑造成更棒的东西。

  从一个局外人的角度来看,有一些关键的决定要做。比如,在这一点上,继续支持基于字符串的选择器是否有意义?如果是的话,eslint 团队是否有能力承担 esquery 的维护工作,并给它一些急需的爱?鉴于 npm 的下载量表明 73% 的 eslint 用户是 TypeScript 用户,那么原生 TypeScript 的支持情况如何?

  不管会发生什么,我对他们的团队和执行他们的愿景的能力有强烈的信心。我为 eslint 的未来感到兴奋!

  关于本文

  译者:@飘飘

文章由官网发布,如若转载,请注明出处:https://www.veimoz.com/1506
0 评论
364

发表评论

!