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

現今的網路應用程式可能會變得相當龐大,尤其是 JavaScript 部分。截至 2018 年中旬,HTTP Archive 將行動裝置上的 JavaScript 傳輸大小中位數定為約 350 KB。而這只是傳輸大小!透過網路傳送時,JavaScript 通常會經過壓縮,也就是說,在瀏覽器解壓縮後,JavaScript 的實際數量會增加不少。這點很重要,因為就資源處理而言,壓縮並無關聯。900 KB 的解壓縮 JavaScript 對剖析器和編譯器來說仍是 900 KB,即使壓縮後可能會縮減到約 300 KB。

說明下載、解壓縮、剖析、編譯及執行 JavaScript 的流程圖。
下載及執行 JavaScript 的程序。請注意,即使轉移的腳本壓縮後大小為 300 KB,仍須剖析、編譯及執行 900 KB 的 JavaScript。

JavaScript 是處理成本高昂的資源。與圖片不同,下載後只會產生相對輕微的解碼時間,JavaScript 則必須經過剖析、編譯,最後才會執行。因此,JavaScript 的費用會比其他類型的資源高。

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

雖然我們持續改善JavaScript 引擎的效率,但改善 JavaScript 效能一如往常,仍是開發人員的責任。

為此,我們提供一些可改善 JavaScript 效能的技巧。程式碼分割是這類技術之一,它會將應用程式 JavaScript 分割成多個區塊,並只將這些區塊提供給需要的應用程式路徑。

雖然這項技巧可行,但無法解決 JavaScript 密集應用程式常見的問題,也就是包含從未使用的程式碼。樹狀圖整理功能會嘗試解決這個問題。

什麼是搖樹?

搖樹是一種無效程式碼消除方式。這個詞彙是由 Rollup 廣為人知,但已淘汰的程式碼概念已存在一段時間。這個概念也適用於 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 模組中的匯出內容,讓這些正式版建構作業變得更小。本指南將說明如何達成這項目標!

尋找搖樹商機

為了說明這個概念,我們提供單頁應用程式範例,說明樹狀圖搖晃功能的運作方式。您可以複製該專案並按照步驟操作,但我們會在本指南中逐一介紹每個步驟,因此不必複製 (除非您想透過實作學習)。

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

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

驅動這個應用程式的行為會分為供應商 (即PreactEmotion) 以及應用程式專屬的程式碼套件 (或稱「區塊」,這是 webpack 的稱呼):

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

上圖所示的 JavaScript 套件是正式版版本,也就是經過 uglification 最佳化的版本。應用程式專屬套件的大小為 21.1 KB,這還算不錯,但請注意,樹狀圖不會發生任何搖晃。我們來看看應用程式程式碼,看看可以採取哪些措施來修正這個問題。

在任何應用程式中,尋找樹狀圖搖晃機會都會涉及尋找靜態 import 陳述式。在主要元件檔案的頂端附近,您會看到類似以下的程式碼行:

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

您可以以多種方式匯入 ES6 模組,但您應該要留意這類模組。這行程式碼表示「importutils 模組取得所有內容,並將其放入名為 utils 的命名空間。」這裡的問題是「模組中究竟有多少內容?」

查看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。

雖然這個範例應用程式有點刻意為之,但這類合成情境與實際在正式版網頁應用程式中遇到的最佳化機會相似。現在您已找出樹狀圖搖晃的實用機會,那麼實際上該如何執行呢?

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

Babel 是不可或缺的工具,但可能會讓樹木搖晃的效果不易觀察。如果您使用 @babel/preset-env,Babel 可能會將 ES6 模組轉換為相容性更廣泛的 CommonJS 模組,也就是您 require 而非 import 的模組。

由於 CommonJS 模組的樹狀圖搖晃動作較為複雜,因此如果您決定使用套件,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 模組,這在樹狀圖搖晃的情況下相當重要。模組會接收可預測的輸入內容,並產生同樣可預測的輸出內容,且不會修改其自身範圍以外的任何內容,如果我們未使用這些模組,就可以安全地捨棄這些依附元件。這些是獨立的模組化程式碼片段。因此稱為「模組」。

在 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"
  ]
}

在後一個範例中,系統會假設任何未指定的檔案都沒有副作用。如果不想將這個 flag 新增至 package.json 檔案,您也可以透過 module.rules 在 webpack 設定中指定這個 flag。

只匯入必要內容

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