本文首发于微信公众号:大迁世界, 我的微信:qq449245884,我会第一时间和你分享前端行业趋势,学习途径等等。
更多开源作品请看 GitHub https://github.com/qq449245884/xiaozhi ,包含一线大厂面试完整考点、资料以及我的系列文章。

快来免费体验ChatGpt plus版本的,我们出的钱
体验地址:https://chat.waixingyun.cn
可以加入网站底部技术群,一起找bug.

本文讨论了如何通过优化选择器引擎和AST转换过程,以及完善JavaScript中的linter,从而加速JavaScript和TypeScript项目。作者提到,一个理想的用JS编写的linter可以在不到一秒钟的时间内运行完毕。

在本系列的前两篇文章中,我们已经讨论了很多关于代码风格检查的内容,所以我认为是时候给eslint一个应有的关注了。总的来说,eslint非常灵活,甚至可以将解析器完全替换成另一个不同的解析器。随着JSX和TypeScript的兴起,这种情况并不少见。得益于丰富的插件和预设生态系统,可能已经有了适用于每个使用场景的规则,如果还没有,优秀的文档会指导你如何创建自己的规则。

但这也给性能分析带来了问题,由于配置灵活性的广泛性,两个项目在进行代码检查时可能会有非常不同的体验。不过我们需要从某个地方开始,所以我想,何不从查看 eslint 存储库中使用的代码检查设置开始我们的调查呢!

使用 eslint 对 eslint 进行代码检查

代码库使用任务运行器抽象来协调常见的构建任务,但是通过一些挖掘,我们可以拼凑出针对 JavaScript 文件进行“lint”任务的命令

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

Eslint正在使用eslint来检查他们的代码库!就像本系列的前两篇文章一样,我们将通过node的内置 --cpu-prof 参数生成 *.cpuprofile ,然后将其加载到Speedscope中进行进一步分析。几秒钟后(确切地说是22秒),我们准备好深入研究了!

image.png

通过合并类似的调用堆栈,我们可以更清楚地了解时间花费在哪里。这通常被称为“左重(left-heavy)”可视化。这与标准的火焰图不同,其中x轴表示调用发生的时间。相反,在这种风格中,x轴表示总时间消耗的时间,而不是发生的时间。对我来说,这是 Speedscope 的主要优点之一,而且感觉更加迅速。这并不意外,因为它是由 Figma 的几个开发人员编写的,他们以在我们行业中的工程卓越而闻名。

image.png

一个特定的 BackwardTokenCommentCursor 条目似乎很有趣,因为它是一堆中最大的块。跟随附加的文件位置到源代码,它似乎是一个保存文件中我们所处位置状态的类。作为第一步,我添加了一个简单的计数器,每当该类被实例化时就会增加,并再次运行了lint任务。

超过2000万次后

总的来说,这个类已经被构建了超过2000万次。这似乎相当多。请记住,我们实例化的任何对象或类都会占用内存,这些内存稍后需要清理。我们可以在数据中看到这种后果,即垃圾回收(清理内存的行为)总共需要2.43秒。这不好。

在创建该类的新实例时,它调用了两个函数,这两个函数似乎都会启动搜索。不过,如果不了解它正在做什么,第一个函数可以被排除在外,因为它不包含任何形式的循环。从经验来看,循环通常是性能调查的主要嫌疑对象,因此我通常从那里开始搜索。

尽管第二个函数称为 utils.search() ,但它包含一个循环。它循环遍历从我们在此时进行代码检查的文件内容中解析出的标记流。标记是编程语言的最小构建块,可以将它们视为语言的“单词”。例如,在JavaScript中,function一词通常表示为一个函数标记,逗号或单个分号也是如此。在这个 utils.search() 函数中,我们似乎关心找到文件中最接近当前位置的标记。

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

为了做到这一点,通过JavaScript的本地 .findIndex() 方法在令牌数组上进行搜索。该算法的描述如下:

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

考虑到令牌数组随文件中代码量的增加而增加,这并不理想。我们可以使用更有效的算法来搜索数组中的值,而不是遍历数组中的每个元素。例如,将该行替换为二分搜索可以将时间减半。

虽然减少50%听起来不错,但仍然没有解决这个代码被调用2000万次的问题。对我来说,这才是真正的问题。我们更多地是试图减少这里的症状影响,而不是解决潜在的问题。我们已经在文件中进行了迭代,因此我们应该知道自己在哪里。不过,更改这一点需要进行更深入的重构,这对于本博客文章来说太多了。鉴于这不是一个容易的修复,我检查了一下在性能分析中还有哪些值得关注的地方。中心的长紫色条很难忽视,不仅因为它们是不同的颜色,而且因为它们占用了很多时间,并且没有深入到数百个较小的函数调用中。

选择器引擎

在 speedscope 中,调用堆栈指向一个名为 esquery 的项目,我在此之前从未听说过。这是一个旧项目,其目标是通过一种小型选择器语言在解析的代码中查找特定对象。如果你眯起眼睛看,你会发现它与 CSS 选择器有很强的相似之处。它们在这里的工作方式相同,只是我们不是在 DOM 树中查找特定的 HTML 元素,而是在另一个树结构中查找对象。这是相同的想法。

image.png

这些痕迹表明,npm包附带了压缩后的源代码。混淆的变量名通常只有一个字符,这强烈暗示了这样一个过程。幸运的是,这个包还附带了一个未压缩的版本,所以我只是修改了package.json,让它指向了那个版本。再次运行后,我们收到了以下数据:

image.png

未压缩的代码的性能比压缩的代码慢大约10-20%。

尽管如此,相对时间保持不变,因此它仍然非常适合我们的调查。因此, getPath 函数似乎需要一些帮助:

function getPath(obj, key) {
    var key  s = 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;
}

过时的转译将长期困扰我们

如果你对JavaScript工具领域有所了解,那么这些功能看起来会让人觉得非常熟悉。_createForOfIteratorHelper几乎可以肯定是由他们的发布流程插入的函数,而不是这个库的作者添加的。当for-of循环被添加到JavaScript时,它花费了一段时间才在各个地方得到支持。

将现代JavaScript功能降级的工具往往在谨慎性方面出错,并以非常保守的方式重写代码。在这个例子中,我们知道我们将一个字符串拆分成一个字符串数组。用一个完全成熟的迭代器来循环遍历这个数组完全是过度设计,一个简单的标准for循环就足够了。但由于工具没有意识到这一点,它们选择了能覆盖尽可能多场景的变体。这里是原始代码供您参考:

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毫秒的时间。浪费在polyfills或过时降级处理上的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和我调查graphql包解析速度突然降低的情况,当时他们在新版本中将循环方式切换为for-of循环。

这是一件只有 V8/Gecko/Webkit 工程师才能够正确验证的事情,但我的假设是它仍然必须调用迭代器协议,因为它可能已经被全局覆盖,这将改变每个数组的行为。它可能是这样的事情。

尽管我们通过这些改变取得了一些快速的胜利,但仍然远非理想。总的来说,该功能仍然是一个待改进的热门竞争者,因为它单独负责总时间的几秒钟。再次应用快速计数器技巧揭示了它被调用了大约22k次。可以肯定的是,这是一个在"热"路径中的功能。

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

function getPath(obj, key) {
    let last = 0;
    // Fine because all keys are ASCII and not 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毫秒。

image.png

继续使用 matches() 函数,我们看到由奇怪的 for-of 下传递创建的大量开销,类似于我们之前看到的情况。为了节省时间,我直接在 Github 上复制了源代码中的函数。由于 matches() 在跟踪中更加突出,仅这个更改就可以节省整整 1 秒钟。

我们的生态系统中有很多库都存在这个问题。我真的希望有一种方法可以通过一次点击更新它们所有。也许我们需要一个反向转译器,它可以检测到向下转译的模式并将其转换回现代代码。

我联系了 jviide,看看我们是否可以进一步优化 matches() 。通过他的额外更改,我们能够使整个选择器代码相对于原始未修改状态快约5倍。他基本上是通过消除 matches() 函数中的一堆开销来实现的,这使他也能够简化几个相关的辅助函数。例如,他注意到模板字符串的转译效果不佳。

// input
const literal = `${selector.value.value}`;

// output: down transpiled, slow
const literal = "".concat(selector.value.value);

他甚至更进一步,通过将每个新选择器解析为一系列函数调用链,并在运行时缓存生成的包装函数。这个技巧为选择器引擎带来了另一个巨大的加速。我强烈建议查看他的更改。我们还没有发起PR,因为 esquery 似乎在这一点上没有维护。

提前退出

有时候退一步并从不同的角度解决问题是很好的。到目前为止,我们看了实现细节,但我们实际上正在处理什么样的选择器?有没有潜力缩短其中的一些?为了测试这个理论,我首先需要更好地了解正在处理的选择器的类型。毫不奇怪,大多数选择器都很短。但其中有几个选择器是相当复杂的。例如,这里有一个单独的选择器:

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

当使用自定义特定领域语言时,可能会出现一些问题,例如匹配错误,而且通常没有工具支持。相反,如果使用JavaScript,可以随时使用适当的调试器检查值。虽然前面的字符串选择器示例有些极端,但大多数选择器看起来都像这样

BinaryExpression

或:

VariableDeclaration

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

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);
        }
    }
}

重新思考选择器

一种选择器引擎在需要在不同语言之间传递遍历命令时非常有用,比如我们在浏览器中使用CSS的情况。但是,选择器引擎并不是免费的,因为它总是需要解析选择器以拆解我们应该执行的操作,然后即时构建一些逻辑来执行那个解析后的内容。

但是在 eslint 中,我们没有跨越任何语言障碍。我们仍然停留在 JavaScript 领域。因此,通过将查询指令转换为选择器并将其解析回我们可以再次运行的内容,我们在性能方面没有任何收益。相反,我们消耗了约 25% 的总体 linting 时间来解析和执行选择器。需要一种新的方法。

然后我恍然大悟。

选择器在概念上仅仅是一种“描述”,用于根据其所持有的条件查找元素。这可以是在树中进行查找,也可以是在类似数组的平面数据结构中进行查找。如果你思考一下,即使是标准 Array.prototype.filter() 调用中的回调函数也是一个选择器。我们从一组项目(=数组)中选择值,并仅挑选我们关心的值。我们使用 esquery 所做的正是同样的事情。从一堆对象(=AST节点)中,我们挑选出符合某种条件的对象。那就是选择器!那么,如果我们避免使用选择器解析逻辑,并改用纯 JavaScript 函数呢?

// String based esquery selector
const esquerySelector = `[type="CallExpression"][callee.type="MemberExpression"][callee.computed!=true][callee.property.type="Identifier"]: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")
    );
}

让我们试试这个!我写了一些基准测试来测量这两种方法的时间差异。稍后,数据就会在我的屏幕上弹出。

image.png

看起来纯 JavaScript 函数版本在性能方面轻松地超越了基于字符串的版本。它的优越性非常明显。即使在花费大量时间提高 esquery 的速度之后,它仍然无法接近 JavaScript 变体。在选择器不匹配且引擎可以提前退出的情况下,它仍然比普通函数慢 30 倍。这个小实验证实了我的假设,即我们为选择器引擎付出了相当多的时间。

第三方插件和预设的影响

尽管从eslint的设置中可以看到更多的优化空间,但我开始想知道我是否花时间优化了正确的东西。eslint自己的linting设置中看到的相同问题是否也会在其他linting设置中出现? eslint的关键优势之一一直是其灵活性和对第三方linting规则的支持。回想一下,我所工作的每个项目几乎都有几个自定义linting规则和大约2-5个额外的eslint插件或预设。但更重要的是,它们完全切换了解析器。快速查看npm下载统计数据突显了替换eslint内置解析器的趋势。

image.png

如果这些数字是可信的,那么这意味着只有8%的 eslint 用户使用内置解析器。它还显示了TypeScript已经变得非常普遍,占据了eslint总用户数的73%。我们没有关于使用babel解析器的用户是否也用于TypeScript的数据。我猜其中一部分人会这样做, TypeScript用户的总数实际上可能更高。

在各种开源代码库中对几个不同的设置进行了分析后,我选择了来自 vite 的设置,其中包含了其他配置文件中存在的许多模式。它的代码库是用 TypeScript 编写的, eslint 的解析器也相应地被替换了。

image.png

与之前类似,我们可以在性能剖析图中看到各个区域显示出耗时的情况。有一个区域暗示了将TypeScript的格式转换为eslint所理解的格式需要消耗相当多的时间。配置加载方面也出现了一些奇怪的情况,因为它实际上不应该占用这么多时间。我们还发现了一个老朋友,即eslint-import-plugineslint-plugin-node,它们似乎引发了一系列模块解析逻辑。

这里有趣的一点是选择器引擎的开销并没有显示出来。有一些 applySelector 函数被调用,但在更大的画面中它几乎不消耗任何时间。

总是出现并需要相当长时间才能执行的两个第三方插件是 eslint-plugin-importeslint-plugin-node 。每当这两个插件中的一个或两个处于活动状态时,它们在分析数据中真正显现。它们都会导致大量的文件系统流量,因为它们试图解析一堆模块,但不缓存结果。我们在本系列的第二部分中写了很多关于这个的内容,所以我不会再详细介绍了。

转换所有的AST节点

我们将从一开始的TypeScript转换开始。我们的工具将我们提供给它们的代码解析为一种称为抽象语法树(简称:AST)的数据结构。你可以将其视为我们所有工具使用的基本构建块。它提供了诸如:"嘿,这里我们声明了一个变量,它具有这个名称和那个值",或者"这是一个带有这个条件的if语句,它保护了那个代码块"等信

// `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
      }
  ]
}

可以在优秀的AST Explorer页面上亲自查看我们的工具如何解析代码。我强烈建议访问该网站并尝试使用各种代码片段进行操作。这将帮助你更好地了解我们工具的AST格式有多相似或者多不同。

然而,在 eslint 的情况下存在一个问题。我们希望规则能够在我们选择的所有解析器中都能够工作。当我们激活 no-console 规则时,我们希望它能够在所有解析器中都能够工作,而不是强制每个规则都必须为每个解析器重新编写。基本上,我们需要一个共享的 AST 格式,我们都可以同意。这正是 eslint 所做的。它期望每个 AST 节点都与 estree 规范匹配,该规范规定了每个 AST 节点应该如何查看。这是一个已经存在了相当长时间的规范,许多 JavaScript 工具都是从这个规范开始的。甚至 babel 也是基于此构建的,但自那时以来有一些已记录的偏差。

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

也许Babel的解析器更快?如果我们用 @babel/eslint-parser 替换 @typescript-eslint/parser 会怎样?

image.png

原来这样做可以节省我们相当多的时间。有趣的是,这个改变也大大缩短了配置加载时间。配置加载时间的改善可能是由于 Babel 的解析器分布在较少的文件中。

image.png

请注意,尽管在撰写本文时,Babel解析器明显更快,但它不支持类型感知的代码检查。这是 @typescript-eslint/parser 独有的功能。这为像 no-for-in-array 这样的规则打开了可能性,它可以检测您在 for-in 循环中迭代的变量实际上是 object 而不是 array 。因此,您可能希望继续使用 @typescript-eslint/parser 。但是,如果你确信自己没有使用它们的任何规则,并且只是想要理解TypeScript的语法并更快地进行代码检查,那么切换到Babel的解析器是一个不错的选择。

还有一些关于 Rust 端口的闲聊,这引起了我的好奇心,想知道目前基于 Rust 的 JavaScript 语言检查器有多快。唯一一个似乎有些生产就绪并能够解析 TypeScript 语法大部分内容的是 rslint。

还有一些关于 Rust 端口的闲聊,这引起了我的好奇心,想知道目前基于 Rust 的 JavaScript 语言检查器有多快。唯一一个似乎有些生产就绪并能够解析 TypeScript 语法大部分内容的是 rslint。

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

话虽如此,在vite的代码库(144个文件)上运行所有这些数字的结果如下。

image.png

根据这些数字,我相当有信心,仅通过这个小实验,我们就可以用 JavaScript 实现非常接近 Rust 的性能。

总结

总的来说, eslint 项目前景非常光明。它是最成功的开源项目之一,已经找到了获得大量资金的秘诀。我们研究了一些可以使 eslint 更快的事情,还有很多其他方面的内容没有涉及到。
“eslint的未来”讨论包含了许多伟大的想法,这些想法可以使 eslint 变得更好,潜在地更快。我认为棘手的问题是避免一次性尝试解决所有问题,因为在我的经验中,这通常注定会失败。同样适用于从头开始重写。相反,我认为当前的代码库是一个完美的起点,可以塑造成为更棒的东西。

从外部人的角度来看,有一些关键决策需要做出。比如,现在是否有意义继续支持基于字符串的选择器?如果是,那么 eslint 团队是否有能力承担 esquery 的维护工作并给予它所需的关注?还有,考虑到 npm 下载量表明 73% 的 eslint 用户是 TypeScript 用户,那么原生 TypeScript 支持又该怎么办呢?

代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug

交流

有梦想,有干货,微信搜索 【大迁世界】 关注这个在凌晨还在刷碗的刷碗智。

本文 GitHub https://github.com/qq449245884/xiaozhi 已收录,有一线大厂面试完整考点、资料以及我的系列文章。


王大冶
68.1k 声望105k 粉丝