JS生態(tài)系統(tǒng)加速模塊解析賦能性能優(yōu)化探索
引言
長話短說:無論您是在構(gòu)建、測試或檢查 JS,模塊解析始終是這一切的核心。盡管模塊解析在前端工具鏈中占據(jù)核心地位,但我們并沒有花太多時間來優(yōu)化它。通過本文討論的變更,工具的速度優(yōu)化 30%。
本期《前端翻譯計劃》共享的是“加速 JS 生態(tài)系統(tǒng)系列博客”,包括但不限于:
- PostCSS,SVGO 等等
- 模塊解析
- 使用 eslint
- npm 腳本
- draft-js emoji 插件
- polyfill 暴走
- 桶裝文件崩潰
- Tailwind CSS
本期共享的是第 2 篇博客 —— 模塊解析賦能性能優(yōu)化。
在本系列的第 1 篇博客中,我們發(fā)現(xiàn)了若干種加速 JS 工具庫的方法。雖然這些低層補丁使總構(gòu)建時間大大加速,但我們的工具中是否還有某些基建可以優(yōu)化。那些對常見 JS 任務(wù)(比如打包、測試和 linting)的總時間影響更大的東東。
因此,我從我們行業(yè)常用的各種工具中收集了大約十幾個 CPU 配置文件。經(jīng)過一番檢查后,我發(fā)現(xiàn)每個配置文件中都存在重復(fù)模式,該模式對這些任務(wù)的總運行時間的影響高達(dá) 30%。它是我們基建中茲事體大的部分,值得深入探討。
這個關(guān)鍵部分稱為模塊解析。在我所有調(diào)試中,它花費的時間比解析源碼還要多。
捕獲堆棧跟蹤的成本
當(dāng)我注意到這些跟蹤中最耗時的方面花費在 captureLargerStackTrace
負(fù)責(zé)將堆棧跟蹤附加到 Error
對象的內(nèi)部 Node 函數(shù)時,好戲就開始了。這似乎有點不同尋常,因為這兩項任務(wù)都成功了,并且沒有顯示任何拋出錯誤的跡象。
單擊分析數(shù)據(jù)中的一系列事件后,可以更清晰地了解正在發(fā)生的情況。幾乎所有錯誤的產(chǎn)生都來自調(diào)用 Node 原生的 fs.statSync()
函數(shù),而該函數(shù)又在名為 isFile
的函數(shù)內(nèi)調(diào)用。文檔提到 fs.statSync()
基本上相當(dāng)于 POSIX
的 fstat
命令,通常用于檢查磁盤上是否存在路徑,是文件還是目錄。考慮到這一點,我們應(yīng)該只在文件不存在、缺乏文件讀取權(quán)限或類似特殊用例中獲取錯誤。是時候瞄一下 isFile
的源碼了。
function isFile(file) { try { const stat = fs.statSync(file) return stat.isFile() || stat.isFIFO() } catch (err) { if (err.code === 'ENOENT' || err.code === 'ENOTDIR') { return false } throw err } }
乍一看,這是一個看似無辜的函數(shù),但天網(wǎng)恢恢疏而不漏。值得注意的是,我們忽略某些錯誤情況并返回 false
,而不是轉(zhuǎn)發(fā)錯誤。 ENOENT
和 ENOTDIR
錯誤代碼最終都意味著,磁盤上不存在該路徑。也許這就是我們看到的開銷?我的意思是,我們會立即忽略這些錯誤。為了測試這個理論,我打印了 try/catch
區(qū)塊捕獲的所有錯誤。你瞧,拋出的每個錯誤要么是 ENOENT
代碼,要么是 ENOTDIR
代碼。
查看 Node 的 fs.statSync
文檔可以發(fā)現(xiàn),它支持傳遞 throwIfNoEntry
選項,該選項可防止在不存在文件系統(tǒng)入口時報錯。相反,在那種情況下它將返回 undefined
。
function isFile(file) { const stat = fs.statSync(file, { throwIfNoEntry: false }) return stat !== undefined && (stat.isFile() || stat.isFIFO()) }
應(yīng)用此選項允許我們避免 catch
區(qū)塊中的 if
語句,這反過來又使 try/catch
變得多余,并允許我們進(jìn)一步簡化函數(shù)。
這一簡單更改將項目的 lint 時間縮短了 7%。更棒的是,測試也從相同的更改中受益。
文件系統(tǒng)很昂貴
隨著該函數(shù)的堆棧跟蹤開銷被消除,我覺得還有一大坨優(yōu)化空間。您知道的,在幾分鐘內(nèi)捕獲的跟蹤中根本不應(yīng)該出現(xiàn)幾個錯誤。因此,我在該函數(shù)中注入了一個簡單的計數(shù)器,了解其調(diào)用頻率。顯而易見,它被調(diào)用了大約 15k 次,大約是項目中文件數(shù)量的 10 倍。這聽起來像是一個優(yōu)化的機會。
模塊與否,這是一個問題
默認(rèn)情況下,工具需要了解三種說明符:
相對模塊導(dǎo)入:
./foo
、../bar/boof
絕對模塊導(dǎo)入:
/foo
、/foo/bar/bob
包導(dǎo)入
foo
、@foo/bar
從性能角度而言,三者中最有趣的是最后一個。裸導(dǎo)入說明符,即不以點 .
或斜杠 /
開頭的導(dǎo)入說明符,是一種特殊的導(dǎo)入類型,通常引用 npm 包。該算法在 Node 的文檔中有深入說明。其要點是,它嘗試解析包名稱,然后向上遍歷,檢查是否存在包含該模塊的特殊 node_modules
目錄,直到到達(dá)文件系統(tǒng)的根目錄。讓我們用一個例子來說明一下。
假設(shè)我們有一個位于 /Users/marvinh/my-project/src/features/DetailPage/components/Layout/index.js
的文件嘗試導(dǎo)入模塊 foo
,然后該算法會檢查以下位置。
/Users/marvinh/my-project/src/features/DetailPage/components/Layout/node_modules/foo/
/Users/marvinh/my-project/src/features/DetailPage/components/node_modules/foo/
/Users/marvinh/my-project/src/features/DetailPage/node_modules/foo/
/Users/marvinh/my-project/src/features/node_modules/foo/
/Users/marvinh/my-project/src/node_modules/foo/
/Users/marvinh/my-project/node_modules/foo/
/Users/marvinh/node_modules/foo/
/Users/node_modules/foo/
此乃一大坨文件系統(tǒng)調(diào)用。簡而言之,這會檢查每個目錄是否包含模塊目錄。檢查數(shù)量與導(dǎo)入文件所在的目錄數(shù)量直接相關(guān)。問題是,導(dǎo)入 foo
的每個文件都會發(fā)生這種情況。這意味著,如果 foo
被導(dǎo)入到其他地方的文件中,我們會再次向上爬取整個目錄樹,直到找到包含該模塊的 node_modules
目錄。這就是緩存已解析模塊有很大幫助的一個方面。
但它會變得更好!一大坨項目都使用路徑映射別名來節(jié)省一點輸入,以便您可以在任何地方使用相同的導(dǎo)入說明符,并避免一大坨點 ../../../
。這通常是通過 TS 的 paths
編譯器選項或打包器中的解析別名來完成的。問題在于,這些通常與包導(dǎo)入無法區(qū)分。如果我在 /Users/marvinh/my-project/src/features/
處添加到功能目錄的路徑映射,以便我可以使用諸如 import {...} from “features/DetailPage”
之類的導(dǎo)入聲明,那么每個工具都應(yīng)該知道這一點。
但如果沒有呢?由于沒有每個 JS 工具都使用的集中模塊解析包,因此它們是多個相互競爭的工具,支持不同級別的功能。就本人而言,該項目大量使用路徑映射,并且它包含一個 linting 插件,該插件不知道 TS 的 tsconfig.json
中定義的路徑映射。自然地,它假設(shè) features/DetailPage
指的是一個 Node 模塊,這導(dǎo)致它執(zhí)行整個遞歸向上遍歷,希望找到該模塊。但它從未這樣做過,所以它會報錯。
緩存所有的東西
接下來,我增強了日志記錄,查看調(diào)用該函數(shù)的唯一文件路徑有多少個,以及它是否始終返回相同的結(jié)果。只有大約 2.5k 個對 isFile
的調(diào)用具有唯一的文件路徑,并且傳遞的文件參數(shù)和返回值之間存在強大的 1:1 映射。它仍然比項目中的文件數(shù)量多,但比總共被調(diào)用的 15k 次要低得多。如果我們在其周圍添加一個緩存,避免訪問文件系統(tǒng),那會如何?
const cache = new Map() function resolve(file) { const cached = cache.get(file) if (cached !== undefined) return cached // 這里存在解析邏輯...... const resolved = isFile(file) cache.set(file, resolved) return file }
添加緩存使總 linting 時間又加快了 15%。不過,緩存的風(fēng)險在于,它們可能會變得過時。它們通常必須在某個時間點失效。為了安全起見,我最終選擇了一種更保守的方法來檢查緩存的文件是否仍然存在。如果您認(rèn)為工具經(jīng)常在監(jiān)視模式下運行,那這種情況并不罕見,在監(jiān)視模式下,期望盡可能多地緩存,并且僅使更改的文件無效。
const cache = new Map() function resolve(file) { const cached = cache.get(file) // 保守策略:檢查緩存文件是否存在硬盤上 // 避免監(jiān)測模式下穩(wěn)定緩存時文件可能移動或重命名 if (cached !== undefined && isFile(file)) { return cached } // 這里存在解析邏輯...... for (const ext of extensions) { const filePath = file + ext if (isFile(filePath)) { cache.set(file, filePath) return filePath } } throw new Error(`Could not resolve ${file}`) }
老實說,我本來希望它首先會抵消添加緩存的好處,因為即使在緩存場景中我們也會訪問文件系統(tǒng)。但從數(shù)字來看,這只使總 linting 時間惡化了 0.05%。相比之下,這是一個非常小的影響,但額外的文件系統(tǒng)調(diào)用不是更重要嗎?
文件擴展名猜謎游戲
JS 中的模塊問題在于,該語言從一開始就沒有模塊系統(tǒng)。當(dāng) Node 橫空出世時,它普及了 CommonJS 模塊系統(tǒng)。該系統(tǒng)有若干“可愛”的功能,比如能夠省略正在加載的文件的擴展名。當(dāng)您編寫諸如 require("./foo")
之類的語句時,它會自動添加 .js
擴展名,并嘗試讀取 ./foo.js
處的文件。如果不存在,它將檢查 json
文件 ./foo.json
,如果也不可用,它將檢查 ./foo/index.js
處的 index
文件。
實際上,我們在這里處理的是歧義,工具必須能夠理解 ./foo
的解析結(jié)果。這樣,很可能會產(chǎn)生浪費的文件系統(tǒng)調(diào)用,因為無法提前知道將文件解析到哪里。工具實際上必須嘗試每種組合,直到找到匹配項。如果我們看看目前存在的可能擴展的總數(shù),情況會變得更糟。工具通常有一系列潛在的擴展需要檢查。如果您包含 TS,那么在撰寫本文時,典型前端項目的完整列表為:
const extensions = [ '.js', '.jsx', '.cjs', '.mjs', '.ts', '.tsx', '.mts', '.cts' ]
有 8 個需要檢查的潛在擴展。這還不是全部。您實際上必須將該列表加倍才能考慮 index
文件,這些文件也可以解析為所有這些擴展名!這意味著,我們的工具除了循環(huán)瀏覽擴展列表,直到找到磁盤上存在的擴展之外,沒有其他選擇。當(dāng)我們想要解析 ./foo
,并且實際文件是 foo.ts
時,我們需要檢查:
foo.js -> 不存在
foo.jsx -> 不存在
foo.cjs -> 不存在
foo.mjs -> 不存在
foo.ts -> 終于找到了!
這是四個不必要的文件系統(tǒng)調(diào)用。當(dāng)然,您可以更改擴展的順序,并將項目中最常見的擴展放在數(shù)組的開頭。這會增加提前找到正確擴展的機會,但并不能完全消除問題。
作為 ES2015 規(guī)范的一部分,提出了一個新的模塊系統(tǒng)。所有細(xì)節(jié)都沒有及時充實,但語法卻充實了。import
語句很快就占據(jù)了主導(dǎo)地位,因為它們在工具方面比 CommonJS 有優(yōu)勢。由于其靜態(tài)性,它為更多工具增強功能開辟了空間,比如最著名的 tree-shaking
(樹搖優(yōu)化),其中未使用的模塊甚至模塊中的功能可以輕松檢測到,并從生產(chǎn)版本中刪除。自然而然地,每個人都接受了新的導(dǎo)入語法。
但有一個問題:只最終確定了語法,而不是實際的模塊加載或解析應(yīng)該如何工作。為了填補這一空白,工具重新使用了 CommonJS 中的現(xiàn)有語義。這對于采用是有好處的,因為移植大多數(shù)代碼庫只需要進(jìn)行語法更改,并且這些可以通過 codemods
自動化。從采用的角度來看,這是一個很棒的方面!但這也意味著,我們繼承了導(dǎo)入說明符應(yīng)解析為哪個文件擴展名的猜測游戲。
模塊加載和解析的實際規(guī)范在幾年后最終確定,并通過強制擴展糾正了這個錯誤。
// 非法 ESM,導(dǎo)入說明符缺失擴展名 import { doSomething } from './foo' // 合法 ESM import { doSomething } from './foo.js'
通過消除這種歧義源并始終添加擴展,我們可以避免一整類問題。工具也變得更快。但生態(tài)系統(tǒng)在這方面取得進(jìn)展(甚至根本沒有取得進(jìn)展)還需要時間,因為工具已經(jīng)適應(yīng)了處理模糊性的問題。
路在何方?
在整個調(diào)查過程中,我有點驚訝地發(fā)現(xiàn)在優(yōu)化模塊分辨率方面還有一大坨優(yōu)化空間,因為它是我們工具的核心。本文中描述的若干更改將 linting 時間減少了 30%!
我們在這里所做的若干優(yōu)化也不是 JS 獨有的。這些優(yōu)化與其他編程語言的工具中可以找到的優(yōu)化相同。當(dāng)談到模塊分辨率時,四個主要要點是:
- 盡可能避免調(diào)用文件系統(tǒng)
- 盡可能緩存,避免調(diào)用文件系統(tǒng)
- 當(dāng)使用
fs.stat
或fs.statSync
時,請始終設(shè)置throwIfNoEntry: false
- 盡可能限制向上遍歷
我們工具的緩慢并不是由 JS 這種語言造成的,而是因為根本沒有優(yōu)化。JS 生態(tài)系統(tǒng)的碎片化也無濟于事,因為沒有一個用于模塊解析的標(biāo)準(zhǔn)包。相反,有很多個包,并且它們都共享不同的功能子集。這并不奇怪,因為多年來支持的功能列表不斷增長,并且在撰寫本文時還沒有一個庫可以支持所有這些功能。擁有一個每個人都使用的單一庫能使每個人一勞永逸地解決此問題。
免責(zé)聲明
本文屬于是語冰的直男翻譯了屬于是,略有刪改,僅供粉絲參考,英文原味版請傳送 Speeding up the JavaScript ecosystem - module resolution
以上就是JS生態(tài)系統(tǒng)加速模塊解析賦能性能優(yōu)化探索的詳細(xì)內(nèi)容,更多關(guān)于JS模塊解析的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
JavaScript中的作用域與閉包、原型與原型鏈、異步與單線程
JavaScript的三座大山指的是:作用域和閉包、原型和原型鏈、異步與單線程,這些概念在日常的開發(fā)工作中經(jīng)常被提及,并對我們理解和編寫高質(zhì)量的JavaScript代碼至關(guān)重要2024-02-02多次注冊事件會導(dǎo)致一個事件被觸發(fā)多次的解決方法
一個JavaScript邏輯,會自動綁定函數(shù)到按鈕的click事件,但是這段代碼會反復(fù)注冊事件,具體的解決方法如下,感興趣的朋友可以參考下2013-08-08js將列表組裝成樹結(jié)構(gòu)的兩種實現(xiàn)方式分享
最近做的任務(wù)提了新的需求,需要實現(xiàn)一個樹形結(jié)構(gòu),所以下面這篇文章主要給大家介紹了關(guān)于js將列表組裝成樹結(jié)構(gòu)的兩種實現(xiàn)方式,需要的朋友可以參考下2022-01-01JS使用Expires?max-age判斷緩存過期的瀏覽器實例
這篇文章主要為大家介紹了JS使用Expires?max-age判斷緩存過期的瀏覽器實例,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-11-11