关于javascript:Part3JavaScript生态加速攻略eslint

5次阅读

共计 11655 个字符,预计需要花费 30 分钟才能阅读完成。

本文首发于微信公众号:大迁世界, 我的微信: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 秒),咱们筹备好深入研究了!

通过合并相似的调用堆栈,咱们能够更分明地理解工夫破费在哪里。这通常被称为“左重(left-heavy)”可视化。这与规范的火焰图不同,其中 x 轴示意调用产生的工夫。相同,在这种格调中,x轴示意总工夫耗费的工夫,而不是产生的工夫。对我来说,这是 Speedscope 的次要长处之一,而且感觉更加迅速。这并不意外,因为它是由 Figma 的几个开发人员编写的,他们以在咱们行业中的工程卓越而闻名。

一个特定的 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 元素,而是在另一个树结构中查找对象。这是雷同的想法。

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

未压缩的代码的性能比压缩的代码慢大概 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 毫秒。

持续应用 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")
    );
}

让咱们试试这个!我写了一些基准测试来测量这两种办法的工夫差别。稍后,数据就会在我的屏幕上弹出。

看起来纯 JavaScript 函数版本在性能方面轻松地超过了基于字符串的版本。它的优越性非常明显。即便在破费大量工夫进步 esquery 的速度之后,它依然无奈靠近 JavaScript 变体。在选择器不匹配且引擎能够提前退出的状况下,它依然比一般函数慢 30 倍。这个小试验证实了我的假如,即咱们为选择器引擎付出了相当多的工夫。

第三方插件和预设的影响

只管从 eslint 的设置中能够看到更多的优化空间,但我开始想晓得我是否花工夫优化了正确的货色。eslint 本人的 linting 设置中看到的雷同问题是否也会在其余 linting 设置中呈现?eslint 的要害劣势之一始终是其灵活性和对第三方 linting 规定的反对。回忆一下,我所工作的每个我的项目简直都有几个自定义 linting 规定和大概 2 - 5 个额定的 eslint 插件或预设。但更重要的是,它们齐全切换了解析器。疾速查看 npm 下载统计数据突显了替换 eslint 内置解析器的趋势。

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

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

与之前相似,咱们能够在性能分析图中看到各个区域显示出耗时的状况。有一个区域暗示了将 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 会怎么?

原来这样做能够节俭咱们相当多的工夫。乏味的是,这个扭转也大大缩短了配置加载工夫。配置加载工夫的改善可能是因为 Babel 的解析器散布在较少的文件中。

请留神,只管在撰写本文时,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 个文件)上运行所有这些数字的后果如下。

依据这些数字,我相当有信念,仅通过这个小试验,咱们就能够用 JavaScript 实现十分靠近 Rust 的性能。

总结

总的来说,eslint 我的项目前景十分光明。它是最胜利的开源我的项目之一,曾经找到了取得大量资金的秘诀。咱们钻研了一些能够使 eslint 更快的事件,还有很多其余方面的内容没有波及到。
“eslint 的将来”探讨蕴含了许多平凡的想法,这些想法能够使 eslint 变得更好,潜在地更快。我认为辣手的问题是防止一次性尝试解决所有问题,因为在我的教训中,这通常注定会失败。同样实用于从头开始重写。相同,我认为以后的代码库是一个完满的终点,能够塑造成为更棒的货色。

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

代码部署后可能存在的 BUG 没法实时晓得,预先为了解决这些 BUG,花了大量的工夫进行 log 调试,这边顺便给大家举荐一个好用的 BUG 监控工具 Fundebug。

交换

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

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

正文完
 0