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](http://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

參考連結