加速JavaScript - npm脚本


如果你使用JavaScript,你可能已经在package.json中使用过”scripts”字段来为项目设置常见任务。这些脚本可以在终端上使用npm run来执行。我注意到我越来越倾向于直接调用底层命令,而不是使用npm run,主要是因为这样明显更快。但是相比之下,是什么让它们变得如此慢呢?是时候进行一次性能分析了!

只加载需要的代码

许多开发人员可能不知道的是,npm CLI是一个标准的JavaScript文件,可以像任何其他.js文件一样被调用。在 macOSLinux上,你可以通过运行 which npm 来获取 npm cli 的完整路径。将该文件倒出到终端显示,它只是一个普通的标准 .js 文件。唯一特殊的是第一行,它告诉你的 shell 用哪个程序来执行当前文件。由于我们处理的是一个JavaScript文件,这个程序就是node

由于它只是一个.js文件,我们可以依赖所有通常生成性能分析文件的方法。我最喜欢的方法之一是使用 node--cpu-prof 参数。将这些知识结合起来,我们可以通过 node --cpu-prof $(which npm) run myscript 从一个npm脚本中生成性能分析文件。将该分析文件加载到 speedscope 中可以揭示关于npm结构的很多信息。

大部分时间都花在加载组成npm cli的所有模块上。与我们运行的脚本相比,脚本的运行时间相对较短。我们看到一堆文件,似乎只在满足某些条件时才需要。一个例子是在发生错误时才需要的错误消息格式化代码。

在npm中存在这样的情况,其中急切地需要一个退出处理程序。让我们只在需要时才引入该模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
 // in exit-handler.js
const log = require('./log-shim.js')
- const errorMessage = require('./error-message.js')
- const replaceInfo = require('./replace-info.js')

const exitHandler = err => {
//...
if (err) {
+ const replaceInfo = require('./replace-info.js');
+ const errorMessage = require('./error-message.js')
//...
}
};

在进行了这个更改之后,与没有进行更改时的性能分析相比,总耗时并没有显示出任何差异。这是因为我们在这里懒加载的模块在其他地方是急切地被引入的。为了正确地懒加载它们,我们需要更改所有引入它们的地方。

接下来,我注意到加载了与npm的审计功能相关的一堆代码。这似乎很奇怪,因为我并没有运行与审计相关的任何操作。不幸的是,解决这个问题并不像简单地移动一些 require 调用那么容易。

大一统的类

在各种 JavaScript 工具中,一个经常出现的问题是它们由几个庞大的类组成,这些类引入了所有代码,而不仅仅是你所需的代码。这些类通常最初很小,并且有着保持精简的良好意图,但不知何故它们变得越来越庞大。确保只加载所需代码变得越来越困难。这让我想起了 Joe Armstrong(Erlang 编程语言的创作者)的一句话。

“你想要一个香蕉,但你得到的是一个拿着香蕉的大猩猩和整个丛林。” - Joe Armstrong

npm内部有一个名为Arborist的类,它引入了许多只在特定命令中需要的内容。它涉及修改node_modules中的布局和包,审计包版本以及许多其他与 npm run 命令无关的事物。如果我们想要优化 npm run,我们需要将它们从急切加载的模块列表中移除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const mixins = [
require("../tracker.js"),
require("./pruner.js"),
require("./deduper.js"),
require("./audit.js"),
require("./build-ideal-tree.js"),
require("./load-workspaces.js"),
require("./load-actual.js"),
require("./load-virtual.js"),
require("./rebuild.js"),
require("./reify.js"),
require("./isolated-reifier.js"),
];

const Base = mixins.reduce((a, b) => b(a), require("events"));
class Arborist extends Base {
//...
}

根据我们的需求,Arborist 类后来扩展的mixins数组中加载的所有模块都是不必要的。我们可以将它们全部移除。这个改变为我们节省了约20毫秒的时间,这可能看起来不算多,但这些节约是累积的。与之前一样,我们需要检查其他引入这些模块的地方,以确保我们真正只在需要时加载它们。

减小模块大小

对几个importrequire 语句进行一些变更确实不错,但对数据的影响并不显著。更大的问题是依赖项,它们通常有一个主入口文件,将整个模块的代码引入。归根结底,问题在于当引擎看到一堆顶层importrequire 语句时,它将急切地解析和加载这些模块。没有例外。但这恰恰是我们想要避免的。

一个具体的例子是从 npm-registry-fetch 包导入的 cleanUrl 函数。正如名称所示,该包的作用是处理网络相关的事务。但在运行脚本时,npm run 并不执行任何网络请求。这又是节省了20毫秒。我们也不需要显示进度条,因此我们也可以删除与此相关的代码。对于 npm cli 使用的许多其他依赖项也是如此。

对于这些情况,加载的模块数量是一个非常现实的问题。毫不奇怪,对于启动时间至关重要的库已经转向捆绑工具,将它们的所有代码合并为较少的文件。引擎在加载大块 JavaScript 时表现得相当不错。我们之所以如此关心在网络上传递这些字节的成本,主要是因为在网络上传递这些字节的成本很高。

然而,这种方法存在权衡。文件越大,解析时间越长,因此在某一阈值之后,单个庞大文件的解析成本将高于将其拆分的成本。一如既往:通过测量可以显示你是否达到了这种权衡。另一点需要考虑的是,捆绑工具不能像用 ESM 编写的代码一样有效地捆绑为CommonJS模块系统编写的代码。通常,它们会在 CommonJS 模块周围引入大量包装代码,这使得首次捆绑代码的大部分好处都被抵消。

对所有字符串排序

随着模块的不断减少,性能分析变得更加清晰,并显现出其他可以改进的领域。一个特定对 collaterCompare 函数的调用引起了我的注意。

也许你认为10毫秒不值得花时间调查,但在这个性能分析中,更像是“千头万绪”的事情。没有一个单一的大入口使一切变得快速。因此,改进甚至更小的调用点非常值得。对于 collatorCompare 函数,有趣的是它的目的是以区域设置感知的方式对字符串进行排序。为了实现这一点,实现被分为两个部分:一个初始化函数和它返回的执行实际比较的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Simplified example of the code in @isaacs/string-locale-compare

const collatorCompare = (locale, opts) => {
const collator = new Intl.Collator(locale, opts);
// Always returns a new function that needs to be optimized from scratch
return (a, b) => collator.compare(a, b);
};

const cache = new Map();
module.exports = (locale, options = {}) => {
const key = `${locale}\n${JSON.stringify(options)}`;

if (cache.has(key)) return cache.get(key);

const compare = collatorCompare(locale, opts);
cache.set(key, compare);
return compare;
};

如果我们查看加载此模块的所有地方,我们会发现我们只对对英语字符串进行排序感兴趣,并且除了区域设置之外,我们从不传递任何其他选项。但是由于这个模块的结构方式,每次新的 require 调用都会促使开发人员创建一个全新的比较函数,需要再次进行优化。

1
2
// Every require call immediately calls the "default" export with "en"
const localeCompare = require("@isaacs/string-locale-compare")("en");

但理想情况下,我们希望每个人都使用相同的比较函数。考虑到这一点,我们可以用两行代码替换原有的代码,其中我们只创建一次 Intl.Collator,并且只创建一次 localeCompare 函数。

1
2
3
// We only ever need to construct the Collator class instance once
const collator = new Intl.Collator("en");
const localeCompare = (a, b) => collator.compare(a, b);

在一个特定的地方,npm 保存了一个可用命令的排序列表。该列表是硬编码的,永远不会在运行时更改。它只包含ASCII字符串,因此我们可以使用普通的 .sort(),而不是我们的区域感知函数。

1
2
3
4
5
6
7
8
9
10
11
  // This array only contains ascii strings
const commands = [
'access',
'adduser',
'audit',
'bugs',
'cache',
'ci',
// ...
- ].sort(localeCompare)
+ ].sort()

通过这个改变,调用该函数所需的时间接近于0毫秒。由于这是最后一个急切加载此模块的地方,又节省了10毫秒。

值得注意的是,在这一点上,我们已经将 npm run 的运行时间缩短了一半。我们现在只需大约200毫秒,而不是一开始的大约400毫秒。看起来不错!

超预期的消耗设置 process.title

另一个引起注意的函数调用是对process.title 属性的设置。为设置属性花费20毫秒似乎是昂贵的。

该设置器的实现出奇的简单:

1
2
3
4
5
6
7
8
class Npm extends EventEmitter {
// ...
set title(t) {
// This line is the culprit
process.title = t;
this.#title = t;
}
}

更改当前运行进程的title似乎是一个相当耗时的操作。尽管这个功能非常有用,因为它可以在任务管理器中更容易地识别同时运行的多个 npm 进程。为了进行这项调查,我没有深入研究并将该部分注释掉。尽管如此,我认为值得深入了解是什么使其变得如此耗时。

globing日志文件

在性能分析中引起我的注意的另一个条目是对 glob 模块内另一个字符串排序函数的调用。在这里进行全局搜索似乎很奇怪,当我们只想运行npm脚本时。glob 模块用于按用户定义的模式遍历文件系统以查找匹配的文件,但我们为什么需要这样做呢?大部分时间似乎花在对字符串进行排序上。

这个函数只被调用一次,传入一个包含11个字符串的简单数组,排序这样的数组应该是瞬间完成的。奇怪的是,性能分析显示这花费了大约10毫秒。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Sorting this array somehow takes 10ms
[
"/Users/marvinhagemeister/.npm/_logs/2023-03-18T20_06_53_324Z-debug-0.log",
"/Users/marvinhagemeister/.npm/_logs/2023-03-18T20_07_35_219Z-debug-0.log",
"/Users/marvinhagemeister/.npm/_logs/2023-03-18T20_07_36_674Z-debug-0.log",
"/Users/marvinhagemeister/.npm/_logs/2023-03-18T20_08_11_985Z-debug-0.log",
"/Users/marvinhagemeister/.npm/_logs/2023-03-18T20_09_23_766Z-debug-0.log",
"/Users/marvinhagemeister/.npm/_logs/2023-03-18T20_11_30_959Z-debug-0.log",
"/Users/marvinhagemeister/.npm/_logs/2023-03-18T20_11_42_726Z-debug-0.log",
"/Users/marvinhagemeister/.npm/_logs/2023-03-18T20_12_53_575Z-debug-0.log",
"/Users/marvinhagemeister/.npm/_logs/2023-03-18T20_17_08_421Z-debug-0.log",
"/Users/marvinhagemeister/.npm/_logs/2023-03-18T20_21_52_813Z-debug-0.log",
"/Users/marvinhagemeister/.npm/_logs/2023-03-18T20_24_02_611Z-debug-0.log",
];

实现看起来也相当正常。

1
2
3
function alphasort(a, b) {
return a.localeCompare(b, "en");
}

也许我们可以使用之前用于比较这些字符串的 Intl.Collator 对象。

1
2
3
4
const collator = Intl.Collator("en");
function alphasort(a, b) {
return collator.compare(a, b);
}

这样做起到了效果。我不太确定为什么相对于 String.prototype.localeCompareIntl.Collator 对象更快。这确实听起来有点可疑。但我可以在我的环境中可靠地验证速度差异。对于这个特定的调用,Intl.Collator 方法始终更快。

更大的问题是,搜索文件系统以查找日志文件似乎与我们的意图相悖。当命令成功时,写入和清除日志文件非常有用,但如果我们是最初创建它们的人,我们不应该知道我们写入的文件的名称吗?我尝试进行了一些更改,但为了本文的目的,我决定将其注释掉并继续调查。

在这一点上,我们从一开始的大约400毫秒降到了大约138毫秒。虽然这已经是一个相当不错的改进,但我们还可以做得更好。

删除所有的东西

我觉得我需要更加激进地删除或取消注释与运行 npm 脚本无关的代码。到目前为止,我们已经做了我们的一份努力,我们可以继续这样做,但我对我们应该追求的最佳时间感到好奇。基本目标是仅加载绝对必要执行 npm 脚本的代码。其他一切都只是额外的开销和浪费时间。

于是,我编写了一个简短的脚本,只做了运行npm脚本所需的最低限度。最终,我将运行时间降低到约22毫秒,这大约是我们开始时的400毫秒的18倍。尽管与其功能相比,22毫秒仍然感觉像很长的时间。与其他语言(比如 Rust)相比,它显然是一项亮点。无论如何,22毫秒现在已经足够快了。

总结

表面上看,我们花费了这么多时间让 npm run 命令快了大约380毫秒,似乎有些奇怪。然而,如果你考虑到这个命令在全球范围内被开发人员执行的频率,以及它在CI中的执行频率,这些节省相当迅速地累积起来。对于本地开发来说,有更迅速的 npm 脚本也是很好的,因此肯定存在个人受益的角度。

但是最大的问题仍然存在:没有简单的方法来绕过模块。到目前为止,我查看的所有 JavaScript 工具都存在这个问题。有些工具更明显,而其他一些受影响较小。解析和加载一堆模块的开销是非常真实的。我不确定长期解决方案是什么,或者这是否可以由 JavaScript 引擎本身解决。

在找到合适的解决方案之前,我们可以立即应用的一种可行的解决方法是在将代码发布到 npm 时对其进行捆绑。但我私下里希望这并不是唯一可行的前进道路,所有运行时在这方面都有所改进。我们生态系统越是简化,我们就越适合初学者。

原文:https://marvinh.dev/blog/speeding-up-javascript-ecosystem-part-4/