javascript技巧

关注公众号 jb51net

关闭
首页 > 网络编程 > JavaScript > javascript技巧 > JS npm脚本

JS生态系统加速npm脚本优化及性能分析探索

作者:大家的林语冰 人猫神话

这篇文章主要为大家介绍了JS生态系统加速npm脚本优化及性能分析,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪

引言

长话短说:npm 脚本总是由整颗地球的 JS 开发者和 CI(持续集成)系统执行。尽管使用率很高,但它们并没有得到良好优化,且增加了大约 400 毫秒的开销。在本文中,我们能够将其优化至约 22 毫秒。

本期《前端翻译计划》共享的是“加速 JS 生态系统系列博客”,包括但不限于:

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脚本的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文