Webpack 5 實踐:你不知道的 Tree Shaking
theme: juejin
本篇文章從 什麼是 Tree Shaking、如何使用 Tree Shaking、Tree Shaking 的原理:usedExports
和 sideEffects
以及 如何實踐 Tree Shaking 和相關注意事項四個維度剖析 Tree Shaking,希望對你有所幫助。
什麼是 Tree Shaking
Tree Shaking 是一個術語,通常用於描述移除 JavaScript 上下文中的未引用程式碼 (dead-code)。它依賴於 ES2015 模組語法的靜態結構特性,通過在執行過程中靜態分析模組之間的匯入匯出,確定 ESM 模組中哪些匯出值未曾被其它模組使用,並將其刪除,以此實現打包產物的優化。
Tree Shaking 較早前由 Rich Harris 在 Rollup 中率先實現,Webpack 自 2.0 版本開始接入,至今已經成為一種應用廣泛的效能優化手段。
啟用 Tree Shaking
在 Webpack5 中,Tree Shaking 在生產環境下預設啟動。如果想在開發環境啟動 Tree Shaking,需要如下配置:
-
配置
optimization.usedExports
為 true,啟動標記功能; -
啟動程式碼優化功能,可以通過如下方式實現:
- 配置 `optimization.minimize = true`; - 提供 `optimization.minimizer` 陣列。
當然,使用 Tree Shaking 的大前提是使用 ESM 規範語法來編寫你的模組。那麼為什麼使用 CommonJs、AMD 等模組化方案無法支援 Tree Shaking 呢?
因為在 CommonJs、AMD、CMD 等舊版本的 JavaScript 模組化方案中,匯入匯出行為是高度動態,難以預測的,例如:
if(process.env.NODE_ENV === 'development'){
require('./bar');
exports.foo = 'foo';
}
而 ESM 方案則從規範層面規避這一行為,它要求所有的匯入匯出語句只能出現在模組頂層,且匯入匯出的模組名必須為字串常量,這意味著下述程式碼在 ESM 方案下是非法的:
if(process.env.NODE_ENV === 'development'){
import bar from 'bar';
export const foo = 'foo';
}
所以,ESM 下模組之間的依賴關係是高度確定的,與執行狀態無關,編譯工具只需要對 ESM 模組做靜態分析,就可以從程式碼字面量中推斷出哪些模組值未曾被其它模組使用,這是實現 Tree Shaking 技術的必要條件。
示例:
// src/math.js
export function square(x) {
return x * x;
}
export function cube(x) {
return x * x * x;
}
// src/index.js
import { cube } from './math.js';
console.log(cube(5));
// webpack.config.js
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
mode: 'development',
optimization: {
usedExports: true,
},
};
(需要將 mode
配置設定成 development
以確定 bundle 不會被壓縮。)
該示例中,math.js
匯出了 square
、cube
兩個函式,而 index.js
僅僅匯入並呼叫了 cube
函式,我們並沒有從 math.js
中 import
另外一個 square
方法,因此這個函式體就是所謂的“未引用程式碼(dead code)”,
檢視打包結果:
/***/ (function (module, __webpack_exports__, __webpack_require__) {
'use strict';
/* unused harmony export square */
/* harmony export (immutable) */ __webpack_exports__['a'] = cube;
function square(x) {
return x * x;
}
function cube(x) {
return x * x * x;
}
});
可以看到,square
函式的匯出語句被 shake 掉,接下來只要啟用壓縮工具就可將 square
的定義清除掉以達到完整的 Tree Shaking 效果。
使用以下三個配置均可啟用程式碼壓縮工具:
- 配置
mode = production
; - 配置
optimization.minimize = true
; - 提供
optimization.minimizer
陣列。
Tree Shaking 原理探索
optimization.usedExports
通過上述示例,我們知道要啟用 Webpack 的 Tree Shaking 功能,需配置 optimization.usedExports
為 true,那麼該欄位的作用是什麼呢?
usedExports
用於在 Webpack 編譯過程中啟動標記功能,它會將每個模組中沒有被使用過的匯出內容標記為 unused
,當生成產物時,被標記的變數對應的匯出語句會被刪除。
當然,僅僅刪除未使用變數的匯出語句是不夠的,若 Webpack 配置啟用了程式碼壓縮工具,如 Terser 外掛,那麼在打包的最後它還會刪除所有引用被標記內容的程式碼語句,這些語句一般稱作 Dead Code。可以說,真正執行 Tree Shake 操作的是 Terser 外掛。
但是,並不是所有 Dead Code 都會被 Terser 刪除。沿用以上示例:
// src/math.js
export function square(x) {
return x * x;
}
export function cube(x) {
return x * x * x;
}
console.log(square(10));
// src/index.js
import { cube } from './math.js';
console.log(cube(5));
我們新增一條列印語句,它列印了呼叫 squre
函式的返回結果,index.js
保留原樣。按照我們之前的設想,打包後會刪除與 squre
函式相關的程式碼語句,即 squre
函式的宣告語句、列印語句都會被刪除。
打包結果:
可以看到,math.js
模組中,square
函式的痕跡被完全清除,但是列印語句仍然被保留。這是因為,這條語句存在副作用。
副作用(side effect) 的定義是,在匯入時會執行特殊行為的程式碼,而不是僅僅暴露一個 export 或多個 export。例如 polyfill,它影響全域性作用域,因而存在副作用。
顯然,以上示例的 console.log()
語句存在副作用。Terser 在執行 Tree Shaking 時,會保留存在副作用的程式碼,而不是將其刪除。
Terser 為什麼選擇不刪除存在副作用的語句呢?因為有副作用不代表有害,例如 polyfill ,它會影響全域性作用域,但是可以讓我們使用 ES6+ 來書寫程式碼而不必考慮目標瀏覽器的相容性。
事實上,要判斷一串存在副作用的程式碼是否對專案”有害“是非常麻煩的,Terser 嘗試去解決這個問題,但在很多情況下,它不太確定。但這並不意味著 terser 由於無法解決這些問題而運作得不好,而是由於在 JavaScript 這種動態語言中實在很難去確定。因此 Terser 採取保守策略,選擇將副作用保留。
作為開發者,如果你非常清楚某條語句會被判別有副作用但其實是無害的,應該被刪除,可以使用 /*#__PURE__*/
註釋,來向 terser 傳遞資訊,表明這條語句是純的,沒有副作用,terser 可以放心將它刪除:
// src/math.js
export function square(x) {
return x * x;
}
export function cube(x) {
return x * x * x;
}
/*#__PURE__*/ console.log(square(10));
打包結果:
可以看到,console.log
語句已被刪除。
"sideEffects"
探索
與 /*#__PURE__*/
註釋類似,"sideEffects"
也可以標記不存在副作用的內容,與前者不同的是,它作用於模組層面。
"sideEffects"
是 package.json
的一個欄位,預設值為 true
。如果你非常清楚你的 package 是純粹的,不包含副作用,那麼可以簡單地將該屬性標記為 false
,來告知 webpack 可以安全地刪除未被使用的程式碼(Dead Code);如果你的 package 中有些模組確實有一些副作用,可以改為提供一個數組:
// package.json
{
"name": "your-project",
"sideEffects": ["./src/some-side-effectful-file.js"]
}
為了更清楚地表達 "sideEffects"
欄位的意圖,我們建立一個 package:
// package.json
{
"name": "mypackage",
"main": "index.js"
}
// index.js
export * from "./math.js";
export * from "./print.js";
// math.js
export function square(x) {
return x * x;
}
export function cube(x) {
return x.sum(x);
}
console.log(square(10));
// print.js
export function print() {
console.log("Hello World!");
}
然後使用 npm link
在全域性建立一個指向該 package 檔案位置的符號連結,然後在另一個專案中使用 npm link mypackage
引入該 package。
在專案的 index.js
中,我們引入但僅呼叫該 package 的 cube
函式:
import { cube } from "mypackage";
cube(5);
準備工作完畢。我們先不使用 sideEffects
欄位,僅開啟 usedExports
。結合前面對該欄位的闡述,我們知道它會標記出未被使用的匯出內容,打包時 terser 就會將引用被標記內容的語句刪除。
打包結果如下:
可以看到打包結果符合我們的預期:square
和 print
函式的痕跡被清除,console.log
語句由於具有副作用所以沒有被刪除。
- 注:
index
模組的a
函式是壓縮混淆前的執行時函式__webpack_require__
,用於匯入指定的模組以支撐 bundle 的模組化特性。
但是可以看到,print
模組仍然被保留,儘管它的內容為空,保留它並不會造成什麼影響,但是難免引起專案冗餘,而且 index
中仍然匯入了 print
模組,程式碼執行過程中難免會有效能損耗,另外如果該模組是一個 async chunk 的話還會造成額外的網路開銷。為了將這些冗餘的模組 shake 乾淨,我們可以使用 sideEffects
欄位。
print
模組之所以不會被刪除掉,是 sideEffects
欄位預設為 true 的緣故,導致 package 中包括 print
在內的所有模組都被標記為有副作用,因此 terser 不會貿然將它們刪除。 所以,我們可以這樣設定:
// package.json
{
"name": "mypackage",
"main": "index.js",
"sideEffects": ["./index.js"]
}
僅標記 index
模組為有副作用,其他模組沒有副作用,我們再來打包看看:
可以看到 print
模組被刪除,並且 index
中對 print
的匯入語句也被清除了!
接下來更進一步,全部設定為無副作用試試:
// package.json
{
"name": "mypackage",
"main": "index.js",
"sideEffects": false
}
打包構建,結果如下:
可以看到作為入口檔案的 index
模組也被刪除了,僅保留了 math
模組。所以設定了 "sideEffects": false
,表明整個 package 不存在任何副作用,Webpack 可以安心執行 Tree Shaking 了。
前面都在講 JS 檔案,我們再來看看存在 CSS 檔案的情況。
在專案下新建一個 CSS 檔案,然後修改 index 的內容:
// style.css
.hello-world {
color: red;
}
// index.js
import "./style.css";
我們在專案根目錄的 package.json
中設定 sideEffetcs
欄位的值為 false 來達到完整的 Tree Shaking 效果。打包結果如下:
打包結果竟然為空,我們不是將 CSS 檔案 import 進來了嗎,怎麼會被刪除呢?
這是因為,在打包過程中,css-loader
會將 CSS 檔案轉譯為匯出該檔案中所有 CSS 規則集的 JS 模組。而我們在 index 中並沒有匯入它的匯出值,僅僅是簡單的將其 import 進來,導致這個 ”CSS 模組“ 的匯出值被標記為 unused,由於還被標記為無副作用,所以整個模組就被刪除了。
因此,當專案中存在 CSS 檔案時,我們就不能簡單粗暴的將 sideEffects
標記為 false 了。
結論
sideEffetcs
作用於整個模組,它不會分析整個模組內部的程式碼是否具有副作用:
- 當你對模組設定了
"sideEffects": false
,就表明這個模組沒有副作用,相當於告訴 Webpack:喂!我沒有副作用啊,如果我的匯出值沒有被別的模組使用那就請把我清除掉吧! - 當你對模組設定了
"sideEffects": true
,就表明這個模組有副作用,相當於告訴 Webpack:喂!我有副作用啊,就算我沒有被別的模組匯入(指匯出值被使用)也不要把我清除啊!
因此,對於 CSS 檔案,需要使用 sideEffects
標記所有 CSS 檔案,來保留所有 CSS 檔案,以及對 CSS 檔案的匯入語句。
如果你仍想對 CSS 檔案使用 "sideEffects: false"
,並且想保留這個 CSS 檔案,可以這樣:
這樣的話,CSS 檔案的匯出值(預設匯出值)被消費,Terser 就不會將其 shake 掉。
總結
Webpack 的 Tree Shaking 機制由 optimization.usedExports
和sideEffects
共同承擔,兩者都具備 Tree Shake 掉多餘程式碼的功能:
usedExports
作用於程式碼語句層面,依賴於 terser 去檢測語句中的副作用;sideEffects
作用於模組層面,用於標記整個模組的副作用。
usedExports
和 terser 在生產環境下預設開啟,它會刪除專案所有模組中未被引用的匯出變數以及對應的匯出語句,同時保留具有副作用的語句。
被標記為 sideEffects: false
的模組,如果匯出值未被引用,在打包後會被刪除。
Tree Shaking 實踐
應用程式
如果我們所開發的是一個應用程式(application),為了達到最佳的 Tree Shaking 效果,是不是要在專案下的 package.json
中設定 sideEffects: false
呢?
答案是否定的,在日常開發中,除了手動 import CSS 檔案之外,我們還經常會使用 MiniCssExtractPlugin
將所有 CSS 從它們所在的 chunk 中抽離出來成為單獨的檔案,以利用並行載入和按需載入來優化網頁載入效能,這意味著,如果設定了 sideEffects: false
的話打包時 Webpack 就會將它們刪除。因此我們需要改用陣列語法來標記它們的副作用:
// package.json
{
"sideEffects": ["**/*.css"]
}
這看起來似乎是個最佳實踐,我們保留了 CSS 檔案,同時又刪除了未被引用的模組。讓我們看看管不管用:
可以看到除了手動引入的 CSS 檔案以外,剩下的 CSS 檔案全都被刪除。儘管我們已經標記了專案下的 CSS 檔案的副作用,但是很明顯,被 MiniCssExtractPlugin
分離出的 CSS 檔案並不在 "sideEffects"
標記列表內。
因此,在應用程式中使用 "sideEffects"
會導致無法預料的後果,而且使用它的收益也不會很高,因為專案中的模組我們基本都會引用,沒有被引用的也不會被 Webpack 納入模組依賴圖。
因此,個人不建議在應用程式中使用 "sideEffects"
。
Vant 元件庫的文件中,推薦使用 babel-plugin-import 外掛來引入元件,它會在編譯過程中將 import 語句自動轉換為按需引入的形式:
// 原始程式碼
import { Button } from 'vant';
// 編譯後代碼
import Button from 'vant/es/button';
import 'vant/es/button/style';
實際上,不使用 babel-plugin-import ,僅使用上面的原始程式碼也可以匯入元件,並且支援 Tree Shaking。很多人對上例中原始程式碼的匯入方式有誤解,認為從 "vant"
路徑匯入元件的方式不支援 Tree Shaking,而從 'vant/es/button'
路徑匯入元件的方式就支援 Tree Shaking。對於不使用 ESM 的庫確實如此,比如 lodash
,但是對於使用 ESM 的庫,兩種引入方式就都一樣了。一個庫支不支援 Tree Shaking 取決於這個庫打包出的 bundle 是否是 ESM 語法僅此而已。而 Vant 明顯滿足這個條件。
Vant 推薦使用 babel-plugin-import 的原因就是它可以自動引入元件,可以省去手動引入的麻煩。
實際上,babel-plugin-import 的作用不止如此。它的強大之處在於它能讓不使用 ESM 的庫支援 "Tree Shaking",比如 lodash
。原因很簡單,因為它能把原始的匯入語句轉換為更加精確的匯入語句:
// 原始程式碼
import { random } from 'lodash'
// 轉換後
import { random } from 'lodash/random"
轉換之後的匯入語句僅僅匯入 lodash
的 random
模組而不是整個 lodash
庫,因此 Webpack 打包時也僅打包 random
而不是整個 lodash
,從而達到類似於 Tree Shaking 的效果。
綜上,如果你是一個應用程式的開發者,想要達到最佳的 Tree Shaking 效果,你應該這樣做:
-
(個人見解)使用
optimization.usedExports
而不是"sideEffects"
。前者在生產環境下預設啟動,換句話說,你什麼也不用做; -
優先使用按需引入的方式匯入專案所需要的元件、API;
-
優先使用支援 ESM 語法並設定了
"sideEffects"
的庫版本。如果你所使用的庫並未設定"sideEffects"
,那就給作者提個 issue 吧!![image-20220327145501325.png](http://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/8134b120463b4f5ab96a4b057fbd2e8d~tplv-k3u1fbpfcp-watermark.image?)
庫
"sideEffects"
的強大之處,體現在它能大大減少專案所引用的包的體積。如果專案所引用的包支援 ESM 模組語法,且設定了 "sideEffects: false"
,那麼在打包時 Webpack 就能刪除包中所有未被引入的程式碼,減少 bundle 體積。
諸如 vue
、vuex
、vue-router
的 package.json
都添加了 "sideEffects": false
。
因此,如果你是一個庫(library)的開發者,你應該在你的 package.json
中設定 "sideEffects"
,並打包出使用 ESM 格式的 bundle,以支援 Tree Shaking。
然而,令人遺憾的是,Webpack 尚不支援打包 ESM 格式的 bundle:
因此對於庫開發者,推薦使用 Rollup 作為構建工具,僅需如下配置:
// rollup.config.js
export default {
...,
output: {
file: 'bundle.es.js',
format: 'es'
}
};
就能打包出使用 ESM 格式的 bundle。
但是,我們在開發一個庫的過程中還要考慮相容性的問題,很明顯打包出 ESM 格式的 bundle 的話舊瀏覽器是無法支援的,並且出於構建效能的考慮, Vue CLI 等腳手架所整合的 babel-loader 預設情況下會排除 node_modules
內部的檔案。使用者如果使用了我們釋出的使用 ESM的包就必須配置複雜的規則以把我們的包加入編譯的白名單。
因此為了能在支援 Tree Shaking 的同時又能相容低版本的瀏覽器,最佳實踐是打包出兩個版本的 bundle,一份使用 ESM 規範語法以支援 Tree Shaking,一份使用其它模組語法如 CommonJS 做回退處理。這需要使用 package.json
的 module
欄位。
使用了 package.json
的 module
欄位之後,當打包工具遇到我們的模組時:
-
如果它支援
module
欄位,則會優先解析該欄位所指定的檔案; -
如果它還無法識別
module
欄位,則會解析main
欄位所指定的檔案。
因此我們可以把 module
欄位的值指定為使用 ESM 語法的 bundle 路徑,把 main
欄位指定為使用其它模組語法的 bundle 路徑。
檢視 vuex 的 package.json
會發現它也是這麼做的: