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

現今的網路應用程式可能會變得相當龐大,尤其是 JavaScript 部分。截至 2018 年中旬,HTTP Archive 將行動裝置上 JavaScript 的平均傳輸大小定為約 350 KB。而這只是傳輸大小!透過網路傳送時,JavaScript 通常會經過壓縮,也就是說,在瀏覽器解壓縮後,JavaScript 的實際大小會比原本大上許多。這點很重要,因為就資源處理而言,壓縮並無關聯。即使解壓縮後的 900 KB 大小,已解壓縮的 JavaScript 仍為剖析器和編譯器 900 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 套件是生產版本,代表這是透過擴散效果的最佳化。應用程式專屬套件為 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 提供寶貴意見,協助我們大幅提升本文品質。