運用樹狀搖動功能減少 JavaScript 酬載

現今的網路應用程式可能相當龐大,尤其是 JavaScript 部分。根據 HTTP Archive 的資料,截至 2018 年中,行動裝置上 JavaScript 的中位數傳輸大小約為 350 KB。這還只是移轉大小!透過網路傳送 JavaScript 時,通常會經過壓縮,因此瀏覽器解壓縮後,實際的 JavaScript 數量會多出不少。請務必注意這一點,因為就資源「處理」而言,壓縮並不重要。即使壓縮後可能只有約 300 KB,但對剖析器和編譯器來說,900 KB 的解壓縮 JavaScript 仍是 900 KB。

這張圖表說明下載、解壓縮、剖析、編譯及執行 JavaScript 的流程。
下載及執行 JavaScript 的程序。請注意,即使指令碼的傳輸大小為 300 KB (已壓縮),仍有 900 KB 的 JavaScript 必須剖析、編譯及執行。

處理 JavaScript 資源的成本很高。圖片下載後只需解碼,但 JavaScript 必須經過剖析、編譯,最後才能執行。因此,JavaScript 的費用會比其他類型的資源高。

這張圖表比較了 170 KB 的 JavaScript 與大小相當的 JPEG 圖片的處理時間。相較於 JPEG,JavaScript 資源的每個位元組會耗用更多資源。
剖析/編譯 170 KB JavaScript 的處理成本,與解碼大小相當的 JPEG 所需時間。(來源)。

雖然我們不斷提升 JavaScript 引擎的效率,但改善 JavaScript 效能仍是開發人員的任務。

為此,我們提供一些提升 JavaScript 效能的技巧。程式碼分割就是其中一種技術,可將應用程式 JavaScript 分割成多個區塊,並只將這些區塊提供給需要這些區塊的應用程式路徑,藉此提升效能。

雖然這項技術可行,但無法解決 JavaScript 密集型應用程式的常見問題,也就是納入從未使用過的程式碼。Tree shaking (移除沒用到的程式碼) 會嘗試解決這個問題。

什麼是 tree shaking (移除沒用到的程式碼)?

搖樹是一種無效程式碼淘汰作業。這個詞彙是由 Rollup 普及,但消除無用程式碼的概念已存在一段時間。這個概念也已在 webpack 中獲得採用,本文會透過範例應用程式進行示範。

「tree shaking (移除沒用到的程式碼)」一詞源自應用程式及其依附元件的心理模型,也就是樹狀結構。樹狀結構中的每個節點都代表依附元件,可為應用程式提供不同的功能。在現代應用程式中,這些依附元件是透過靜態 import 陳述式引入,如下所示:

// Import all the array utilities!
import arrayUtils from "array-utils";

應用程式剛推出時,可能只有少數依附元件。此外,它也會使用您新增的大部分 (甚至全部) 依附元件。不過,隨著應用程式日趨成熟,可能會新增更多依附元件。更糟的是,舊版依附元件會遭到淘汰,但可能不會從程式碼集修剪掉。最終導致應用程式隨附大量未使用的 JavaScript。tree shaking (移除沒用到的程式碼) 功能會利用靜態 import 陳述式提取 ES6 模組特定部分的方式,解決這個問題:

// Import only some of the utilities!
import { unique, implode, explode } from "array-utils";

這個 import 範例與上一個範例的差異在於,這個範例只會匯入 "array-utils" 模組的特定部分,而不是從該模組匯入所有內容 (這可能包含大量程式碼)。在開發版本中,這不會有任何變更,因為系統一律會匯入整個模組。在正式版建構作業中,您可以設定 webpack「移除」未明確匯入的 ES6 模組匯出內容,縮小正式版建構作業的規模。這份指南將說明如何達成上述目標!

尋找搖晃樹木的機會

為說明 tree shaking (移除沒用到的程式碼) 功能,我們提供單頁應用程式範例,您可以視需要複製這個範例,但本指南會逐步說明每個步驟,因此不必複製 (除非您喜歡實作練習)。

這個範例應用程式是吉他效果踏板的可搜尋資料庫。輸入查詢內容後,系統會顯示效果踏板清單。

螢幕截圖:單頁應用程式範例,用於搜尋吉他效果踏板資料庫。
範例應用程式的螢幕截圖。

驅動這項應用程式的行為會分成供應商 (即 PreactEmotion) 和應用程式專屬的程式碼組合 (或 webpack 稱呼的「區塊」):

螢幕截圖:Chrome 開發人員工具的網路面板中顯示兩個應用程式程式碼組合 (或區塊)。
應用程式的兩個 JavaScript 組合。這些是未壓縮的大小。

上圖顯示的 JavaScript 組合是正式版建構作業,也就是透過醜化作業進行最佳化。應用程式專屬套件的 21.1 KB 並不算差,但請注意,完全沒有發生 tree shaking (移除沒用到的程式碼)。讓我們查看應用程式程式碼,看看如何修正這個問題。

在任何應用程式中,尋找樹狀結構重整機會都涉及尋找靜態 import 陳述式。在主要元件檔案頂端附近,您會看到類似這樣的程式碼行:

import * as utils from "../../utils/utils";

您可以透過多種方式匯入 ES6 模組,但這類模組應特別留意。這行程式碼的具體意義是「從 utils 模組匯入所有內容,並放在名為 utils 的命名空間中」。這裡要問的重點是「該模組中有多少內容?」import

查看 utils 模組的原始碼,您會發現大約有 1,300 行程式碼。

需要所有這些東西嗎?請搜尋匯入 utils 模組的主要元件檔案,檢查該命名空間出現的次數。

螢幕截圖:在文字編輯器中搜尋「utils.」,只傳回 3 個結果。
我們從中匯入大量模組的 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 行檔案中,只會使用其中一個。這會導致傳送大量未使用的 JavaScript。

雖然這個範例應用程式有點牽強,但這類合成情境與您在正式版網頁應用程式中可能遇到的實際最佳化機會類似,這點不會改變。現在您已找出適合使用 tree shaking (移除沒用到的程式碼) 的機會,那麼實際該怎麼做呢?

避免 Babel 將 ES6 模組轉譯為 CommonJS 模組

Babel 是不可或缺的工具,但可能會讓 tree shaking (移除沒用到的程式碼) 的效果更難觀察。如果您使用 @babel/preset-env,Babel 可能會將 ES6 模組轉換為相容性較高的 CommonJS 模組,也就是您 require 而不是 import 的模組。

由於 CommonJS 模組更難進行 tree shaking (移除沒用到的程式碼),因此如果您決定使用這些模組,webpack 就無法判斷要從套件中修剪哪些內容。解決方法是設定 @babel/preset-env,明確排除 ES6 模組。無論您是在 babel.config.jspackage.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 模組,這在 tree shaking (移除沒用到的程式碼) 的脈絡中非常重要。如果模組採用可預測的輸入內容,並產生同樣可預測的輸出內容,且不會修改自身範圍以外的任何項目,那麼如果我們未使用這些模組,就能安全地捨棄這些依附元件。這些是獨立的模組化程式碼片段,因此稱為「模組」。

就 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);
}

在本範例中,tree shaking (移除沒用到的程式碼) 功能應該只需要這些設定。以下是依附元件樹狀結構經過篩選前的 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

以下是 tree shaking (移除沒用到的程式碼) 成功後的輸出內容:

                 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 MillerAddy OsmaniJeff Posnick、Sam Saccone 和 Philip Walton 提供寶貴意見,大幅提升本文品質。