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
會發現它也是這麼做的: