Webpack 5 實踐:你不知道的 Tree Shaking

語言: CN / TW / HK

theme: juejin

本篇文章從 什麼是 Tree Shaking、如何使用 Tree Shaking、Tree Shaking 的原理:usedExportssideEffects 以及 如何實踐 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 導出了 squarecube 兩個函數,而 index.js 僅僅導入並調用了 cube 函數,我們並沒有從 math.jsimport 另外一個 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 函數的聲明語句、打印語句都會被刪除。

打包結果:

image-20220326211348176.png

可以看到,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));

打包結果:

image-20220326213939041.png

可以看到,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 就會將引用被標記內容的語句刪除。

打包結果如下:

image-20220326222056336.png

可以看到打包結果符合我們的預期:squareprint 函數的痕跡被清除,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 模塊為有副作用,其他模塊沒有副作用,我們再來打包看看:

image-20220326224031988.png

可以看到 print 模塊被刪除,並且 index 中對 print 的導入語句也被清除了!

接下來更進一步,全部設置為無副作用試試:

// package.json
{
        "name": "mypackage",
        "main": "index.js",
        "sideEffects": false
}

打包構建,結果如下:

image-20220326225556305.png

可以看到作為入口文件的 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 效果。打包結果如下:

image-20220326232104735.png

打包結果竟然為空,我們不是將 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 文件,可以這樣:

image-20220325125037647.png

這樣的話,CSS 文件的導出值(默認導出值)被消費,Terser 就不會將其 shake 掉。

總結

Webpack 的 Tree Shaking 機制由 optimization.usedExportssideEffects 共同承擔,兩者都具備 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 文件,同時又刪除了未被引用的模塊。讓我們看看管不管用:

image-20220327133059498.png

可以看到除了手動引入的 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"

轉換之後的導入語句僅僅導入 lodashrandom 模塊而不是整個 lodash 庫,因此 Webpack 打包時也僅打包 random 而不是整個 lodash,從而達到類似於 Tree Shaking 的效果。

綜上,如果你是一個應用程序的開發者,想要達到最佳的 Tree Shaking 效果,你應該這樣做:

  • (個人見解)使用 optimization.usedExports 而不是 "sideEffects"。前者在生產環境下默認啟動,換句話説,你什麼也不用做;

  • 優先使用按需引入的方式導入項目所需要的組件、API;

  • 優先使用支持 ESM 語法並設置了 "sideEffects" 的庫版本。如果你所使用的庫並未設置 "sideEffects",那就給作者提個 issue 吧!

    ![image-20220327145501325.png](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/8134b120463b4f5ab96a4b057fbd2e8d~tplv-k3u1fbpfcp-watermark.image?)
    

"sideEffects" 的強大之處,體現在它能大大減少項目所引用的包的體積。如果項目所引用的包支持 ESM 模塊語法,且設置了 "sideEffects: false",那麼在打包時 Webpack 就能刪除包中所有未被引入的代碼,減少 bundle 體積。

諸如 vuevuexvue-routerpackage.json 都添加了 "sideEffects": false

因此,如果你是一個庫(library)的開發者,你應該在你的 package.json中設置 "sideEffects",並打包出使用 ESM 格式的 bundle,以支持 Tree Shaking。

然而,令人遺憾的是,Webpack 尚不支持打包 ESM 格式的 bundle:

image-20220327150215351.png

因此對於庫開發者,推薦使用 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.jsonmodule 字段。

使用了 package.jsonmodule 字段之後,當打包工具遇到我們的模塊時:

  • 如果它支持 module 字段,則會優先解析該字段所指定的文件;

  • 如果它還無法識別 module 字段,則會解析 main 字段所指定的文件。

因此我們可以module 字段的值指定為使用 ESM 語法的 bundle 路徑,把 main 字段指定為使用其它模塊語法的 bundle 路徑

查看 vuex 的 package.json 會發現它也是這麼做的:

image-20220327153532631.png

參考鏈接