前言
加快 JavaScript 生态系统的速度系列最后一篇。今日前端早读课文章由 @飘飘翻译分享。
正文从这开始~~
tl;dr: Linting 是在代码中查找可能导致错误或确保一致的阅读体验的模式行为。它是许多 JavaScript/TypeScript 项目的核心部分。我们发现在他们的选择器引擎和 AST 转换过程中有很多节省时间的潜力,并用 JS 编写的完美的 linter 将能够达到亚秒级的运行时间。
在这个系列的过去两篇文章中,我们已经谈了很多关于 linting 的内容,所以我想现在是时候给 eslint 以应有的关注了。总的来说,eslint 是如此的灵活,你甚至可以把解析器换成一个完全不同的。这与 JSX 和 TypeScript 的兴起一样常见。借助健康的插件和预设的生态系统,每个用例可能都有一个规则,如果没有,优秀的文档会指导你如何创建自己的规则。这是我想在这里强调的一件事,因为它是一个经得起时间考验的项目。
【第2866期】加快JavaScript生态系统的速度--一次一个库
【第2871期】加快JavaScript生态系统的速度--模块解析
但是,这也给性能分析带来了问题,因为由于配置的灵活性很广,当涉及到 linting 性能时,两个项目会有截然不同的体验。不过我们需要从某处开始,所以我想有什么比看看 eslint 仓库本身使用的 inting 设置更好的方式来开始我们的调查呢?
使用 eslint 对 eslint 进行 lint
他们的仓库使用了一个任务运行程序抽象来协调常见的构建任务,但只要稍加挖掘,我们就可以拼凑出为 "lint" 任务运行的命令,特别是对 JavaScript 文件进行检查。
node bin/eslint.js --report-unused-disable-directives . --ignore-pattern "docs/**"
很好,在这里你可以看到。Eslint 正在使用 eslint 来对他们的代码库进行检查。像本系列的前两篇文章一样,我们将通过 node 内置的 -cpu-prof
参数生成一个 *.cpuprofile
,我们将把它加载到 Speedscope 中进行进一步分析。几秒钟后(确切的说是 22 秒),我们就可以开始行动了
通过将类似的调用堆栈合并在一起,我们可以更清楚地了解时间花在哪里。这通常被称为 "左重" 的可视化。这不要与你的标准火焰图混淆,后者的 X 轴代表一个调用发生的时间。相反,在这种风格中,X 轴代表的是总时间的消耗,而不是它发生的时间。对我来说,这是 Speedscope 的主要好处之一,而且感觉也更快了。因为它是由 Figma 的几个开发人员编写的,他们在我们的行业中以其卓越的工程设计而闻名,所以我不会期望它有任何不足。
我们可以立即发现 eslint 资源库中的 linting 设置在一些关键区域花费了时间。最突出的是,总时间的很大一部分花在了处理 JSDoc 的规则上,正如从它们的函数名称推断的那样。另一个有趣的方面是,在 lint 任务的不同时间有两个不同的解析器在运行:esquery 和 acorn。但是 JSDoc 规则花了这么长时间激起了我的好奇心。
一个特别的 BackwardTokenCommentCursor
条目似乎很有意义,因为它是最大的一个块。在源的附加文件位置之后,它似乎是一个保持我们在文件中的位置状态的类。作为第一项措施,我添加了一个普通的计数器,每当该类被实例化并再次运行 lint 任务时该计数器就会递增。
关于迷路 2000 万次
总而言之,这个类已经被构造了超过 2000 万次。这似乎相当多了。记住,我们实例化的任何对象或类都会占用内存,而这些内存后来需要被清理。我们可以从数据中看到这个结果,垃圾收集(清理内存的行为)总共需要 2.43 秒。这可不是什么好事。
在创建该类的一个新实例时,它调用了两个函数,这两个函数似乎都是为了启动搜索。在不知道更多关于它在做什么的情况下,第一个函数可以被排除在外,因为它不包含任何形式的循环。根据经验,循环通常是调查性能的主要怀疑对象,所以我通常从那里开始搜索。
第二个函数,叫做 utils.search()
,包含一个循环。它循环遍历从我们当时正在检查的文件内容中解析出来的 token 流。token 是编程语言中最小的构建块,你可以把它们看作是语言的 "单词"。例如,在 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()
方法在 token 数组上进行搜索。该算法被描述为:
findIndex()
是一个迭代方法。它对数组中的每个元素按升序索引顺序调用所提供的 callbackFn 函数一次,直到 callbackFn 返回一个真实的值。
鉴于 token 数组会随着我们在文件中的代码量而增长,这听起来并不理想。我们可以使用更有效的算法来搜索数组中的一个值,而不是遍历数组中的每个元素。例如,用二进制搜索取代这一行,可以将时间减少一半。
虽然减少 50% 的时间看起来不错,但它仍然没有解决这段代码被调用 2000 万次的问题。对我来说,这才是真正的问题。我们或多或少在试图减少症状的影响,而不是解决潜在的问题。我们已经遍历了文件,所以我们应该清楚地知道我们在哪里。改变这一点需要一个更具侵略性的重构,而且对于这篇博文来说,要解释的东西太多了。看到这不是一个简单的修复,我检查了配置文件中还有什么值得关注的地方。中间的紫色长条很难错过,不仅仅是因为它们是不同的颜色,还因为它们占用了大量的时间,没有深入到数百个更小的函数调用中。
选择器引擎
speedscope 中的 callstack 指向一个叫做 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
有 99.99% 的把握是由他们的发布管道插入的函数,而不是由这个库的作者。当 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
循环到处都被支持,所以我再次修补了软件包,并将函数的实现与源码中的原始函数进行了替换。这一次的改动节省了大约 400ms。我总是对我们在浪费 polyfills 或过时的 downtranspilation 上浪费了多少 CPU 时间印象深刻。你可能会认为这没什么区别,但当你遇到像这样的情况时,数字就会显示出不同的情况。值得一提的是,我还测量了将 for-of 循环替换为标准 for 循环的情况。
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 变体相比,这又提高了 200ms。我想即使在今天,for-of 循环也很难被引擎优化。这让我想起了 Jovi 和我过去所做的一项调查,即当 graphql 包发布新版本,改用 for-of 循环时,其解析速度突然减慢。
这是一个 v8/gecko/webkit 工程师可以正确验证的事情,但我的假设是,它仍然需要调用迭代器协议,因为它可能被全局覆盖,这将改变每个 Array 的行为。可能是这样的情况。
虽然我们从这些变化中获得了一些快速的胜利,但它仍然远远不够理想。总的来说,这个功能仍然是一个需要改进的首要竞争者,因为它只占用了总时间的几秒钟。再次应用快速计数器黑客显示,它被调用了大约 22000 次。可以肯定的是,这个函数在某种程度上是 "热" 路径。
特别值得注意的是,在许多处理字符串的性能密集型代码中,都是围绕着 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 秒,在应用了所有的优化后,我们成功地将其降低到 486ms。
继续看 matches()
函数,我们看到奇怪的 for-of downtranspilation 产生了大量的开销,与我们之前看到的情况类似。为了节省时间,我直接上了 github,从源代码中复制了这个函数。由于 matches()
在 traces 中更加突出,仅这一改动就节省了整整 1 秒。
在我们的生态系统中,有很多库都存在这个问题。我真的希望有一种方法可以一键更新它们。也许我们需要一个反向转译器,它可以检测到向下转译的模式,并将其重新转换为现代代码。
我联系了 jviide,看看我们是否可以进一步优化 matches()
。通过他的额外修改,我们能够使整个选择器代码比原来未修改的状态快 5 倍左右。他所做的基本上是摆脱了 matches()
函数中的一堆开销,这使得他也可以简化一些相关的辅助函数。例如,他注意到,模板字符串的转置效果很差。
// input
const literal = `${selector.value.value}`;
// output: down transpiled, slow
const literal = "".concat(selector.value.value);
他甚至更进一步,将每个新的选择器解析为一连串的函数调用,并将产生的包装函数缓存起来。这个技巧使他的选择器引擎的速度又有了很大的提高。我强烈建议你去看看他的改动。我们还没有打开一个 PR,因为 esquery 在这个时候似乎还没有被维护。
EDIT: 我们最后还是开了一个 PR。
提前保释
有时退一步,从不同的角度来解决这个问题是不错的。到目前为止,我们看的是实现细节,但我们实际上是在处理什么样的选择器?是否有可能将其中一些短路?为了测试这一理论,我首先需要更好地了解正在处理的选择器的种类。不出所料,大多数的选择器都是短的。但其中有几个是相当有特色的。例如,这是一个单一的选择器。
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
or:
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);
}
}
}
既然我们已经在选择器引擎上做了手脚,我开始好奇一个字符串化的选择器和一个写成普通 JavaScript 函数的选择器相比会有什么不同。我的直觉告诉我,用简单的 JavaScript 条件的选择器会更容易被引擎优化。
重新思考选择器
如果你需要跨越语言障碍传递遍历命令,比如我们在浏览器中使用 CSS,那么选择器引擎就非常有用。但它从来都不是免费的,因为选择器引擎总是需要解析选择器来解构我们应该做什么,然后动态构建一些逻辑来执行这些解析的东西。
但在 eslint 内部,我们没有跨越任何语言障碍。我们呆在 JavaScript 领域。因此,通过将查询指令转换为选择器并将其解析为我们可以再次运行的东西,我们不会获得任何性能上的提升。相反,我们在解析和执行选择器的过程中消耗了大约 25% 的 linting 时间。我们需要一种新的方法。
然后我想到了。
一个选择器在概念上只不过是一个 "描述",用于根据它所持有的标准来获得一个查找元素。这可能是一个树状的查找,也可能是一个像数组一样的平面数据结构。如果你仔细想想,甚至标准 Array.prototype.filter()
调用中的回调函数也是一个选择器。我们正在从一个项的集合(=Array
)中选择值,并且只选择我们关心的值。我们用 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 的关键优势之一一直是它的灵活性和对第三方提示规则的支持。回顾过去,几乎每一个我工作过的项目都有几个自定义的提示规则和大约 2-5 个额外的 eslint 插件或预置安装。但更重要的是,他们完全换掉了解析器。快速浏览一下 npm 的下载统计,就会发现替换 eslint 内置解析器的趋势。
如果这些数字是可信的,那就意味着只有 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 语句,它守护着这个代码块",等等。
// `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 节点都能与 eslint 规范相匹配,该规范规定了每个 AST 节点应该是什么样子。这是一个已经存在了相当长一段时间的规范,许多 JavaScript 工具都是从它开始的。即使是 babel 也是建立在这个基础上的,但从那时起就有一些记录的偏差。
但这就是使用 TypeScript 时问题的症结所在。TypeScript 的 AST 格式是非常不同的,因为它也需要考虑到代表类型本身的结点。一些构造在内部也有不同的表示方式,因为它使 TypeScript 本身变得更容易。这意味着每一个 TypeScript 的 AST 节点都必须转换为 eslint 理解的格式。这种转换需要时间。在这个配置文件中,这占到了总时间的 22%。之所以需要这么长的时间,不只是单纯的遍历,还因为每次转换我们都要分配新的对象。我们基本上在内存中有两个不同 AST 格式的副本。
也许 babel 的解析器更快?如果我们把 @typescript-eslint/parser
换成 @babel/eslint-parser
呢?
事实证明,仅仅这样做就为我们节省了不少的时间。有趣的是,这个更改还大大减少了配置加载时间。对配置加载时间的改善可能是由于 babel 的解析器被分散在更少的文件中。
请注意,虽然 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 个文件)上运行它们时的数据。
基于这些数字,我相当有信心,在这个小实验的基础上,我们只用 JavaScript 就可以获得非常接近 rust 的性能。
结论
总的来说,eslint 项目有一个非常光明的未来。它是最成功的开放源码软件项目之一,它已经找到了获得大量资金的秘密。我们看了一些可以使 eslint 更快的东西,还有很多这里没有涉及的领域需要研究。
"eslint 的未来" 的讨论包含了很多伟大的想法,这些想法将使 eslint 变得更好,而且可能更快。我认为最棘手的是避免一次解决所有的问题,因为根据我的经验,这往往注定要失败。重写也是如此。相反,我认为目前的代码库是一个很好的起点,准备将其塑造成更棒的东西。
从一个局外人的角度来看,有一些关键的决定要做。比如,在这一点上,继续支持基于字符串的选择器是否有意义?如果是的话,eslint 团队是否有能力承担 esquery 的维护工作,并给它一些急需的爱?鉴于 npm 的下载量表明 73% 的 eslint 用户是 TypeScript 用户,那么原生 TypeScript 的支持情况如何?
不管会发生什么,我对他们的团队和执行他们的愿景的能力有强烈的信心。我为 eslint 的未来感到兴奋!
关于本文
译者:@飘飘
作者:@marvinhagemeist
原文:https://marvinh.dev/blog/speeding-up-javascript-ecosystem-part-3/
这期前端早读课
对你有帮助,帮” 赞 “一下,
期待下一期,帮” 在看” 一下 。