現今的網頁應用程式似乎相當龐大,尤其是其中的 JavaScript 部分。自 2018 年中起,HTTP 封存檔將行動裝置上的 JavaScript 傳輸中位數大小為約 350 KB。這只是轉乘大小!JavaScript 通常是在透過網路傳送時經過壓縮,也就是說,JavaScript 的「實際」數量在瀏覽器解壓縮後會比較長。這裡的重點是,就資源處理而言,壓縮並不相關。對剖析器和編譯器的 900 KB 仍有 900 KB,即使壓縮後可能約為 300 KB。
JavaScript 的處理資源相當昂貴。圖片下載後只會產生相對簡單的解碼時間,但是 JavaScript 必須先剖析、編譯並執行最終執行。位元代表位元組,因此 JavaScript 比其他類型的資源要高。
![這張圖表比較 170 KB 和大小相等的 JPEG 圖片處理時間。與 JPEG 相比,JavaScript 資源的位元組耗用的資源會大幅增加。](https://web.developers.google.cn/static/articles/reduce-javascript-payloads-with-tree-shaking/image/a-diagram-comparing-proc-5b49bd91e7285.png?authuser=2&hl=zh-tw)
我們會持續不斷改善,改善 JavaScript 引擎的效率,一如以往,改善 JavaScript 效能是開發人員的任務。
因此,一些技巧可以改善 JavaScript 效能。「程式碼分割」是其中一種技巧,可將應用程式 JavaScript 分割成多個區塊,並將這些區塊僅提供給需要這些區塊的應用程式路徑,藉此提高效能。
雖然這項技巧能夠運作,卻不支援處理大量 JavaScript 應用程式的常見問題,因為其中包含從未使用的程式碼。搖晃的樹根會嘗試解決這個問題。
什麼是樹晃?
樹木搖晃是一種清除無效程式碼的方式。「字詞匯總」很常用,但這個概念已存在一段時間。這個概念也透過 webpack 的方式發現,本文透過範例應用程式的方式示範。
「樹木搖晃」一詞取自應用程式的心理模型,及其依附元件是類似樹狀結構的結構。樹狀結構中的每個節點都代表可為應用程式提供獨特功能的依附元件。在現代應用程式中,這些依附元件是透過靜態 import
陳述式導入,如下所示:
// Import all the array utilities!
import arrayUtils from "array-utils";
如果應用程式較年幼,依附元件可能很少,也會使用大多數 (甚至不是全部) 新增的依附元件。不過,隨著應用程式日趨成熟,您可以新增更多依附元件。為化繁為簡,舊版依附元件不會使用,但可能不會縮減程式碼集。最終是因為應用程式最終會透過許多未使用的 JavaScript 運送。如要解決這個問題,請使用靜態 import
陳述式提取 ES6 模組的特定部分:
// Import only some of the utilities!
import { unique, implode, explode } from "array-utils";
這個 import
範例與上一個範例的差異在於,系統不會從 "array-utils"
模組 (可能是大量程式碼) 匯入「所有內容」,這個範例只會匯入其中的特定部分。在開發版本中,這不會改變任何內容,因為無論匯入整個模組為何。在正式版版本中,您可以將 webpack 設定為從 ES6 模組未明確匯入的匯出中「搖動」,使這些正式版更小。本指南將說明如何達成目標!
尋找搖晃樹的機會
為了進行說明,我們提供單頁應用程式範例 (示範樹晃的運作方式)。你可以複製連結,並視需要跟著說明操作,但本指南將逐一說明,因此你並不需要複製資料 (除非你只是動手練習)。
範例應用程式是可搜尋的吉他效應踏板資料庫,只要輸入查詢,畫面上就會顯示動作跳躍動作清單。
![用於搜尋吉他效應踏板資料庫的範例應用程式螢幕截圖。](https://web.developers.google.cn/static/articles/reduce-javascript-payloads-with-tree-shaking/image/a-screenshot-a-sample-p-c7096b568d49b.png?authuser=2&hl=zh-tw)
驅動這個應用程式的行為會分為各供應商 (即Preact 和 Emotion) 和應用程式專屬的程式碼組合 (或稱「區塊」,因為 Webpack 會呼叫這類程式碼):
![Chrome 開發人員工具網路面板中顯示的兩個應用程式碼組合 (或區塊) 的螢幕截圖。](https://web.developers.google.cn/static/articles/reduce-javascript-payloads-with-tree-shaking/image/a-screenshot-two-applica-13160017c55f2.png?authuser=2&hl=zh-tw)
上圖顯示的 JavaScript 套件是正式環境版本,意味著這些套件經過即時最佳化處理。應用程式專屬套件為 21.1 KB 是不嚴重的,但請注意,沒有任何樹木正在晃動。讓我們查看應用程式的程式碼,看看要如何修正這個問題。
在任何應用程式中,尋找樹林時,都需要尋找靜態的 import
陳述式。主要元件檔案頂端附近會顯示一行如下:
import * as utils from "../../utils/utils";
您可以以多種方式匯入 ES6 模組,但這類模組應該對您有所掌握。這一行寫著「import
所有內容 (來自 utils
模組),並放在名為 utils
的命名空間中。這時最重要的問題就是:「該模組有多少東西?」
您可以查看 utils
模組原始碼,其中有大約 1,300 行的程式碼。
你需要所有這類功能嗎?讓我們仔細確認,搜尋匯入 utils
模組的主要元件檔案,看看該命名空間出現了多少執行個體。
![「utils.」文字編輯器中的搜尋螢幕截圖,僅傳回 3 筆結果。](https://web.developers.google.cn/static/articles/reduce-javascript-payloads-with-tree-shaking/image/a-screenshot-a-search-a-b9c933e924ee5.png?authuser=2&hl=zh-tw)
utils
命名空間中,只會在主要元件檔案中叫用三次。
後來,utils
命名空間只會在我們應用程式中的三個位置出現,但哪些函式的功能又是如何?如果您再次查看主要元件檔案,該函式似乎只有一個函式,而 utils.simpleSort
函式會在排序下拉式選單變更時,用於依照多種條件排序搜尋結果清單:
if (this.state.sortBy === "model") {
// `simpleSort` gets used here...
json = utils.simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
// ..and here...
json = utils.simpleSort(json, "type", this.state.sortOrder);
} else {
// ..and here.
json = utils.simpleSort(json, "manufacturer", this.state.sortOrder);
}
總共有 1,300 個包含大量匯出作業的 1, 300 行檔案,系統只會使用其中一個。進而傳送許多未使用的 JavaScript。
儘管這個應用程式的表現可能有些不足,但這並未改變這種合成情境與實際在正式版網頁應用程式中遇到的實際最佳化機會。現在,您已經找出一個可以發掘樹景的契機,實際上是如何完成呢?
避免 Babel 將 ES6 模組轉譯至 CommonJS 模組
Babel 是不可或缺的工具,但可能會影響觀察樹木的晃動效果。如果您使用的是 @babel/preset-env
,Babel 可以將 ES6 模組轉換為更廣泛相容的 CommonJS 模組,也就是您 require
而非 import
的模組。
由於 CommonJS 模組較難執行樹狀圖,所以當您決定使用 Webpack 時,Webpack 會不知道該從套件中去掉什麼。解決方法是將 @babel/preset-env
設為明確保留 ES6 模組。無論您在 babel.config.js
或 package.json
中設定 Babel 的位置,都需要額外新增一些內容:
// babel.config.js
export default {
presets: [
[
"@babel/preset-env", {
modules: false
}
]
]
}
在 @babel/preset-env
設定中指定 modules: false
,可讓 Babel 達到預期的行為,讓 Webpack 能夠分析您的依附關係樹狀結構,並搖動未使用的依附元件。
留意副作用
從應用程式搖動依附元件時,需要考量的另一個方面是專案模組是否會產生副作用。連帶效果的其中一個例子是函式修改了其範圍以外的項目,這就是其執行作業的副作用:
let fruits = ["apple", "orange", "pear"];
console.log(fruits); // (3) ["apple", "orange", "pear"]
const addFruit = function(fruit) {
fruits.push(fruit);
};
addFruit("kiwi");
console.log(fruits); // (4) ["apple", "orange", "pear", "kiwi"]
在本例中,addFruit
在修改 fruits
陣列超出範圍時,會產生副作用。
副作用也適用於 ES6 模組,在樹晃的情境下尤其重要。如果模組接受可預測的輸入內容,且產生可預測的輸出內容,且不會修改其範圍外的任何項目,這些模組就是可以放心捨棄的模組,即使不使用這類模組,也能放心捨棄這些模組。是獨立的模組程式碼。因此「模組」。
在對 Webpack 有疑慮的情況下,您可以使用提示,在專案的 package.json
檔案中指定 "sideEffects": false
,指出套件及其依附元件沒有任何副作用:
{
"name": "webpack-tree-shaking-example",
"version": "1.0.0",
"sideEffects": false
}
或者,您也可以告知 Webpack 哪些特定檔案不會產生副作用:
{
"name": "webpack-tree-shaking-example",
"version": "1.0.0",
"sideEffects": [
"./src/utils/utils.js"
]
}
在後者的例子中,未指定的所有檔案都會被認定為沒有任何副作用。如果不想在 package.json
檔案中加入這個屬性,您也可以透過 module.rules
在 Webpack 設定中指定。
僅匯入所需內容
指示 Babel 單獨保留 ES6 模組後,我們只需要稍微調整 import
語法,即可只導入 utils
模組中所需的函式。在本指南的範例中,您只需要使用 simpleSort
函式:
import { simpleSort } from "../../utils/utils";
由於系統只會匯入 simpleSort
,而非整個 utils
模組,因此所有 utils.simpleSort
執行個體都必須變更為 simpleSort
:
if (this.state.sortBy === "model") {
json = simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
json = simpleSort(json, "type", this.state.sortOrder);
} else {
json = simpleSort(json, "manufacturer", this.state.sortOrder);
}
這個範例應該只提供搖動樹景所需的一切,以下是搖動依附元件樹狀結構之前的 Webpack 輸出內容:
Asset Size Chunks Chunk Names
js/vendors.16262743.js 37.1 KiB 0 [emitted] vendors
js/main.797ebb8b.js 20.8 KiB 1 [emitted] main
以下為樹狀結構搖動「後」的輸出內容:
Asset Size Chunks Chunk Names
js/vendors.45ce9b64.js 36.9 KiB 0 [emitted] vendors
js/main.559652be.js 8.46 KiB 1 [emitted] main
雖然這兩個套件的順序都相同,但這是最優勢的 main
套裝組合。透過搖動 utils
模組中未使用的部分,main
套件可縮減約 60%。這樣不僅能減少指令碼下載所需時間,也會拉長處理時間。
搖一些樹!
使用樹晃時產生的里程數取決於您的應用程式、依附元件和架構。點選此按鈕即可體驗這項功能,如果您知道嗎?您其實還沒有設定模組套件來執行這項最佳化作業,不妨試試這項最佳化功能,親身體驗這項最佳化功能為您的應用程式帶來哪些好處吧!
也許您發現樹晃可以大幅提升成效,甚至完全沒有增加。不過,您可以設定建構系統,在正式環境中利用這項最佳化功能,並選擇性地匯入應用程式的需求,藉此主動將應用程式套件保持精簡。
特別感謝 Kristofer Baxter、Jason Miller、Addy Osmani、Jeff Posnick、Sam Saccone 和 Philip Walton 提供的意見回饋,這些意見大幅提升了這篇文章的品質。