JS生态系统加速eslint解析器使用实例探索

 更新时间:2024年01月21日 11:46:52   作者:大家的林语冰 人猫神话  
这篇文章主要为大家介绍了JS生态系统加速之eslint解析器使用实例探索,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪

引言

长话短说:Linting 是在代码中查找模式的行为,这可能导致错误或确保一致的阅读体验。它是一大坨 JS/TS 项目的核心部分。我们发现其选择器引擎和 AST 转换过程存在时间优化的巨大潜力,并且诉诸 JS 编写的完美 linter 能够达到亚秒级的运行时间。

本期《前端翻译计划》共享的是“加速 JS 生态系统系列博客”,包括但不限于:

  • PostCSS,SVGO 等等
  • 模块解析
  • 使用 eslint
  • npm 脚本
  • draft-js emoji 插件
  • polyfill 暴走
  • 桶装文件崩溃
  • Tailwind CSS

本期共享的是第 2 篇博客 —— eslint。

在本系列的前两篇文章中,我们已经讨论了一大坨关于 linting 的问题,所以是时候让 eslint 崭露头角了。总体而言,eslint 非常灵活,您甚至可以将解析器更换为截然不同的解析器。随着 JSX 和 TS 的兴起,这种情况屡见不鲜。凭借健康的插件和预设生态系统的丰富,每个用例可能都有一个规则,如果没有,优秀的文档会指引您创建自己的规则。

但这也给性能分析带来了一个问题,因为由于强大的配置灵活性,两个项目在 linting 性能方面可能会有截然不同的体验。

使用 eslint

eslint 的代码库使用任务运行程序抽象来协调常见的构建任务,但通过抽丝剥茧,我们可以拼凑出用于“lint”任务运行的命令,尤其是 JS 文件的 lint。

node bin/eslint.js --report-unused-disable-directives . --ignore-pattern "docs/**"

如你所见:ESLint 正在使用 ESLint 检查自己的代码库!我们将通过 Node 的内置 --cpu-prof 参数生成 *.cpuprofile,并将其加载到 Speedscope 中分析。

通过将类似的调用堆栈合并在一起,我们可以更清楚地了解时间开销的“重灾区”。这通常被称为“left-heavy”可视化。不要将其与标准火焰图混淆,火焰图的 x 轴表示调用发生的时间。相反,这里的 x 轴表示总时间中消耗的时间,而不是发生的时间。

我们立即可以找出 eslint 代码库中的 linting 设置花费时间的若干关键区域。值得注意的是,总时间的很大一部分花在处理 JSDoc 的规则上(从函数名称推断)。另一个有趣的方面是,在 lint 任务期间有两个不同的解析器在不同时间运行:esquery 和 acorn。但 JSDoc 规则花了这么长时间,激起了我的好奇心。

一个特别的 BackwardTokenCommentCursor 入口似乎很有趣,因为它是该组块中最大的区块。根据附加的文件定位到源码,它似乎是一个保存我们在文件中位置状态的类。作为第一个措施,我添加了一个普通计数器,每当实例化该类并再次运行 lint 任务时,该计数器就会递增。

2000 万次

总而言之,该类已被构造超过 2000 万次。这看起来太多了。粉丝请记住,我们实例化的任何对象或类都会占用内存,并且稍后需要清理该内存。我们可以在数据中看到垃圾收集(清理内存的行为)总共花费 2.43 秒的结果。这并不好。

创建该类的新实例后,它会调用两个函数,这两个函数似乎都会启动搜索。如果不了函数的细节,那么可以排除第一个函数,因为它不包含任何形式的循环。根据经验,循环通常是研究性能的主要嫌疑人。

第二个函数称为 utils.search(),它包含了一个循环。它循环遍历从我们当时检查的文件内容中解析出的 token 流。token 是编程语言的最小构建块,您可以将它们视为语言的“单词”。举个栗子,在 JS 中,“函数”一词通常表示为一个函数 token,逗号或单个分号也是举一反一。在 utils.search() 函数中,我们似乎关心的是找到距离文件中当前位置最近的 token。

exports.search = function search(tokens, location) {
  const index = tokens.findIndex(el => location <= getStartLocation(el))
  return index === -1 ? tokens.length : index
}

为此,搜索是通过 JS 的原生 .findIndex() 方法在 token 数组上完成的。该算法的说明是:

findIndex() 是一种迭代方法。它按升序索引顺序为数组中的每个元素调用一次提供的 callbackFn 函数,直到 callbackFn 返回真值。

鉴于 token 数组随着文件中代码量的增加而增长,这听起来并不理想。我们可以使用更有效的算法来搜索数组中的值,而不是遍历数组中的每个元素。举个栗子,用二分搜索替换那行代码可以将时间减少 50%。

虽然减少 50% 看似不错,但它仍然没有解决此代码被调用 2000 万次的问题。对我而言,这才是问题所在。我们或多或少地试图减少症状的影响,但是治标不治本。我们已经在遍历该文件,因此我们应该确切地知道我们在哪里。不过,改变这一点需要更具侵入性的重构,并且对于本文而言超纲了。看到这不是一个容易解决的问题,我检查了配置文件中还有哪些值得关注的内容。中心的长紫色条很难被忽视,不仅因为它们的颜色不同,而且因为它们占用了大量时间,并且没有深入到数百个较小的函数调用。

选择器引擎

speedscope 中的调用堆栈指向一个名为 esquery 的项目。这是一个较旧的项目,其目标是能够通过小型选择器语言在解析的代码中找到特定对象。如果您仔细观察,您会发现它与 CSS 选择器非常相似。它们的套路基本相同,只是我们没有在 DOM 树中找到特定的 HTML 元素,而是在另一个树结构中找到一个对象。

调试表明 npm 包附带了压缩的源码。混淆的变量名通常是单个字符,强烈暗示压缩的过程正在发生。对我而言幸运的是,该软件包还附带了一个未压缩的变体,因此我修改了 package.json 来指向它。稍后再运行一次,我们会得到以下数据:

好多了!对于未压缩的代码,需要记住的一点是,它的执行速度比压缩的变体慢大约 10-20%。这是一个粗略的近似范围,在比较压缩代码和未压缩代码的性能时,我在整个职业生涯中多次测量过此范围。有了这个经验,getPath 函数似乎需要一些帮助。

function getPath(obj, key) {
  var keys = key.split('.')
  var _iterator = _createForOfIteratorHelper(keys),
    _step
  try {
    for (_iterator.s(); !(_step = _iterator.n()).done; ) {
      var _key = _step.value
      if (obj == null) {
        return obj
      }
      obj = obj[_key]
    }
  } catch (err) {
    _iterator.e(err)
  } finally {
    _iterator.f()
  }
  return obj
}

过时的转译将困扰我们很长一段时间

如果您已经接触 JS 工具领域一段时间,那么这些函数看起来非常熟悉。_createForOfIteratorHelper 99.99% 是由它们的发布管道插入的函数,而不是由该库的作者插入的。当 for-of 循环被添加到 JS 中时,花了一段时间才普遍支持。

向下转译现代 JS 功能的工具往往会因谨慎而犯错,并以非常保守的方式重写代码。在本例中,我们将 string 拆分为字符串数组。使用完整的迭代器来循环它完全是把饭叫饥,并且一个无聊的循环标准足矣。但由于工具没有意识到这一点,因此它们选择了覆盖尽可能多场景的变体。以下是用于比较的原始代码:

function getPath(obj, key) {
  const keys = key.split('.')
  for (const key of keys) {
    if (obj == null) {
      return obj
    }
    obj = obj[key]
  }
  return obj
}

今时今日,for-of 循环普遍支持,因此我再次修补了该包,并将函数实现替换为源码中的原始函数实现。这一简单更改可节省大约 400 毫秒。我总是对我们在浪费的 polyfill 或过时的向下转译上消耗了多少 CPU 时间印象深刻。我还测量了用标准 for 循环替换 for-of 循环。

function getPath(obj, key) {
  const keys = key.split('.')
  for (let i = 0; i < keys.length; i++) {
    const key = keys[i]
    if (obj == null) {
      return obj
    }
    obj = obj[key]
  }
  return obj
}

令人惊讶的是,与 for-of 变体相比,这又提高了 200 毫秒。我想即使在今天,for-of 循环也更难针对引擎进行优化。这让我想起了过去的一项调查,Jovi 和我在发布新版本并切换到 for-of 循环时,对 graphql 包的解析速度突然变慢进行调查。

这是 v8/gecko/webkit 工程师可以正确验证的东东,但我的假设是它仍然必须调用迭代器协议,因为这可能已被全局覆盖,这将改变每个数组的行为。大抵就是如此吧。

虽然我们从这些变化中快速斩获了一些成果,但仍远未达到理想状态。总体而言,该功能仍然是有待优化的首要竞争者,因为它单独负责了总时间的几秒钟。再次应用快速计数器的奇技淫巧,发现它被调用了大约 22k 次。可以肯定的是,这个函数在某种程度上处于“hot”路径中。

特别值得注意的是,一大坨处理字符串的性能密集型代码都围绕 String.prototype.split() 方法。这将有效地迭代所有字符,分配一个新数组,然后迭代该数组,所有这些都可以在一次迭代中完成。

function getPath(obj, key) {
  let last = 0
  // 有效,因为所有的键都是 ASCII,而不是 unicode
  for (let i = 0; i < key.length; i++) {
    if (obj == null) {
      return obj
    }
    if (key[i] === '.') {
      obj = obj[key.slice(last, i)]
      last = i + 1
    } else if (i === key.length - 1) {
      obj = obj[key.slice(last)]
    }
  }
  return obj
}

这次重写对其性能影响巨大。当我们开始时,getPath 总共花费了 2.7 秒,应用了所有优化后,我们设法将其降低到 486 毫秒。

继续使用 matches() 函数,我们看到奇怪的 for-of 向下转译产生了大量开销。为了节省时间,我直接在 github 上复制了源码中的函数。由于 matches() 在调试中更加突兀,因此仅此更改就节省了整整 1 秒。

我们生态系统中的一大坨库都面临此问题。我真的希望有一种银弹可以一键更新它们。也许我们需要一个反向转译器来检测向下转译模式,并将其再次转换回现代代码。

我联系了 jviide,看看是否可以进一步优化 matches()。通过其额外更改,我们能够使整个选择器代码比原始未修改状态快大约 5 倍。它基本上所做的就是消除 matches() 函数中的大量开销,这也使它能够简化一些相关的辅助函数。举个栗子,它注意到模板字符串的转译效果很差。

// 输入
const literal = `${selector.value.value}`

// 输出,向下转译很慢
const literal = ''.concat(selector.value.value)

它甚至更进一步,将每个新选择器解析为动态函数调用链,并缓存生成的包装函数。这个技巧再次大幅加速了选择器引擎。

提早纾困

有时退后一步,从不同的角度解决问题是件好事。到目前为止,我们已经了解了实现细节,但我们实际上正在处理什么样的选择器?是否有可能使其中一些短路?为了测试这个理论,我首先需要更好地了解正在处理的选择器类型。毫不奇怪,大多数选择器都很短。其中有几个确实很有特色。举个栗子,这是一个简单选择器:

VariableDeclaration:not(ExportNamedDeclaration > .declaration) > VariableDeclarator.declarations:matches(
  [init.type="ArrayExpression"],
  :matches(
 [init.type="CallExpression"],
[init.type="NewExpression"]
  )[init.optional!=true][init.callee.type="Identifier"][init.callee.name="Array"],
[init.type="CallExpression"][init.optional!=true][init.callee.type="MemberExpression"][init.callee.computed!=true][init.callee.property.type="Identifier"][init.callee.optional!=true]
 :matches(
   [init.callee.property.name="from"],
   [init.callee.property.name="of"]
)[init.callee.object.type="Identifier"][init.callee.object.name="Array"],
[init.type="CallExpression"][init.optional!=true][init.callee.type="MemberExpression"][init.callee.computed!=true][init.callee.property.type="Identifier"][init.callee.optional!=true]:matches(
   [init.callee.property.name="concat"],
   [init.callee.property.name="copyWithin"],
   [init.callee.property.name="fill"],
   [init.callee.property.name="filter"],
   [init.callee.property.name="flat"],
   [init.callee.property.name="flatMap"],
   [init.callee.property.name="map"],
   [init.callee.property.name="reverse"],
   [init.callee.property.name="slice"],
   [init.callee.property.name="sort"],
   [init.callee.property.name="splice"]
 )
  ) > Identifier.id

这无疑是一个有点偏离轨道的例子。我不想成为那个在不正确匹配时必须进行调试的倒霉蛋。这是我对任何形式的自定义领域特定语言的主要抱怨。它们通常根本不提供工具支持。如果我们留在 JS 领域,我们可以使用适当的调试器随时随地检查该值。虽然前面的字符串选择器示例有点极端,但大多数选择器如下所示:

BinaryExpression

/* 或者 */
VariableDeclaration

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

class NodeEventGenerator {
  // ...
  isType = new Set([
    'IfStatement',
    'BinaryExpression'
    // 其他......
  ])
  applySelector(node, selector) {
    // 捷径,只需断言类型
    if (this.isType.has(selector.rawSelector)) {
      if (node.type === selector.rawSelector) {
        this.emitter.emit(selector.rawSelector, node)
      }
      return
    }
    // 回退到完整的选择器引擎匹配
    if (
      esquery.matches(
        node,
        selector.parsedSelector,
        this.currentAncestry,
        this.esqueryOptions
      )
    ) {
      this.emitter.emit(selector.rawSelector, node)
    }
  }
}

由于我们已经短路了选择器引擎,我开始好奇字符串化选择器与以纯 JS 函数编写的选择器相比如何。我的直觉告诉我,将选择器编写为简单的 JS 条件会更容易针对引擎优化。

反思选择器

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

但在 eslint 内部我们没有跨越任何语言障碍。我们还停留在 JS 领域。因此,通过将查询指令转换为选择器,并将它们解析回我们可以再次运行的内容,我们不会获得任何性能方面的好处。相反,我们消耗了大约 25% 的总 linting 时间来解析和执行选择器。我们需要一种新方案。

从概念上讲,选择器只不过是一个“描述”,用于根据它所持有的条件来查找元素。这可能是在树或平面数据结构(比如 array)中的查找。如果您考虑一下,即使标准 Array.prototype.filter() 调用中的回调函数也是一个选择器。我们从元素集合(数组)中选择值,并仅选择我们关心的值。我们对 esquery 所做的事情完全相同。从一堆对象(AST 节点)中,我们挑选出符合特定条件的对象。那就是一个选择器!那么,如果我们避免选择器解析逻辑,并使用纯 JS 函数呢?

// String 筑基的 esquery 选择器
const esquerySelector = `[type="CallExpression"][callee.type="MemberExpression"][callee.computed!=true][callee.property.type="Identifier"]:matches([callee.property.name="substr"], [callee.property.name="substring"])`
// 纯 JS 函数的同款选择器
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')
  )
}

让我们尝试一下吧!我编写了一些基准来衡量这两种方案的时间差异。

whatfoo.substr(1, 2) ops/sec
esquery422,848.208
esquery(优化)3,036,384.255
纯 JS 函数66,961,066.5239

看起来纯 JS 函数变体对基于字符串的函数变体“降维打击”。这简直棒棒哒。即使在花费了所有时间来使 esquery 更快之后,它仍远不及 JS 变体。在选择器不匹配,且引擎可以提前退出的情况下,它仍然比普通函数慢 30 倍。这个小实验证实了我的假设,即我们为选择器引擎付出了相当多的时间。

第三方插件和预设的影响

尽管在 eslint 设置的配置文件中可以看到更多的优化空间,但我开始怀疑我是否花时间优化了正确的事情。到目前为止,我们在 eslint 自己的 linting 设置中看到的相同问题是否也出现在其他 linting 设置中?eslint 的主要优势之一始终是其灵活性和对第三方 linting 规则的支持。回顾过去,我从事的几乎每个项目都安装了一些自定义 linting 规则和大约 2-5 个额外的 eslint 插件或预设。但更重要的是,它们完全关闭了解析器。快速浏览一下 npm 下载统计数据,就可以发现替换 eslint 内置解析器的趋势:

软件包npm 周下载量%
eslint31.718.905100%
@typescript-eslint/parser23.192.76773%
@babel/eslint-parser6.057.11019%

如果这些数字可信,那就意味着,所有 eslint 用户中只有 8% 使用内置解析器。它还显示了 TS 已经变得多么普遍,占 eslint 总用户群的最大份额(73%)。我们没有关于 babel 解析器的用户是否也将其用于 TS 的数据。我的猜测是,它们中的一部分人这样做了,而且 TS 用户的总数实际上甚至更高。

在分析各种开源存储库中的若干不同设置后,我选择了 Vite 中的一个,它也包含其他配置文件中存在的大量模式。它的代码库是用 TS 编写的,并且 eslint 的解析器已相应替换。

和之前一样,我们可以找出各个区域,显示时间花在哪里。有一个区域暗示了,从 TS 格式到 eslint 格式的转换需要相当多的时间。配置加载也发生了一些怪事,因为它永远不会像这里那样占用那么多时间。我们找到了一个老朋友,eslint-import-plugin 和 eslint-plugin-node,它们似乎启动了一堆模块解析逻辑。

不过,这里有趣的一点是,选择器引擎的开销并未显示。有一些 applySelector 函数被调用的实例,但从整体上看它几乎不消耗任何时间。

eslint-plugin-import 和 eslint-plugin-node 似乎总是弹出并需要相当长的时间才能执行的两个第三方插件。每当这些插件之一或两个处于活动状态时,它就会真正显示在分析数据中。两者都会导致大量的文件系统流量,因为它们尝试解析一堆模块,但不缓存结果。

转换所有 AST 节点

我们将从起初发生的 TS 转换开始。我们的工具将提供给它们的代码解析为一种称为 AST(抽象语法树)的数据结构。您可以将其视为我们所有工具所使用的构建块。它告诉的信息如下:“瞧,这里我们声明一个变量,它有这个名称和那个值”,或者“这里有一个带有这个条件的 if 语句,它保护那个代码块”,等等。

// `const foo = 42` 的 AST 形式如下所示:
{
  type: "VariableDeclaration",
  kind: "const",
  declarations: [
    {
      kind: "VariableDeclarator",
      name: {
        type: "Identifier",
        name: "foo",
      },
      init: {
        type: "NumericLiteral",
        value: 42
      }
  ]
}

您可以在优秀的 AST Explorer 网站上亲眼看到我们的工具如何解析代码。它可以让您更好地了解我们工具的 AST 格式的异同点。

虽然但是,在 eslint 的情况下存在问题。无论我们选择什么解析器,我们都希望规则能够正常工作。当我们激活 no-console 规则时,我们希望它适用于所有规则,而不是强制为每个解析器重写每个规则。本质上,我们需要的是一个我们都同意的共享 AST 格式。这正是 eslint 所做的。它期望每个 AST 节点都匹配 estree 规范,该规范规定了每个 AST 节点的外观。这个规范存在已久,一大坨 JS 工具都始于该规范。甚至 babel 也构建于其上,但此后有若干记录在案的偏差。

但当您使用 TS 时,问题的症结就在这里。TS 的 AST 格式非常不同,因为它还需要考虑代表类型本身的节点。一些构造在内部也有不同的表示,因为它使 TS 本身变得更容易。这意味着,每个 TS AST 节点都必须转换为 eslint 格式。这种转换需要时间。在此配置文件中,约占总时间的 22%。花费这么长时间的原因不仅仅是遍历本身,而且每次转换我们都会分配新的对象。我们在内存中基本上有两个不同 AST 格式的副本。

也许 babel 的解析器更快?如果我们用 @babel/eslint-parser 替换 @typescript-eslint/parser,那又如何?

what解析时间
@typescript-eslint/parser2.1s
@babel/eslint-parser + @babel/preset-typescript0.6s

事实证明,这样做可以节省相当多的时间。有趣的是,这一更改还大大缩短了配置加载时间。配置加载时间的改进可能是由于 babel 的解析器分布在更少的文件中。

粉丝请注意,虽然 babel 解析器明显更快,但它不支持类型感知 linting。这是 @typescript-eslint/parser 独有的功能。这为诸如 no-for-in-array 规则之类的规则提供了可能性,它可以检测您在 for-in 循环中迭代的变量实际上是否是 object 的 array。因此您可能想继续使用 @typescript-eslint/parser。如果您确信您不使用它们的任何规则,并且您只是需要 eslint 来理解 TS 的语法,再加上更快一点的 lint,那么切换到 babel 的解析器是一个不错的选择。

理想的 linter 是什么样子?

我偶然发现了关于 eslint 未来的讨论,其中性能是首要任务之一。其中提出了一些很棒的想法,特别是引入会话的概念,这允许完整的程序检查,而不是像今天那样在每个文件的基础上进行检查。鉴于至少 73% 的 eslint 用户使用它来检查 TS 代码,因此需要更少 AST 转换的更紧密集成也会对性能产生巨大影响。

还有一些关于 Rust 移植的讨论,这激起了我对当前基于 Rust 的 JS linter 的速度有多快的好奇。rslint 是唯一一个似乎已经做好生产准备,并能够解析大部分 TS 语法的工具。

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

话虽如此,以下是在 Vite 存储库上运行它们时的数字(144 个文件)。

what时间
eslint(JS)5.85s
自定义 linter(JS)0.52s
rslint(Rust 筑基)0.45s

基于这些数字,我相当有信心,基于这个小实验,我们只需使用 JS 就可以非常接近 Rust 的性能。

完美谢幕

总的来说,eslint 项目有着非常光明的前景。它是最成功的 OSS 项目之一,并且找到了获得大量资金的秘诀。我们研究了一些能让 eslint 更快的东西,还有一大坨这里没有涉及的领域需要研究。

“eslint 的未来”讨论包含了一大坨伟大的想法,这些想法将使 eslint 变得更好且可能更快。我认为头大的一点是,避免尝试立即解决所有问题,因为根据我的经验,这通常注定会失败。彻底重写也是如此。相反,我认为当前的代码库是一个完美的起点,可以被塑造成更棒的东西。

从局外人的角度来看,需要做出一些关键决定。举个栗子,此时继续支持基于字符串的选择器是否有意义?如果是,eslint 团队是否有能力承担 esquery 的维护工作,并给予它一些急需的关爱?鉴于 npm 下载计数表明 73% 的 eslint 用户是 TS 用户,那么原生 TS 支持又如何呢?

免责声明

本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考,英文原味版请传送 Speeding up the JavaScript ecosystem - eslint

以上就是JS生态系统加速eslint解析器使用实例探索的详细内容,更多关于JS eslint解析器的资料请关注脚本之家其它相关文章!

相关文章

最新评论