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
,主要因為這明顯更快。但反而言之,是什么讓它們慢如龜速呢?是時候進行性能分析了!
僅按需加載加載代碼
一大坨開發(fā)者不知道的是,npm CLI 是一個標準 JS 文件,可以像其他 .js
文件一樣執(zhí)行。在 macOS 和 Linux 上,您可以通過運行 which npm
獲取 npm cli 的完整路徑。將該文件轉(zhuǎn)儲到終端表明,它是一個平平無奇的標準 .js
文件。唯一奇葩在于首行代碼,它告訴 shell 可以使用哪個程序來執(zhí)行當前文件。因為我們正在處理一個 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 的所有模塊上。相比之下,我們運行的腳本的時間就相形見絀了。我們看到一大坨文件,似乎只有在滿足特定條件時才需要。舉個栗子,格式化錯誤消息的代碼,當且僅當發(fā)生錯誤時才需要。
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 審計功能相關(guān)的代碼。這看起來很奇葩,因為我沒有運行任何審計相關(guān)的東東。不幸的是,對我們而言,這并不像移動某些 require
調(diào)用那么容易。
萬能類
各種 JS 工具中反復出現(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
類稍后在其上擴展)都不需要。我們可以一鍵清空回收站。這一更改優(yōu)化了大約 20 毫秒,這可能看似九牛一毛,但積少成多。和以前一樣,我們需要檢查 require
這些模塊的其他地方,確保我們確實只按需加載它。
減小模塊圖大小
對隨處可見的一大坨 require
語句進行更改很好,但不會顯著影響性能。更大的問題在于依賴,它通常有一個主入口文件,該文件提取所述模塊的所有代碼。最終問題在于,當引擎瞄到一大坨頂層 import
或 require
語句時,它會餓漢式解析并加載這些模塊。無一例外。但這正是我們想要避免的。
一個具體的例子是,從 npm-registry-fetch
包導入的 cleanUrl
函數(shù)。顧名思義,該包主要關(guān)于網(wǎng)絡(luò)方面。但運行腳本時,我們不會在 npm run
中執(zhí)行任何類型的網(wǎng)絡(luò)請求。這又優(yōu)化了 20 毫秒。我們也不需要顯示進度條,因此我們也可以刪除其代碼。npm cli 使用的一大坨其他依賴也是舉一反一。
對于這些場景而言,加載的模塊數(shù)量是一個非常現(xiàn)實的問題。見怪不怪,對于啟動時間茲事體大的庫已轉(zhuǎn)向打包器,將其所有代碼合并到更少的文件中。引擎非常適合加載 JS 大型 blob。我們?nèi)绱岁P(guān)心網(wǎng)絡(luò)上文件大小的主要原因在于,通過網(wǎng)絡(luò)傳輸那些字節(jié)的成本。
不過,此方案也有權(quán)衡。文件越大,解析時間就越長,因此存在有一個閾值,超過該閾值后,單個大文件的解析成本會高于將其拆分。與往常一樣:測量將告訴,您是否達到了這種均衡。另一件需要考慮的事情是,打包器無法像 ESM 代碼那樣高效地打包 CommonJS 模塊系統(tǒng)的代碼。通常,它們會在 CommonJS 模塊周圍引入一大坨包裝代碼,這首先抵消了打包代碼的大部分福利。
排序所有字符串
隨著模塊圖的逐次遞減,配置文件的干擾越來越小,并揭露了其他可以優(yōu)化的地方。對 collaterCompare
函數(shù)的特定調(diào)用引起了我的注意。
您可能會認為,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)用默認導出 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 保存可用命令的排序列表。該列表是硬編碼的,并且在運行時永遠不變。它僅由 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) { // 這行代碼是罪魁禍首 process.title = t this.#title = t } }
更改當前正在運行的進程的標題似乎是一個相當昂貴的操作。不過,此功能確實頗有用處,因為當您同時運行多個 npm 進程時,它可以更輕松地在任務(wù)管理器中發(fā)現(xiàn)特定的 npm 進程。盡管如此,私以為可能值得深究是什么導致了如此昂貴的成本。
全局日志文件
配置文件中引起我注意的另一個入口是,對 glob
模塊內(nèi)另一個字符串排序函數(shù)的調(diào)用。很奇怪的是,當我們只想運行 npm 腳本時,我們甚至在這里進行通配符。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)是一個相當不錯的優(yōu)化,但我們還可以更進一步。
刪除所有東西
私以為我需要更加積極地刪除或取消注釋與運行 npm 腳本無關(guān)的代碼。目前為止,我們已經(jīng)盡職盡責,我們可以漸進增強,但我很好奇我們應(yīng)該爭取的預(yù)期時間是多少?;灸繕耸前葱杓虞d執(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)對初學者就越友好。
免責聲明
本文屬于是語冰的直男翻譯了屬于是,略有刪改,僅供粉絲參考
本文屬于是語冰的直男翻譯了屬于是,略有刪改,僅供粉絲參考,英文原味版請傳送 Speeding up the JavaScript ecosystem - npm scripts[1]。
以上就是JS生態(tài)系統(tǒng)加速npm腳本優(yōu)化及性能分析探索的詳細內(nèi)容,更多關(guān)于JS npm腳本的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
基于JavaScript實現(xiàn)Json數(shù)據(jù)根據(jù)某個字段進行排序
這篇文章主要介紹了基于JavaScript實現(xiàn)Json數(shù)據(jù)根據(jù)某個字段進行排序的相關(guān)資料,需要的朋友可以參考下2015-11-11微信小程序轉(zhuǎn)換uniapp的遷移步驟以及遇到的問題總結(jié)
最近公司有個需求,第一次遇到,把原生的微信小程序代碼轉(zhuǎn)換為uni-app項目,下面這篇文章主要給大家介紹了關(guān)于微信小程序轉(zhuǎn)換uniapp的遷移步驟以及遇到問題的相關(guān)資料,文中通過實例代碼介紹的非常詳細,需要的朋友可以參考下2022-07-07ES6 迭代器(Iterator)和 for.of循環(huán)使用方法學習(總結(jié))
這篇文章主要介紹了ES6 迭代器(Iterator)和 for.of循環(huán)使用方法學習總結(jié),小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-02-02JS插件plupload.js實現(xiàn)多圖上傳并顯示進度條
這篇文章主要為大家詳細介紹了PHP結(jié)合plupload.js JS插件實現(xiàn)多圖上傳并顯示進度條加刪除實例,具有一定的參考價值,感興趣的小伙伴們可以參考一下2016-11-11JavaScript數(shù)據(jù)結(jié)構(gòu)之雙向鏈表
這篇文章主要為大家詳細介紹了JavaScript數(shù)據(jù)結(jié)構(gòu)之雙向鏈表,文中示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下2021-03-03