如果你使用JavaScript
,你可能已经在package.json
中使用过”scripts”字段来为项目设置常见任务。这些脚本可以在终端上使用npm run
来执行。我注意到我越来越倾向于直接调用底层命令,而不是使用npm run
,主要是因为这样明显更快。但是相比之下,是什么让它们变得如此慢呢?是时候进行一次性能分析了!
只加载需要的代码
许多开发人员可能不知道的是,npm CLI
是一个标准的JavaScript
文件,可以像任何其他.js文件一样被调用。在 macOS
和Linux
上,你可以通过运行 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 | // in exit-handler.js |
在进行了这个更改之后,与没有进行更改时的性能分析相比,总耗时并没有显示出任何差异。这是因为我们在这里懒加载的模块在其他地方是急切地被引入的。为了正确地懒加载它们,我们需要更改所有引入它们的地方。
接下来,我注意到加载了与npm
的审计功能相关的一堆代码。这似乎很奇怪,因为我并没有运行与审计相关的任何操作。不幸的是,解决这个问题并不像简单地移动一些 require
调用那么容易。
大一统的类
在各种 JavaScript
工具中,一个经常出现的问题是它们由几个庞大的类组成,这些类引入了所有代码,而不仅仅是你所需的代码。这些类通常最初很小,并且有着保持精简的良好意图,但不知何故它们变得越来越庞大。确保只加载所需代码变得越来越困难。这让我想起了 Joe Armstrong
(Erlang 编程语言的创作者)的一句话。
“你想要一个香蕉,但你得到的是一个拿着香蕉的大猩猩和整个丛林。” - Joe Armstrong
在npm
内部有一个名为Arborist
的类,它引入了许多只在特定命令中需要的内容。它涉及修改node_modules
中的布局和包,审计包版本以及许多其他与 npm run
命令无关的事物。如果我们想要优化 npm run
,我们需要将它们从急切加载的模块列表中移除。
1 | const mixins = [ |
根据我们的需求,Arborist
类后来扩展的mixins
数组中加载的所有模块都是不必要的。我们可以将它们全部移除。这个改变为我们节省了约20毫秒的时间,这可能看起来不算多,但这些节约是累积的。与之前一样,我们需要检查其他引入这些模块的地方,以确保我们真正只在需要时加载它们。
减小模块大小
对几个import
或 require
语句进行一些变更确实不错,但对数据的影响并不显著。更大的问题是依赖项,它们通常有一个主入口文件,将整个模块的代码引入。归根结底,问题在于当引擎看到一堆顶层import
或 require
语句时,它将急切地解析和加载这些模块。没有例外。但这恰恰是我们想要避免的。
一个具体的例子是从 npm-registry-fetch
包导入的 cleanUrl
函数。正如名称所示,该包的作用是处理网络相关的事务。但在运行脚本时,npm run
并不执行任何网络请求。这又是节省了20毫秒。我们也不需要显示进度条,因此我们也可以删除与此相关的代码。对于 npm cli
使用的许多其他依赖项也是如此。
对于这些情况,加载的模块数量是一个非常现实的问题。毫不奇怪,对于启动时间至关重要的库已经转向捆绑工具,将它们的所有代码合并为较少的文件。引擎在加载大块 JavaScript
时表现得相当不错。我们之所以如此关心在网络上传递这些字节的成本,主要是因为在网络上传递这些字节的成本很高。
然而,这种方法存在权衡。文件越大,解析时间越长,因此在某一阈值之后,单个庞大文件的解析成本将高于将其拆分的成本。一如既往:通过测量可以显示你是否达到了这种权衡。另一点需要考虑的是,捆绑工具不能像用 ESM 编写的代码一样有效地捆绑为CommonJS
模块系统编写的代码。通常,它们会在 CommonJS
模块周围引入大量包装代码,这使得首次捆绑代码的大部分好处都被抵消。
对所有字符串排序
随着模块的不断减少,性能分析变得更加清晰,并显现出其他可以改进的领域。一个特定对 collaterCompare 函数的调用引起了我的注意。
也许你认为10毫秒不值得花时间调查,但在这个性能分析中,更像是“千头万绪”的事情。没有一个单一的大入口使一切变得快速。因此,改进甚至更小的调用点非常值得。对于 collatorCompare
函数,有趣的是它的目的是以区域设置感知的方式对字符串进行排序。为了实现这一点,实现被分为两个部分:一个初始化函数和它返回的执行实际比较的函数。
1 | // Simplified example of the code in @isaacs/string-locale-compare |
如果我们查看加载此模块的所有地方,我们会发现我们只对对英语字符串进行排序感兴趣,并且除了区域设置之外,我们从不传递任何其他选项。但是由于这个模块的结构方式,每次新的 require
调用都会促使开发人员创建一个全新的比较函数,需要再次进行优化。
1 | // Every require call immediately calls the "default" export with "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 | // This array only contains ascii strings |
通过这个改变,调用该函数所需的时间接近于0毫秒。由于这是最后一个急切加载此模块的地方,又节省了10毫秒。
值得注意的是,在这一点上,我们已经将 npm run 的运行时间缩短了一半。我们现在只需大约200毫秒,而不是一开始的大约400毫秒。看起来不错!
超预期的消耗设置 process.title
另一个引起注意的函数调用是对process.title
属性的设置。为设置属性花费20毫秒似乎是昂贵的。
该设置器的实现出奇的简单:
1 | class Npm extends EventEmitter { |
更改当前运行进程的title似乎是一个相当耗时的操作。尽管这个功能非常有用,因为它可以在任务管理器中更容易地识别同时运行的多个 npm 进程。为了进行这项调查,我没有深入研究并将该部分注释掉。尽管如此,我认为值得深入了解是什么使其变得如此耗时。
globing日志文件
在性能分析中引起我的注意的另一个条目是对 glob 模块内另一个字符串排序函数的调用。在这里进行全局搜索似乎很奇怪,当我们只想运行npm
脚本时。glob
模块用于按用户定义的模式遍历文件系统以查找匹配的文件,但我们为什么需要这样做呢?大部分时间似乎花在对字符串进行排序上。
这个函数只被调用一次,传入一个包含11个字符串的简单数组,排序这样的数组应该是瞬间完成的。奇怪的是,性能分析显示这花费了大约10毫秒。
1 | // Sorting this array somehow takes 10ms |
实现看起来也相当正常。
1 | function alphasort(a, b) { |
也许我们可以使用之前用于比较这些字符串的 Intl.Collator 对象。
1 | const collator = Intl.Collator("en"); |
这样做起到了效果。我不太确定为什么相对于 String.prototype.localeCompare
,Intl.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/