JS生態(tài)系統(tǒng)加速npm腳本優(yōu)化及性能分析探索
引言
長話短說:npm 腳本總是由整顆地球的 JS 開發(fā)者和 CI(持續(xù)集成)系統(tǒng)執(zhí)行。盡管使用率很高,但它們并沒有得到良好優(yōu)化,且增加了大約 400 毫秒的開銷。在本文中,我們能夠?qū)⑵鋬?yōu)化至約 22 毫秒。
本期《前端翻譯計劃》共享的是“加速 JS 生態(tài)系統(tǒng)系列博客”,包括但不限于:
- PostCSS,SVGO 等等
- 模塊解析
- 使用 eslint
- npm 腳本
- draft-js emoji 插件
- polyfill 暴走
- 桶裝文件崩潰
- Tailwind CSS
npm 腳本
本期共享的是第 4 篇博客 —— npm 腳本。
如果使用 JS,您可能使用過 package.json 中的 "scripts" 字段,為項目設(shè)置常見任務(wù)。這些腳本可以在終端上使用 npm run 執(zhí)行。我傾向于直接調(diào)用底層命令,而不是調(diào)用 npm run,主要因為這明顯更快。但反而言之,是什么讓它們慢如龜速呢?是時候進(jìn)行性能分析了!
僅按需加載加載代碼
一大坨開發(fā)者不知道的是,npm CLI 是一個標(biāo)準(zhǔn) JS 文件,可以像其他 .js 文件一樣執(zhí)行。在 macOS 和 Linux 上,您可以通過運行 which npm 獲取 npm cli 的完整路徑。將該文件轉(zhuǎn)儲到終端表明,它是一個平平無奇的標(biāo)準(zhǔn) .js 文件。唯一奇葩在于首行代碼,它告訴 shell 可以使用哪個程序來執(zhí)行當(dāng)前文件。因為我們正在處理一個 node 的 JS 文件。

因為它只是一個 .js 文件,所以我們可以依靠所有常用方法來生成配置文件。我最喜歡的是 Node 的 --cpu-prof 參數(shù)。將這些知識結(jié)合在一起,我們可以通過 node --cpu-prof $(which npm) run myscript,從 npm 腳本生成配置文件。將該配置文件加載到 speedscope 中,可以揭示一大坨有關(guān) npm 結(jié)構(gòu)的信息。

大部分時間都花在加載構(gòu)成 npm cli 的所有模塊上。相比之下,我們運行的腳本的時間就相形見絀了。我們看到一大坨文件,似乎只有在滿足特定條件時才需要。舉個栗子,格式化錯誤消息的代碼,當(dāng)且僅當(dāng)發(fā)生錯誤時才需要。
npm 中存在這種情況,exit 句柄無腦 require。讓我們當(dāng)且僅當(dāng)需要時,才 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 審計功能相關(guān)的代碼。這看起來很奇葩,因為我沒有運行任何審計相關(guān)的東東。不幸的是,對我們而言,這并不像移動某些 require 調(diào)用那么容易。
萬能類
各種 JS 工具中反復(fù)出現(xiàn)的一個問題是,它們由一大坨類組成,這些類包含所有內(nèi)容,而不僅僅是我們需要的代碼。這些類總是從小規(guī)模開始,并有良好的精簡意圖,但不知何故,它們變得越來越腫。確保按需加載代碼越來越難。這讓我想起 Joe Armstrong(Erlang 之父)的這句名言:
“您只想要一根香蕉,但您得到的是一只大猩猩拿著香蕉和整個叢林。”
npm 內(nèi)部有一個 Arborist 類,它引入了一大坨僅特定命令所需的東東。它引入了與修改 node_modules 中的布局和包、審核包版本以及 npm run 命令不需要的其他一大坨相關(guān)內(nèi)容。如果我們想優(yōu)化 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 數(shù)組中的模塊(Arborist 類稍后在其上擴(kuò)展)都不需要。我們可以一鍵清空回收站。這一更改優(yōu)化了大約 20 毫秒,這可能看似九牛一毛,但積少成多。和以前一樣,我們需要檢查 require 這些模塊的其他地方,確保我們確實只按需加載它。
減小模塊圖大小
對隨處可見的一大坨 require 語句進(jìn)行更改很好,但不會顯著影響性能。更大的問題在于依賴,它通常有一個主入口文件,該文件提取所述模塊的所有代碼。最終問題在于,當(dāng)引擎瞄到一大坨頂層 import 或 require 語句時,它會餓漢式解析并加載這些模塊。無一例外。但這正是我們想要避免的。
一個具體的例子是,從 npm-registry-fetch 包導(dǎo)入的 cleanUrl 函數(shù)。顧名思義,該包主要關(guān)于網(wǎng)絡(luò)方面。但運行腳本時,我們不會在 npm run 中執(zhí)行任何類型的網(wǎng)絡(luò)請求。這又優(yōu)化了 20 毫秒。我們也不需要顯示進(jìn)度條,因此我們也可以刪除其代碼。npm cli 使用的一大坨其他依賴也是舉一反一。
對于這些場景而言,加載的模塊數(shù)量是一個非常現(xiàn)實的問題。見怪不怪,對于啟動時間茲事體大的庫已轉(zhuǎn)向打包器,將其所有代碼合并到更少的文件中。引擎非常適合加載 JS 大型 blob。我們?nèi)绱岁P(guān)心網(wǎng)絡(luò)上文件大小的主要原因在于,通過網(wǎng)絡(luò)傳輸那些字節(jié)的成本。
不過,此方案也有權(quán)衡。文件越大,解析時間就越長,因此存在有一個閾值,超過該閾值后,單個大文件的解析成本會高于將其拆分。與往常一樣:測量將告訴,您是否達(dá)到了這種均衡。另一件需要考慮的事情是,打包器無法像 ESM 代碼那樣高效地打包 CommonJS 模塊系統(tǒng)的代碼。通常,它們會在 CommonJS 模塊周圍引入一大坨包裝代碼,這首先抵消了打包代碼的大部分福利。
排序所有字符串
隨著模塊圖的逐次遞減,配置文件的干擾越來越小,并揭露了其他可以優(yōu)化的地方。對 collaterCompare 函數(shù)的特定調(diào)用引起了我的注意。

您可能會認(rèn)為,10 毫秒的優(yōu)化性價比太低,但在此配置文件中,它更像是“勿以善小而不為”。沒有任何銀彈可以讓一切加速。因此,優(yōu)化小型的調(diào)用位置非常值得。collatorCompare 函數(shù)的有趣之處在于,其預(yù)期目的是以區(qū)域設(shè)置感知的方式排序字符串。該實現(xiàn)分為兩部分:初始化函數(shù)及其返回的實際比較的函數(shù)。
// @isaacs/string-locale-compare 中代碼的簡化示例
const collatorCompare = (locale, opts) => {
const collator = new Intl.Collator(locale, opts)
// 始終返回一個需要從零開始優(yōu)化的函數(shù)
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
}如果我們查看該模塊加載的所有位置,可以看到我們只對排序英文字符串感興趣,并且從不傳遞除語言環(huán)境之外的任何其他選項。但由于該模塊的結(jié)構(gòu)化方式,每個新的 require 調(diào)用都會促使我們創(chuàng)建一個需要再次優(yōu)化的全新比較函數(shù)。
// 每個 require 調(diào)用立即使用 en 調(diào)用默認(rèn)導(dǎo)出
const localeCompare = require('@isaacs/string-locale-compare')('en')
但理想情況下,我們希望大家都使用相同的比較函數(shù)??紤]到這一點,我們可以用兩行代碼替換,其中我們創(chuàng)建了一次 Intl.Collator,并且也只創(chuàng)建一次 localeCompare 函數(shù)。
// 我們只需構(gòu)造一次 Collator 類的實例
const collator = new Intl.Collator('en')
const localeCompare = (a, b) => collator.compare(a, b)
在某個特定位置,npm 保存可用命令的排序列表。該列表是硬編碼的,并且在運行時永遠(yuǎn)不變。它僅由 ascii 字符串組成,因此我們可以使用普通的原有 .sort(),而不是我們的區(qū)域設(shè)置感知函數(shù)。
// 此數(shù)組僅包含 ASCII 字符串
const commands = [
'access',
'adduser',
'audit',
'bugs',
'cache',
'ci',
// ...
- ].sort(localeCompare)
+ ].sort()
通過此優(yōu)化,調(diào)用該函數(shù)的時間趨近 0 毫秒。這又優(yōu)化了 10 毫秒,因為此乃最后一個餓漢式加載該模塊的地方。
粉絲請注意,此時我們已經(jīng)將 npm run 的速度提高了一倍。我們現(xiàn)在從開始時的約 400 毫秒減少到約 200 毫秒。
設(shè)置 process.title 的成本很高
另一個跳出的函數(shù)調(diào)用是對神秘 title 屬性的 setter 的調(diào)用。設(shè)置屬性 20ms 似乎很昂貴。

該 setter 的實現(xiàn)非常簡單:
class Npm extends EventEmitter {
// ...
set title(t) {
// 這行代碼是罪魁禍?zhǔn)?
process.title = t
this.#title = t
}
}
更改當(dāng)前正在運行的進(jìn)程的標(biāo)題似乎是一個相當(dāng)昂貴的操作。不過,此功能確實頗有用處,因為當(dāng)您同時運行多個 npm 進(jìn)程時,它可以更輕松地在任務(wù)管理器中發(fā)現(xiàn)特定的 npm 進(jìn)程。盡管如此,私以為可能值得深究是什么導(dǎo)致了如此昂貴的成本。
全局日志文件
配置文件中引起我注意的另一個入口是,對 glob 模塊內(nèi)另一個字符串排序函數(shù)的調(diào)用。很奇怪的是,當(dāng)我們只想運行 npm 腳本時,我們甚至在這里進(jìn)行通配符。glob 模塊用于在文件系統(tǒng)中抓取與用戶定義模式匹配的文件,但為什么我們需要它呢?諷刺的是,大部分時間似乎不是花在搜索文件系統(tǒng)上,而是花在字符串排序上。

該函數(shù)僅使用包含 11 個字符串的簡單數(shù)組調(diào)用一次,并且排序應(yīng)該是即時的。奇怪的是,配置文件顯示這花了大約 10 毫秒。
// 以某種方式排序此數(shù)組需要 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' ]
該實現(xiàn)看起來也人畜無害。
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 相比之下更慢。這聽起來確實很可疑。但我可以可靠地驗證我這邊的速度差異。對于此特定調(diào)用,Intl.Collator 方法始終更快。
更大的問題是,在文件系統(tǒng)中搜索日志文件似乎與我們的意圖不符。如果命令成功,日志文件會被寫入并清除,這非常有用,但是如果我們是最初創(chuàng)建這些文件的人,我們難道不應(yīng)該知道我們寫入的文件的名稱嗎?
此時,我們已從最初的約 400 毫秒降至約 138 毫秒。盡管這已經(jīng)是一個相當(dāng)不錯的優(yōu)化,但我們還可以更進(jìn)一步。
刪除所有東西
私以為我需要更加積極地刪除或取消注釋與運行 npm 腳本無關(guān)的代碼。目前為止,我們已經(jīng)盡職盡責(zé),我們可以漸進(jìn)增強,但我很好奇我們應(yīng)該爭取的預(yù)期時間是多少?;灸繕?biāo)是按需加載執(zhí)行 npm 腳本的代碼。其他一切都只是開銷和時間浪費。
所以我寫了一個簡短的腳本,它只執(zhí)行運行 npm 腳本所需的最低限度的工作。最后我把它降低到了大約 22 毫秒,這比我們開始時的 400 毫秒快了大約 18 倍。我對此非常滿意,盡管與它的實際效果相比,22 毫秒仍然感覺很長。相比之下,Rust 等其他語言無疑更擅長這一點。無論如何,有一點需要指出的是,22 毫秒目前已經(jīng)足夠快了。

完結(jié)撒花
表面上看,我們花了那么多時間使 npm run 命令快了大約 380 毫秒,這似乎事倍功半。雖然但是,如果您考慮一下整顆地球的開發(fā)者執(zhí)行該命令的頻率,以及在 CI 內(nèi)執(zhí)行該命令的頻率,這些優(yōu)化滾雪球驚人。對于本地開發(fā)而言,擁有更快速的 npm 腳本也很棒,所以肯定存在個人利益的角度。
但房間里的大大象仍然存在:沒有簡單的方法來短路模塊圖。目前為止,我見過的所有 JS 工具都存在此痛點。有些工具的影響更為明顯,而另一些工具則影響較小。解析和加載一堆模塊的開銷非常真實。我不確定這個問題的長期解決方案是什么,或者 JS 引擎本身是否可以解決此問題。
在找到合適的解決方案之前,我們今天可以應(yīng)用的一個可行的解決方案是,在將代碼發(fā)布到 npm 時將其打包。我私下希望這不是唯一可行的不二法門,并且所有運行時都在這方面得到優(yōu)化。我們需要處理的工具越少,我們作為一個生態(tài)系統(tǒng)對初學(xué)者就越友好。
免責(zé)聲明
本文屬于是語冰的直男翻譯了屬于是,略有刪改,僅供粉絲參考
本文屬于是語冰的直男翻譯了屬于是,略有刪改,僅供粉絲參考,英文原味版請傳送 Speeding up the JavaScript ecosystem - npm scripts[1]。
以上就是JS生態(tài)系統(tǒng)加速npm腳本優(yōu)化及性能分析探索的詳細(xì)內(nèi)容,更多關(guān)于JS npm腳本的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
基于JavaScript實現(xiàn)Json數(shù)據(jù)根據(jù)某個字段進(jìn)行排序
這篇文章主要介紹了基于JavaScript實現(xiàn)Json數(shù)據(jù)根據(jù)某個字段進(jìn)行排序的相關(guān)資料,需要的朋友可以參考下2015-11-11
微信小程序轉(zhuǎn)換uniapp的遷移步驟以及遇到的問題總結(jié)
最近公司有個需求,第一次遇到,把原生的微信小程序代碼轉(zhuǎn)換為uni-app項目,下面這篇文章主要給大家介紹了關(guān)于微信小程序轉(zhuǎn)換uniapp的遷移步驟以及遇到問題的相關(guān)資料,文中通過實例代碼介紹的非常詳細(xì),需要的朋友可以參考下2022-07-07
ES6 迭代器(Iterator)和 for.of循環(huán)使用方法學(xué)習(xí)(總結(jié))
這篇文章主要介紹了ES6 迭代器(Iterator)和 for.of循環(huán)使用方法學(xué)習(xí)總結(jié),小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-02-02
JS插件plupload.js實現(xiàn)多圖上傳并顯示進(jìn)度條
這篇文章主要為大家詳細(xì)介紹了PHP結(jié)合plupload.js JS插件實現(xiàn)多圖上傳并顯示進(jìn)度條加刪除實例,具有一定的參考價值,感興趣的小伙伴們可以參考一下2016-11-11
JavaScript數(shù)據(jù)結(jié)構(gòu)之雙向鏈表
這篇文章主要為大家詳細(xì)介紹了JavaScript數(shù)據(jù)結(jié)構(gòu)之雙向鏈表,文中示例代碼介紹的非常詳細(xì),具有一定的參考價值,感興趣的小伙伴們可以參考一下2021-03-03

