搖樹:參考指南
已發表: 2022-03-10在開始學習什麼是 tree-shaking 以及如何為成功做好準備之前,我們需要了解 JavaScript 生態系統中的模塊。
從早期開始,JavaScript 程序的複雜性和它們執行的任務數量都在增長。 將這些任務劃分為封閉的執行範圍的需求變得很明顯。 這些任務或值的隔間就是我們所說的模塊。 它們的主要目的是防止重複並利用可重用性。 因此,架構被設計為允許這種特殊類型的範圍,公開它們的價值和任務,並使用外部價值和任務。
為了更深入地了解模塊是什麼以及它們是如何工作的,我推薦“ES Modules: A Cartoon Deep-Dive”。 但是要理解 tree-shaking 和模塊消耗的細微差別,上面的定義就足夠了。
搖樹實際上是什麼意思?
簡單地說,tree-shaking 意味著從包中刪除無法訪問的代碼(也稱為死代碼)。 正如 Webpack 版本 3 的文檔所述:
“你可以把你的應用想像成一棵樹。 您實際使用的源代碼和庫代表了這棵樹的綠色、活生生的葉子。 死代碼代表秋天消耗掉的棕色枯葉。 為了擺脫枯葉,你必須搖動樹,讓它們倒下。”
該術語最早由 Rollup 團隊在前端社區推廣。 但是所有動態語言的作者早在很久以前就一直在努力解決這個問題。 搖樹算法的想法至少可以追溯到 1990 年代初期。
在 JavaScript 領域,自 ES2015 中的 ECMAScript 模塊 (ESM) 規範(以前稱為 ES6)以來,tree-shaking 已經成為可能。 從那時起,大多數捆綁器默認啟用了tree-shaking,因為它們在不改變程序行為的情況下減少了輸出大小。
主要原因是 ESM 本質上是靜態的。 讓我們剖析一下這意味著什麼。
ES 模塊與 CommonJS
CommonJS 比 ESM 規範早了幾年。 它旨在解決 JavaScript 生態系統中對可重用模塊缺乏支持的問題。 CommonJS 有一個require()
函數,它根據提供的路徑獲取外部模塊,並在運行時將其添加到作用域中。
與程序中的任何其他函數一樣, require
是一個function
,這使得在編譯時評估其調用結果變得足夠困難。 最重要的是,可以在代碼的任何地方添加require
調用——包裝在另一個函數調用中,在 if/else 語句中,在 switch 語句中,等等。
隨著 CommonJS 架構的廣泛採用所帶來的學習和奮鬥,ESM 規範已經確定了這種新架構,其中模塊通過相應的關鍵字import
和export
導入和導出。 因此,不再有函數調用。 ESM 也只允許作為頂級聲明——不可能將它們嵌套在任何其他結構中,因為它們是靜態的:ESM 不依賴於運行時執行。
範圍和副作用
然而,為了避免膨脹,搖樹必須克服另一個障礙:副作用。 當一個函數改變或依賴於執行範圍之外的因素時,它被認為具有副作用。 具有副作用的函數被認為是不純的。 純函數將始終產生相同的結果,無論上下文或運行它的環境如何。
const pure = (a:number, b:number) => a + b const impure = (c:number) => window.foo.number + c
打包器通過盡可能多地評估提供的代碼來確定模塊是否是純的,從而達到其目的。 但是編譯時或捆綁時的代碼評估只能到此為止。 因此,假設即使在完全無法訪問的情況下,也無法正確消除具有副作用的包。
正因為如此,捆綁器現在接受模塊的package.json
文件中的密鑰,允許開發人員聲明模塊是否沒有副作用。 這樣,開發人員可以選擇退出代碼評估並提示捆綁程序; 如果沒有可訪問的導入或鏈接到它的require
語句,則可以消除特定包中的代碼。 這不僅使包更精簡,而且還可以加快編譯時間。
{ "name": "my-package", "sideEffects": false }
因此,如果您是包開發人員,請在發布之前認真使用sideEffects
,當然,每次發佈時都要對其進行修改,以避免任何意外的重大更改。
除了根sideEffects
鍵之外,還可以通過在方法調用中註釋內聯註釋/*@__PURE__*/
來逐個文件確定純度。
const x = */@__PURE__*/eliminated_if_not_called()
我認為這個內聯註釋是消費者開發人員的一個逃生口,如果包沒有聲明sideEffects: false
或者庫確實對特定方法產生副作用,則需要這樣做。
優化 Webpack
從版本 4 開始,Webpack 需要越來越少的配置來獲得最佳實踐。 幾個插件的功能已合併到核心中。 而且由於開發團隊非常重視包的大小,他們使 tree-shaking 變得容易。
如果您不是一個修補匠,或者如果您的應用程序沒有特殊情況,那麼搖樹依賴項只需一行。
webpack.config.js
文件有一個名為mode
的根屬性。 只要此屬性的值為production
,它將搖樹並充分優化您的模塊。 除了使用TerserPlugin
消除死代碼之外, mode: 'production'
將為模塊和塊啟用確定性的錯位名稱,並將激活以下插件:
- 標記依賴項使用,
- 標誌包括塊,
- 模塊連接,
- 沒有發出錯誤。
觸發值是production
並非偶然。 您不希望在開發環境中完全優化您的依賴項,因為這會使問題更難調試。 所以我建議用兩種方法之一來解決它。
一方面,您可以將mode
標誌傳遞給 Webpack 命令行界面:
# This will override the setting in your webpack.config.js webpack --mode=production
或者,您可以在webpack.config.js
中使用process.env.NODE_ENV
變量:
mode: process.env.NODE_ENV === 'production' ? 'production' : development
在這種情況下,您必須記住在部署管道中傳遞--NODE_ENV=production
。
這兩種方法都是 Webpack 版本 3 及更低版本中廣為人知的definePlugin
之上的抽象。 您選擇哪個選項完全沒有區別。
Webpack 版本 3 及以下
值得一提的是,本節中的場景和示例可能不適用於最新版本的 Webpack 和其他打包程序。 本節考慮使用 UglifyJS 版本 2,而不是 Terser。 UglifyJS 是 Terser 派生出來的包,因此它們之間的代碼評估可能不同。

因為 Webpack 版本 3 及以下版本不支持package.json
中的sideEffects
屬性,所以在消除代碼之前必須對所有包進行完全評估。 僅這一點就使該方法不太有效,但也必須考慮幾個警告。
如上所述,編譯器無法自行發現包何時篡改全局範圍。 但這不是它跳過搖樹的唯一情況。 還有更模糊的場景。
從 Webpack 的文檔中獲取這個包示例:
// transform.js import * as mylib from 'mylib'; export const someVar = mylib.transform({ // ... }); export const someOtherVar = mylib.transform({ // ... });
這是消費者捆綁包的入口點:
// index.js import { someVar } from './transforms.js'; // Use `someVar`...
無法確定mylib.transform
是否會引發副作用。 因此,不會消除任何代碼。
以下是具有類似結果的其他情況:
- 從編譯器無法檢查的第三方模塊調用函數,
- 重新導出從第三方模塊導入的函數。
babel-plugin-transform-imports 可以幫助編譯器進行 tree-shaking 工作。 它將所有成員和命名導出拆分為默認導出,允許單獨評估模塊。
// before transformation import { Row, Grid as MyGrid } from 'react-bootstrap'; import { merge } from 'lodash'; // after transformation import Row from 'react-bootstrap/lib/Row'; import MyGrid from 'react-bootstrap/lib/Grid'; import merge from 'lodash/merge';
它還有一個配置屬性,警告開發人員避免麻煩的導入語句。 如果您使用的是 Webpack 版本 3 或更高版本,並且您已經對基本配置進行了盡職調查並添加了推薦的插件,但您的包仍然看起來臃腫,那麼我建議您嘗試一下這個包。
範圍提升和編譯時間
在 CommonJS 時代,大多數打包工具會簡單地將每個模塊包裝在另一個函數聲明中,並將它們映射到一個對像中。 這與那裡的任何地圖對像沒有什麼不同:
(function (modulesMap, entry) { // provided CommonJS runtime })({ "index.js": function (require, module, exports) { let { foo } = require('./foo.js') foo.doStuff() }, "foo.js": function(require, module, exports) { module.exports.foo = { doStuff: () => { console.log('I am foo') } } } }, "index.js")
除了難以靜態分析之外,這從根本上與 ESM 不兼容,因為我們已經看到我們無法包裝import
和export
語句。 因此,如今,捆綁器將每個模塊提升到頂層:
// moduleA.js let $moduleA$export$doStuff = () => ({ doStuff: () => {} }) // index.js $moduleA$export$doStuff()
這種方法與 ESM 完全兼容; 另外,它允許代碼評估輕鬆地發現沒有被調用的模塊並刪除它們。 這種方法的警告是,在編譯期間,它需要更多的時間,因為它會在過程中觸及每條語句並將包存儲在內存中。 這就是為什麼捆綁性能已成為每個人都更加關注的一個重要原因,也是為什麼編譯語言被用於 Web 開發工具的一個重要原因。 例如,esbuild 是一個用 Go 編寫的打包器,SWC 是一個用 Rust 編寫的 TypeScript 編譯器,它與 Spark 集成,Spark 也是一個用 Rust 編寫的打包器。
為了更好地理解範圍提升,我強烈推薦 Parcel 版本 2 的文檔。
避免過早的轉譯
不幸的是,有一個特定的問題相當普遍,並且可能對搖樹造成破壞性影響。 簡而言之,當您使用特殊的加載器,將不同的編譯器集成到您的捆綁器時,就會發生這種情況。 常見的組合是 TypeScript、Babel 和 Webpack——在所有可能的排列中。
Babel 和 TypeScript 都有自己的編譯器,它們各自的加載器允許開發人員使用它們,以便於集成。 隱藏的威脅就在其中。
這些編譯器會在代碼優化之前到達您的代碼。 而且無論是默認還是錯誤配置,這些編譯器通常會輸出 CommonJS 模塊,而不是 ESM。 如前一節所述,CommonJS 模塊是動態的,因此無法正確評估死代碼消除。
隨著“同構”應用程序(即在服務器端和客戶端運行相同代碼的應用程序)的增長,這種情況如今變得更加普遍。 因為 Node.js 還沒有對 ESM 的標準支持,所以當編譯器針對node
環境時,它們會輸出 CommonJS。
因此,請務必檢查您的優化算法正在接收的代碼。
搖樹檢查清單
現在您已經了解了捆綁和 tree-shaking 如何工作的細節,讓我們自己繪製一個清單,當您重新訪問當前的實現和代碼庫時,您可以將其打印在方便的地方。 希望這可以節省您的時間,讓您不僅可以優化代碼的感知性能,還可以優化管道的構建時間!
- 使用 ESM,不僅在您自己的代碼庫中,而且還支持將 ESM 輸出為消耗品的軟件包。
- 確保您確切知道哪些(如果有)依賴項沒有聲明
sideEffects
或將它們設置為true
。 - 在使用帶有副作用的包時,使用內聯註釋來聲明純粹的方法調用。
- 如果您要輸出 CommonJS 模塊,請確保在轉換導入和導出語句之前優化您的包。
包創作
希望到此為止,我們都同意 ESM 是 JavaScript 生態系統的前進方向。 但是,與軟件開發中的往常一樣,轉換可能很棘手。 幸運的是,包作者可以採取非破壞性措施來促進其用戶的快速無縫遷移。
通過對package.json
的一些小添加,您的包將能夠告訴打包者該包支持的環境以及如何最好地支持它們。 這是 Skypack 的清單:
- 包括 ESM 導出。
- 添加
"type": "module"
。 - 通過
"module": "./path/entry.js"
(社區約定)。
下面是一個示例,當您遵循所有最佳實踐並且您希望同時支持 Web 和 Node.js 環境時會產生結果:
{ // ... "main": "./index-cjs.js", "module": "./index-esm.js", "exports": { "require": "./index-cjs.js", "import": "./index-esm.js" } // ... }
除此之外,Skypack 團隊還引入了包裹質量分數作為基準,以確定給定包裹是否設置為長壽和最佳實踐。 該工具在 GitHub 上開源,可以作為devDependency
添加到您的包中,以便在每次發布之前輕鬆執行檢查。
包起來
我希望這篇文章對你有用。 如果是這樣,請考慮與您的網絡共享它。 我期待在評論或 Twitter 上與您互動。
有用的資源
文章和文檔
- “ES Modules: A Cartoon Deep-Dive”,Lin Clark,Mozilla Hacks
- “搖樹”,Webpack
- “配置”,Webpack
- “優化”,Webpack
- “Scope Hoisting”,Parcel 版本 2 的文檔
項目和工具
- 泰瑟
- babel-plugin-transform-imports
- 天空背包
- 網頁包
- 包裹
- 捲起
- esbuild
- SWC
- 包裹檢查