1. 程式人生 > >JavaScript效能優化之搖樹

JavaScript效能優化之搖樹

作者|Jeremy Wagner譯者|薛命燈

現代 Web 應用程式可能會變得非常巨大,特別是它們的 JavaScript 部分。HTTP Archive 網站的資料顯示,截至 2018 年中,傳輸到移動裝置上的 JavaScript 檔案中值大約為 350 KB。而這只是傳輸大小,JavaScript 在通過網路傳輸時通常會被壓縮,也就是說,在瀏覽器端解壓後,JavaScript 的實際數量會更多。

從資源處理方面來看,壓縮並不會給資源處理帶來任何好處,比如 900 KB 的 JavaScript 被壓縮後可能只有 300 KB,但在解壓後解析器和編譯器仍然要處理 900 KB 的 JavaScript。

 

上圖是下載和執行 JavaScript 的過程。請注意,即使壓縮後的指令碼為 300 KB,但在後面仍然要解析、編譯和執行 900 KB 的 JavaScript。

處理 JavaScript 非常耗費資源。與影象不一樣,影象在下載完之後只需要對其進行解碼,而 JavaScript 必須被解析、編譯和執行,因此處理 JavaScript 比處理其他型別的資源更昂貴。

 

上圖顯示瞭解析和編譯 170 KB JavaScript 與解碼等效大小 JPEG 影象的處理成本。

引擎開發者在不斷努力提升 JavaScript 引擎的執行效率,但說到底,提升 JavaScript 程式碼的效能更多的是開發人員的責任。

有一些技術可以用於提升 JavaScript 的效能。程式碼拆分就是這樣的一種技術,它將應用程式 JavaScript 劃分為較小的塊,並只嚮應用程式路由提供它們必需的塊,以此來提升效能。這種方式是有效的,但它並沒有解決 JavaScript 應用程式的其他常見問題,比如那些被包含但從未使用的程式碼。為了解決這個問題,我們需要使用搖樹(tree shaking)優化技術。

什麼是搖樹?

搖樹是一種消除死程式碼的方法。這個詞最初是由 Rollup 發起的,並逐漸流行開來,但消除死程式碼的概念卻早已存在。webpack 中也涉及了這個概念,本文將通過示例進行演示。

這項技術之所以被稱為“搖樹”,主要是因為應用程式的依賴項是樹狀結構。樹中的每個節點都代表了一個依賴項,這些依賴項為應用程式提供了不同的功能。在現代應用程式中,這些依賴項通過靜態匯入語句進行引入,如下所示:

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

在你的應用程式還很年輕的時候(如果你願意,可以把它叫作“樹苗”),應用程式的依賴項相對較少,而且你使用了大多數(如果不是全部)新增的依賴項。但是,隨著應用程式的老化,更多的依賴項被新增進來,更糟糕的是,較舊的依賴項不再被使用,但可能無法從程式碼庫中刪除。最終的結果就是應用程式會傳輸大量未使用的 JavaScript 到客戶端。搖樹利用了靜態匯入語句來匯入 ES6 模組的特定部分,從而解決了這個問題:

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

這個示例和之前示例之間的區別在於,它沒有從“array-utils”模組中匯入所有內容,而是匯入它的特定部分。在開發階段的構建中,這樣做並不會有真正的效果,因為不管怎樣它都會匯入整個模組。但是,在生產階段的構建中,我們可以通過配置 webpack 讓它“搖掉”未明確指定的 ES6 模組,從而減小最終的構建體積。在本文中,你將學會如何做到這一點!

尋找搖樹的機會

為了方便說明,我建立了一個單頁應用程式示例(https://github.com/malchata/webpack-tree-shaking-example),這個應用程式使用 webpack 來演示搖樹的工作原理。如果你願意,可以拉取這個示例應用程式。不過我們將在本文中一步步介紹這個方法,所以不一定要拉取程式碼,除非你喜歡邊學邊動手。

示例應用程式是一個超級簡單的吉他踏板資料庫搜尋程式,輸入關鍵字,可以搜尋到吉他踏板的清單。

 

應用程式的行為被分為 vendor(即 Preact 和 Emotion)和特定於應用程式的程式碼包(或者在 webpack 中叫作“chunk”):

 

 

 

上圖中顯示的 JavaScript 包是生產未壓縮版本,也就是說它們通過 uglification(http://lisperator.net/uglifyjs/)進行了優化。特定於應用程式的捆綁包的大小為 21.1 KB 算是不錯的了。但請注意,這裡並沒有經過搖樹優化。現在讓我們來看看應用程式的程式碼,看看可以做些什麼來解決這個問題。

在任何一個應用程式中,在尋找搖樹機會時,都會先查詢靜態匯入語句。在主元件檔案的頂部附近,你將看到如下所示的行:

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

也許你之前也見過這樣的東西。匯入 ES6 模組的方式有很多,但你要特別注意這個。這行語句好像在說:“匯入 utils 模組的所有內容,並把它們放在名稱空間 utils 中”。問題是,“這個模組中究竟有多少東西?”

如果你去看一下 utils 模組的原始碼,你會發現它包含的東西非常多,可能有 1,300 行程式碼。

或許所有這些東西都會被用到?事實是這樣的嗎?讓我們搜尋一下主元件檔案,看看出現了多少 utils 名稱空間裡的東西。

 

我們從 utils 匯入了大量的模組,但在主元件檔案中只調用了三次。

這樣不太好。我們只在應用程式程式碼的三個地方使用了 utils 名稱空間裡的東西。那麼它們是用來實現什麼功能的呢?如果再看一下主元件檔案,我們會發現,似乎只調用了一個函式,即 utils.simpleSort,用於在下拉列表發生變化時按照一定的條件對搜尋結果進行排序:

if (this.state.sortBy === "model") {
  // Simple sort 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 行的檔案,卻只使用了其中一個函式。

需要注意的是,這個示例特意做得這麼簡單,所以可以很容易地找出膨脹程式碼的來源。但在包含大量模組的大型專案中,很難找出哪些匯入造成了捆綁包的數量激增,不過我們可以藉助 Webpack Bundle Analyzer 和 source-map-explorer 這些工具。

這個示例是專門為這篇文章定製的,似乎有點牽強,但在實際的專案當中,遇到這樣的情況是不可避免的。也就是說,你已經發現了可以進行搖樹的機會了,那麼接下來應該怎麼做呢?

 不要讓 Babel 將 ES6 模組轉換為 CommonJS 模組

Babel 是大多數應用程式不可或缺的工具。可惜的是,它會給搖樹優化帶來一些麻煩。如果使用了 babel-preset-env,它會自動將 ES6 模組轉換為 CommonJS 模組(即你 require 的模組,而不是 import 的模組)。這本來是件好事,但在進行搖樹優化時問題就來了。

針對 CommonJS 模組進行搖樹優化會比較困難,而且 webpack 不知道需要從捆綁中去掉哪些東西。解決方案很簡單:在配置 babel-preset-env 時,讓它不要處理 ES6 模組。無論你在哪裡配置 Babel(無論是.babelrc 還是 package.json),只要增加一些額外的東西:

{
  "presets": [
    ["env", {
      "modules": false
    }]
  ]
}

在 babel-preset-env 配置中指定“modules”: false,webpack 就可以分析依賴關係樹,並去掉那些未使用的依賴項。此外,它不會導致相容性問題,因為 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 陣列時會產生副作用,因為它超出了 addFruit 函式的作用域。

副作用也適用於 ES6 模組,所以會影響到搖樹優化。有些模組接收可預測的輸入,返回可預測的結果,並且不會修改自身作用域之外的任何東西,如果我們沒有使用到這些模組,那麼就可以安全地將它們“搖”掉。它們是模組化的獨立程式碼片段。

我們可以在 package.json 檔案中指定“sideEffects”: false,告訴 webpack 哪個模組及其依賴項是無副作用的:

{
  "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 檔案中新增這些內容,可以在 webpack 配置的 module.rules 中指定(https://github.com/webpack/webpack/issues/6065#issuecomment-351060570)。

只匯入需要的東西

之前我們讓 Babel 不要處理 ES6 模組,現在需要對匯入語法稍作調整,只從 utils 模組中匯入我們需要的函式。在本示例中,我們只需要 simpleSort:

import { simpleSort } from "../../utils/utils";

我們像是在說:“只要把 utils 模組中的 simpleSort 給我就行了”。因為我們只將 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 輸出:

 

下面是進行搖樹優化後的輸出:

 

兩個捆綁包都縮小了,不過 main 捆綁包的縮小幅度更大。通過去掉 utils 模組的未使用部分,我們已經設法從這個捆綁中砍掉了大約 60%的程式碼。這不僅可以縮短指令碼下載所需的時間,還可以縮短處理指令碼的時間。

更復雜的場景

在大多數情況下,只要在近期版本的 webpack 中稍作調整就可以進行搖樹優化,但總有一些例外情況會讓你感到頭疼。例如,本文所描述的方法對 lodash 就不起作用。由於 lodash 自身的架構問題,你需要安裝 lodash-es(https://www.npmjs.com/package/lodash-es)來代替常規的 lodash,並且使用不同的語法(被叫作“cherry-picking”)來去掉依賴項:

// This still pulls in all of lodash even if everything is configured right.
import { sortBy } from "lodash";

// This will only pull in the sortBy routine.
import sortBy from "lodash-es/sortBy";

如果你希望保持一致的匯入語法,那麼可以在使用標準 lodash 包的同時安裝 babel-plugin-lodash 外掛(http://babel-plugin-lodash/)。在將這個外掛新增到 Babel 中後,你可以使用典型的匯入語法去掉未使用的依賴項。

如果遇到一個很頑固的庫,先看看它是否使用 ES6 語法進行匯出。如果它用 CommonJS 格式進行匯出(例如 module.exports),那麼這些程式碼將不能通過 webpack 進行搖樹優化。有一些外掛為 CommonJS 模組提供了搖樹功能,例如 webpack-common-shake,但仍然有一些模式的 CommonJS 是無法進行搖樹優化的。如果你想要進行可靠的依賴項消除,最好只針對 ES6 模組。

英文原文:

https://developers.google.com/web/fundamentals/performance/optimizing-javascript/tree-shaking/