JS生态系统加速npm脚本优化及性能分析探索
作者:大家的林语冰 人猫神话
引言
长话短说:npm 脚本总是由整颗地球的 JS 开发者和 CI(持续集成)系统执行。尽管使用率很高,但它们并没有得到良好优化,且增加了大约 400 毫秒的开销。在本文中,我们能够将其优化至约 22 毫秒。
本期《前端翻译计划》共享的是“加速 JS 生态系统系列博客”,包括但不限于:
- PostCSS,SVGO 等等
- 模块解析
- 使用 eslint
- npm 脚本
- draft-js emoji 插件
- polyfill 暴走
- 桶装文件崩溃
- Tailwind CSS
npm 脚本
本期共享的是第 4 篇博客 —— npm 脚本。
如果使用 JS,您可能使用过 package.json
中的 "scripts"
字段,为项目设置常见任务。这些脚本可以在终端上使用 npm run
执行。我倾向于直接调用底层命令,而不是调用 npm run
,主要因为这明显更快。但反而言之,是什么让它们慢如龟速呢?是时候进行性能分析了!
仅按需加载加载代码
一大坨开发者不知道的是,npm CLI 是一个标准 JS 文件,可以像其他 .js
文件一样执行。在 macOS 和 Linux 上,您可以通过运行 which npm
获取 npm cli 的完整路径。将该文件转储到终端表明,它是一个平平无奇的标准 .js
文件。唯一奇葩在于首行代码,它告诉 shell 可以使用哪个程序来执行当前文件。因为我们正在处理一个 node
的 JS 文件。
因为它只是一个 .js
文件,所以我们可以依靠所有常用方法来生成配置文件。我最喜欢的是 Node 的 --cpu-prof
参数。将这些知识结合在一起,我们可以通过 node --cpu-prof $(which npm) run myscript
,从 npm 脚本生成配置文件。将该配置文件加载到 speedscope 中,可以揭示一大坨有关 npm 结构的信息。
大部分时间都花在加载构成 npm cli 的所有模块上。相比之下,我们运行的脚本的时间就相形见绌了。我们看到一大坨文件,似乎只有在满足特定条件时才需要。举个栗子,格式化错误消息的代码,当且仅当发生错误时才需要。
npm 中存在这种情况,exit
句柄无脑 require
。让我们当且仅当需要时,才 require
该模块。
// 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') //... } };
将更改后与未更改的配置文件比较,不会显示总时间存在差异。这是因为我们在这里更改为延迟加载的模块在其他地方饿汉式 require
。为了正确地延迟加载它们,我们需要更改所有 require
的地方。
接下来我注意到,加载了一堆与 npm 审计功能相关的代码。这看起来很奇葩,因为我没有运行任何审计相关的东东。不幸的是,对我们而言,这并不像移动某些 require
调用那么容易。
万能类
各种 JS 工具中反复出现的一个问题是,它们由一大坨类组成,这些类包含所有内容,而不仅仅是我们需要的代码。这些类总是从小规模开始,并有良好的精简意图,但不知何故,它们变得越来越肿。确保按需加载代码越来越难。这让我想起 Joe Armstrong(Erlang 之父)的这句名言:
“您只想要一根香蕉,但您得到的是一只大猩猩拿着香蕉和整个丛林。”
npm 内部有一个 Arborist
类,它引入了一大坨仅特定命令所需的东东。它引入了与修改 node_modules
中的布局和包、审核包版本以及 npm run
命令不需要的其他一大坨相关内容。如果我们想优化 npm run
,我们需要将它们从无脑加载的模块列表中剔除。
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 { //... }
出于我们的目的,所有加载到 mixins
数组中的模块(Arborist
类稍后在其上扩展)都不需要。我们可以一键清空回收站。这一更改优化了大约 20 毫秒,这可能看似九牛一毛,但积少成多。和以前一样,我们需要检查 require
这些模块的其他地方,确保我们确实只按需加载它。
减小模块图大小
对随处可见的一大坨 require
语句进行更改很好,但不会显著影响性能。更大的问题在于依赖,它通常有一个主入口文件,该文件提取所述模块的所有代码。最终问题在于,当引擎瞄到一大坨顶层 import
或 require
语句时,它会饿汉式解析并加载这些模块。无一例外。但这正是我们想要避免的。
一个具体的例子是,从 npm-registry-fetch
包导入的 cleanUrl
函数。顾名思义,该包主要关于网络方面。但运行脚本时,我们不会在 npm run
中执行任何类型的网络请求。这又优化了 20 毫秒。我们也不需要显示进度条,因此我们也可以删除其代码。npm cli 使用的一大坨其他依赖也是举一反一。
对于这些场景而言,加载的模块数量是一个非常现实的问题。见怪不怪,对于启动时间兹事体大的库已转向打包器,将其所有代码合并到更少的文件中。引擎非常适合加载 JS 大型 blob。我们如此关心网络上文件大小的主要原因在于,通过网络传输那些字节的成本。
不过,此方案也有权衡。文件越大,解析时间就越长,因此存在有一个阈值,超过该阈值后,单个大文件的解析成本会高于将其拆分。与往常一样:测量将告诉,您是否达到了这种均衡。另一件需要考虑的事情是,打包器无法像 ESM 代码那样高效地打包 CommonJS 模块系统的代码。通常,它们会在 CommonJS 模块周围引入一大坨包装代码,这首先抵消了打包代码的大部分福利。
排序所有字符串
随着模块图的逐次递减,配置文件的干扰越来越小,并揭露了其他可以优化的地方。对 collaterCompare
函数的特定调用引起了我的注意。
您可能会认为,10 毫秒的优化性价比太低,但在此配置文件中,它更像是“勿以善小而不为”。没有任何银弹可以让一切加速。因此,优化小型的调用位置非常值得。collatorCompare
函数的有趣之处在于,其预期目的是以区域设置感知的方式排序字符串。该实现分为两部分:初始化函数及其返回的实际比较的函数。
// @isaacs/string-locale-compare 中代码的简化示例 const collatorCompare = (locale, opts) => { const collator = new Intl.Collator(locale, opts) // 始终返回一个需要从零开始优化的函数 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
调用都会促使我们创建一个需要再次优化的全新比较函数。
// 每个 require 调用立即使用 en 调用默认导出 const localeCompare = require('@isaacs/string-locale-compare')('en')
但理想情况下,我们希望大家都使用相同的比较函数。考虑到这一点,我们可以用两行代码替换,其中我们创建了一次 Intl.Collator
,并且也只创建一次 localeCompare
函数。
// 我们只需构造一次 Collator 类的实例 const collator = new Intl.Collator('en') const localeCompare = (a, b) => collator.compare(a, b)
在某个特定位置,npm 保存可用命令的排序列表。该列表是硬编码的,并且在运行时永远不变。它仅由 ascii 字符串组成,因此我们可以使用普通的原有 .sort()
,而不是我们的区域设置感知函数。
// 此数组仅包含 ASCII 字符串 const commands = [ 'access', 'adduser', 'audit', 'bugs', 'cache', 'ci', // ... - ].sort(localeCompare) + ].sort()
通过此优化,调用该函数的时间趋近 0 毫秒。这又优化了 10 毫秒,因为此乃最后一个饿汉式加载该模块的地方。
粉丝请注意,此时我们已经将 npm run
的速度提高了一倍。我们现在从开始时的约 400 毫秒减少到约 200 毫秒。
设置 process.title 的成本很高
另一个跳出的函数调用是对神秘 title
属性的 setter
的调用。设置属性 20ms 似乎很昂贵。
该 setter
的实现非常简单:
class Npm extends EventEmitter { // ... set title(t) { // 这行代码是罪魁祸首 process.title = t this.#title = t } }
更改当前正在运行的进程的标题似乎是一个相当昂贵的操作。不过,此功能确实颇有用处,因为当您同时运行多个 npm 进程时,它可以更轻松地在任务管理器中发现特定的 npm 进程。尽管如此,私以为可能值得深究是什么导致了如此昂贵的成本。
全局日志文件
配置文件中引起我注意的另一个入口是,对 glob
模块内另一个字符串排序函数的调用。很奇怪的是,当我们只想运行 npm 脚本时,我们甚至在这里进行通配符。glob
模块用于在文件系统中抓取与用户定义模式匹配的文件,但为什么我们需要它呢?讽刺的是,大部分时间似乎不是花在搜索文件系统上,而是花在字符串排序上。
该函数仅使用包含 11 个字符串的简单数组调用一次,并且排序应该是即时的。奇怪的是,配置文件显示这花了大约 10 毫秒。
// 以某种方式排序此数组需要 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' ]
该实现看起来也人畜无害。
function alphasort(a, b) { return a.localeCompare(b, 'en') }
但也许我们可以使用 Intl.Collator
对象来代替之前用来比较这些字符串的对象。
const collator = Intl.Collator('en') function alphasort(a, b) { return collator.compare(a, b) }
这就码到功成了。我不完全确定为什么 String.prototype.localeCompare
相比之下更慢。这听起来确实很可疑。但我可以可靠地验证我这边的速度差异。对于此特定调用,Intl.Collator
方法始终更快。
更大的问题是,在文件系统中搜索日志文件似乎与我们的意图不符。如果命令成功,日志文件会被写入并清除,这非常有用,但是如果我们是最初创建这些文件的人,我们难道不应该知道我们写入的文件的名称吗?
此时,我们已从最初的约 400 毫秒降至约 138 毫秒。尽管这已经是一个相当不错的优化,但我们还可以更进一步。
删除所有东西
私以为我需要更加积极地删除或取消注释与运行 npm 脚本无关的代码。目前为止,我们已经尽职尽责,我们可以渐进增强,但我很好奇我们应该争取的预期时间是多少。基本目标是按需加载执行 npm 脚本的代码。其他一切都只是开销和时间浪费。
所以我写了一个简短的脚本,它只执行运行 npm 脚本所需的最低限度的工作。最后我把它降低到了大约 22 毫秒,这比我们开始时的 400 毫秒快了大约 18 倍。我对此非常满意,尽管与它的实际效果相比,22 毫秒仍然感觉很长。相比之下,Rust 等其他语言无疑更擅长这一点。无论如何,有一点需要指出的是,22 毫秒目前已经足够快了。
完结撒花
表面上看,我们花了那么多时间使 npm run 命令快了大约 380 毫秒,这似乎事倍功半。虽然但是,如果您考虑一下整颗地球的开发者执行该命令的频率,以及在 CI 内执行该命令的频率,这些优化滚雪球惊人。对于本地开发而言,拥有更快速的 npm 脚本也很棒,所以肯定存在个人利益的角度。
但房间里的大大象仍然存在:没有简单的方法来短路模块图。目前为止,我见过的所有 JS 工具都存在此痛点。有些工具的影响更为明显,而另一些工具则影响较小。解析和加载一堆模块的开销非常真实。我不确定这个问题的长期解决方案是什么,或者 JS 引擎本身是否可以解决此问题。
在找到合适的解决方案之前,我们今天可以应用的一个可行的解决方案是,在将代码发布到 npm 时将其打包。我私下希望这不是唯一可行的不二法门,并且所有运行时都在这方面得到优化。我们需要处理的工具越少,我们作为一个生态系统对初学者就越友好。
免责声明
本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考
本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考,英文原味版请传送 Speeding up the JavaScript ecosystem - npm scripts[1]。
以上就是JS生态系统加速npm脚本优化及性能分析探索的详细内容,更多关于JS npm脚本的资料请关注脚本之家其它相关文章!