過去幾年,我們見證了 e18e 社群的顯著成長,以及越來越多聚焦於效能的貢獻。其中一大重點是「清理」計畫,社群積極剔除冗餘、過時或無人維護的套件。
在這篇文章中,我想簡要探討我認為依賴樹中膨脹的三大主要類型、它們存在的原因,以及我們如何開始解決這些問題。
在許多 npm 依賴樹中,常見的情況是為了實現看似應該由平台原生提供的小工具函式,卻引入了許多類似的小型深層依賴。
為什麼會這樣?為什麼我們需要 is-string 來取代 typeof 檢查?為什麼需要 hasown 來取代 Object.hasOwn 或 Object.prototype.hasOwnProperty?原因有三:
第一,世界上仍有部分人需要支援 ES3,例如 IE6/7 或非常早期的 Node.js 版本。對這些人來說,許多我們現在視為理所當然的功能都不存在,像是 ES5 的特性。
這些使用舊引擎的人必須自行重新實作功能或依賴 polyfill,當然,升級會是更好的選擇。
第二個原因是「安全性」。Node.js 內部有「primordials」概念,這是啟動時包裝的全域物件引用,避免因為有人修改全域命名空間而破壞 Node 本身。例如,如果 Node 使用 Map,但我們重新定義了 Map,可能會導致 Node 崩潰。為避免此問題,Node 會引用原始 Map 而非全域物件。
這對引擎來說非常合理,因為它不應該因為腳本破壞全域命名空間而失效。有些維護者也認為這是建立套件的正確方式,因此出現像是 math-intrinsics 這類重新導出 Math.* 函式以避免被修改的依賴。
最後是跨 realm 的值。這指的是從一個 realm 傳遞到另一個 realm 的值,例如從網頁傳到子 iframe 或反之。
在這種情況下,iframe 中的 new RegExp(pattern) 與父頁面的 RegExp 類別不同,導致 window.RegExp !== iframeWindow.RegExp,因此 val instanceof RegExp 對來自 iframe 的值會是 false。
我本人是 chai 的維護者,我們正面臨這個問題。因為測試執行器可能在 VM 或 iframe 中執行測試,我們不能依賴 instanceof 檢查,而是用 Object.prototype.toString.call(val) === '[object RegExp]' 來判斷,這種方法跨 realm 都有效。
在上方圖表中,is-string 也做了類似的工作,防止從一個 realm 傳到另一個 realm 的 new String(val) 造成問題。
這些對少數人來說是合理的需求。如果你需要支援非常舊的引擎、跨 realm 傳值,或想避免環境被修改,這些套件正是你需要的。
問題是,大多數人並不需要這些。我們使用的是過去十年內的 Node 版本或現代瀏覽器,不需支援 ES5 前的環境,不跨 frame 傳值,也會移除破壞環境的套件。
這些特殊兼容層卻莫名其妙地進入了日常套件的「熱路徑」。真正需要這些功能的少數人應該主動尋找專門套件,結果卻是大家都在承擔成本。
有些人認為套件應拆分到幾乎原子級別,形成一組小型建構模組,方便後續組合成更高階功能。
這種架構導致我們看到像這樣的依賴圖:
最細微的程式碼片段都有自己的套件,例如 shebang-regex 在本文撰寫時的內容非常簡單。
拆分到這種原子級別的理論是,我們可以透過組合這些模組來建立更高階套件。
舉例來說,如果要建立新的 CLI,我們可以拉入幾個這樣的套件,不用自己實作 env['PATH'] || env['Path'],直接用套件即可。
但實際上,這些套件大多沒有成為可重用的建構模組,而是版本重複或僅被單一套件使用的單次套件。
看看這些最細微的套件:
每個只有一個使用者,等同於內嵌程式碼,但卻增加了取得成本(npm 請求、解壓、頻寬等)。
以 nuxt 的依賴樹為例,我們看到一些建構模組被重複使用:
內嵌這些模組不代表不重複程式碼,但至少避免了版本解析、衝突和取得成本。
內嵌讓重複幾乎免費,而套件化則昂貴。
套件越多,供應鏈的風險面越大,每個套件都可能成為維護或安全的潛在問題。
例如,去年一位多個此類套件的維護者遭駭,導致數百個小模組被入侵,進而影響我們實際安裝的高階套件。
像 Array.isArray(val) ? val : [val] 這種簡單邏輯不需要獨立套件,直接內嵌即可避免安全和維護風險。
與第一支柱類似,這種拆分哲學進入了「熱路徑」,但其實不該如此,大家都在付出成本卻無實質效益。
如果你在開發應用程式,可能會想用引擎尚未支援的「未來」功能,這時 polyfill 很有用,提供功能的替代實作,讓你像使用原生功能一樣使用它。
例如 temporal-polyfill 為新 Temporal API 提供 polyfill,無論引擎是否支援都能使用。
但如果你在開發函式庫,該怎麼做?
一般來說,函式庫不該載入 polyfill,因為那是使用者的責任,函式庫不應修改周遭環境。另一種做法是使用 ponyfill。
ponyfill 是不修改環境,而是匯入的 polyfill。
這樣函式庫可以使用未來技術,匯入實作,若原生存在則使用原生,否則使用替代實作,且不改變環境,對函式庫來說安全。
例如 fastly 提供 @fastly/performance-observer-polyfill,包含 PerformanceObserver 的 polyfill 與 ponyfill。
這些 ponyfill 曾經很有用,讓函式庫作者能使用未來技術,且不強迫使用者知道該裝哪些 polyfill。
問題是,當這些 ponyfill 所填補的功能已被所有重要引擎支援後,ponyfill 應該移除,但通常不會,導致許多套件仍依賴已過時的 ponyfill。
除非這些套件因第一支柱仍需維持,否則多半是沒人想到要移除。
當所有長期支援版本的引擎都有該功能時,ponyfill 就該被移除。
這些膨脹多半深藏在依賴樹深處,要徹底清理相當費力,需要維護者與使用者共同努力。
不過我相信只要大家一起努力,能取得顯著進展。
開始問自己:「我為什麼要有這個套件?」「我真的需要它嗎?」
如果發現冗餘,向維護者提出移除請求。
若直接依賴有問題,尋找不含這些問題的替代品,module-replacements 專案是個好起點。
knip 是個很棒的工具,能幫助你找出並移除未使用的依賴、死碼等,是清理依賴樹的好幫手。
這不一定能解決所有問題,但能作為清理的起點。
e18e CLI 有分析模式,能判斷哪些依賴不再需要或有社群推薦替代品。
例如你會看到類似訊息,快速辨識可清理的直接依賴,並用 migrate 指令自動遷移,如從 chalk 換成更小的 picocolors。
未來此 CLI 甚至會根據你的環境推薦,例如在新版 Node 可用原生 styleText 取代顏色函式庫。
npmgraph 是個視覺化工具,能幫你檢視依賴樹並找出膨脹來源。
以 ESLint 依賴圖為例,find-up 分支是孤立的,沒有其他套件使用其深層依賴。對於簡單的向上檔案系統遍歷,可能不需要六個套件,可以找更小的替代品,如 empathic。
module replacements 專案作為社群中心資料庫,紀錄哪些套件可被原生功能或更高效替代品取代。
需要替代品或想檢查依賴時,這資料庫非常有用。
同時,該專案也有 codemods,能自動將套件遷移到建議替代品。
我們都在為少數人喜歡的特殊架構或向後兼容性付出代價。
這並非套件作者的錯,他們有權依自己想法開發。許多作者是早期具影響力的 JavaScript 開發者,當時許多現今便利的 API 和跨平台兼容尚不存在,他們的設計當時可能是最佳選擇。
問題是我們從未真正前進,現在仍下載這些膨脹套件,儘管這些功能已存在多年。
我認為解決方法是反向思考。這小部分人應承擔成本,擁有自己專用的堆疊,其他人則使用現代、輕量且廣泛支援的程式碼。
希望 e18e 和 npmx 等專案能透過文件與工具協助實現。你也可以從檢視自己的依賴開始,問「為什麼?」並向維護者提出疑問。
我相信仍有人需要舊引擎,但也希望看到一些示範。
大部分膨脹源自當時平台功能不足,當時的架構決策可能是正確的。
大多數支援年限資料來自 MDN,或若早於 MDN,則來自兼容性資料。
關於「ponyfill」的話題仍未定論,我認為達成 LTS 後應該移除,但有人希望永久保留。