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/
JavaScript性能優化之搖樹