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

 更新时间:2024年01月21日 11:32:38   作者:大家的林语冰 人猫神话  
这篇文章主要为大家介绍了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脚本的资料请关注脚本之家其它相关文章!

相关文章

  • JavaScript资源预加载组件和滑屏组件的使用推荐

    JavaScript资源预加载组件和滑屏组件的使用推荐

    这篇文章主要介绍了JavaScript资源预加载组件和滑屏组件的使用推荐,分别为preload和slide的用法讲解,使用起来非常简单,需要的朋友可以参考下
    2016-03-03
  • JavaScript实现留言板案例

    JavaScript实现留言板案例

    这篇文章主要为大家详细介绍了JavaScript实现留言板案例,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2020-03-03
  • 判断javascript的数据类型(示例代码)

    判断javascript的数据类型(示例代码)

    这篇文章主要是对判断javascript的数据类型(示例代码)进行了详细的介绍,需要的朋友可以过来参考下,希望对大家有所帮助
    2013-12-12
  • 简单JS代码压缩器

    简单JS代码压缩器

    简单JS代码压缩器...
    2006-10-10
  • 微信小程序实战之自定义抽屉菜单(7)

    微信小程序实战之自定义抽屉菜单(7)

    这篇文章主要为大家详细介绍了微信小程序实战之自定义抽屉菜单效果,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2017-04-04
  • JS实现图片无间断滚动代码汇总

    JS实现图片无间断滚动代码汇总

    这篇文章主要介绍了JS实现图片无间断滚动代码汇总,非常实用的特效代码,需要的朋友可以参考下
    2014-07-07
  • ES6学习教程之模板字符串详解

    ES6学习教程之模板字符串详解

    大家都知道在ES6中引进的一种新型的字符串字面量语法-模板字符串,下面这篇文章主要给大家介绍了关于ES6学习教程之模板字符串的相关资料,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧。
    2017-10-10
  • JS实现放烟花效果

    JS实现放烟花效果

    这篇文章主要为大家详细介绍了JS实现放烟花效果,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2020-03-03
  • JavaScript将数组转换成CSV格式的方法

    JavaScript将数组转换成CSV格式的方法

    这篇文章主要介绍了JavaScript将数组转换成CSV格式的方法,实例分析了javascript使用valueOf方法将数组值转换为csv格式字符串的技巧,非常具有实用价值,需要的朋友可以参考下
    2015-03-03
  • vscode工具函数idGenerator使用深度解析

    vscode工具函数idGenerator使用深度解析

    这篇文章主要为大家介绍了vscode工具函数idGenerator使用深度解析,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-03-03

最新评论