Webpack Tree Shaking

2023-05-18 10:08 更新

tree shaking 是一個術(shù)語,通常用于描述移除 JavaScript 上下文中的未引用代碼(dead-code)。它依賴于 ES2015 模塊語法的 靜態(tài)結(jié)構(gòu) 特性,例如 import 和 export。這個術(shù)語和概念實際上是由 ES2015 模塊打包工具 rollup 普及起來的。

webpack 2 正式版本內(nèi)置支持 ES2015 模塊(也叫做 harmony modules)和未使用模塊檢測能力。新的 webpack 4 正式版本擴展了此檢測能力,通過 ?package.json? 的 "sideEffects" 屬性作為標記,向 compiler 提供提示,表明項目中的哪些文件是 "pure(純正 ES2015 模塊)",由此可以安全地刪除文件中未使用的部分。

添加一個通用模塊

在我們的項目中添加一個新的通用模塊文件 src/math.js,并導出兩個函數(shù):

project

webpack-demo
|- package.json
|- package-lock.json
|- webpack.config.js
|- /dist
  |- bundle.js
  |- index.html
|- /src
  |- index.js
+ |- math.js
|- /node_modules

src/math.js

export function square(x) {
  return x * x;
}

export function cube(x) {
  return x * x * x;
}

需要將 mode 配置設(shè)置成development,以確定 bundle 不會被壓縮:

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,
+ },
};

配置完這些后,更新入口腳本,使用其中一個新方法,并且為了簡化示例,我們先將 lodash 刪除:

src/index.js

- import _ from 'lodash';
+ import { cube } from './math.js';

  function component() {
-   const element = document.createElement('div');
+   const element = document.createElement('pre');

-   // Lodash, now imported by this script
-   element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+   element.innerHTML = [
+     'Hello webpack!',
+     '5 cubed is equal to ' + cube(5)
+   ].join('\n\n');

    return element;
  }

  document.body.appendChild(component());

注意,我們沒有從 src/math.js 模塊中 import 另外一個 square 方法。這個函數(shù)就是所謂的“未引用代碼(dead code)”,也就是說,應(yīng)該刪除掉未被引用的 export?,F(xiàn)在運行 npm script npm run build,并查看輸出的 bundle:

dist/bundle.js (around lines 90 - 100)

/* 1 */
/***/ (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;
  }
});

注意,上面的 unused harmony export square 注釋。如果你觀察它下面的代碼,你會注意到雖然我們沒有引用 square,但它仍然被包含在 bundle 中。我們將在下一節(jié)解決這個問題。

將文件標記為 side-effect-free(無副作用)

在一個純粹的 ESM 模塊世界中,很容易識別出哪些文件有副作用。然而,我們的項目無法達到這種純度,所以,此時有必要提示 webpack compiler 哪些代碼是“純粹部分”。

通過 package.json 的 "sideEffects" 屬性,來實現(xiàn)這種方式。

{
  "name": "your-project",
  "sideEffects": false
}

如果所有代碼都不包含副作用,我們就可以簡單地將該屬性標記為 false,來告知 webpack 它可以安全地刪除未用到的 export。

如果你的代碼確實有一些副作用,可以改為提供一個數(shù)組:

{
  "name": "your-project",
  "sideEffects": ["./src/some-side-effectful-file.js"]
}

此數(shù)組支持簡單的 glob 模式匹配相關(guān)文件。其內(nèi)部使用了 glob-to-regexp(支持:*,**,{a,b},[a-z])。如果匹配模式為 *.css,且不包含 /,將被視為 **/*.css。

{
  "name": "your-project",
  "sideEffects": ["./src/some-side-effectful-file.js", "*.css"]
}

最后,還可以在 module.rules 配置選項 中設(shè)置 "sideEffects"。

解釋 tree shaking 和 sideEffects

sideEffects 和 usedExports(更多被認為是 tree shaking)是兩種不同的優(yōu)化方式。

sideEffects 更為有效 是因為它允許跳過整個模塊/文件和整個文件子樹。

usedExports 依賴于 terser 去檢測語句中的副作用。它是一個 JavaScript 任務(wù)而且沒有像 sideEffects 一樣簡單直接。而且它不能跳轉(zhuǎn)子樹/依賴由于細則中說副作用需要被評估。盡管導出函數(shù)能運作如常,但 React 框架的高階函數(shù)(HOC)在這種情況下是會出問題的。

讓我們來看一個例子:

import { Button } from '@shopify/polaris';

打包前的文件版本看起來是這樣的:

import hoistStatics from 'hoist-non-react-statics';

function Button(_ref) {
  // ...
}

function merge() {
  var _final = {};

  for (
    var _len = arguments.length, objs = new Array(_len), _key = 0;
    _key < _len;
    _key++
  ) {
    objs[_key] = arguments[_key];
  }

  for (var _i = 0, _objs = objs; _i < _objs.length; _i++) {
    var obj = _objs[_i];
    mergeRecursively(_final, obj);
  }

  return _final;
}

function withAppProvider() {
  return function addProvider(WrappedComponent) {
    var WithProvider =
      /*#__PURE__*/
      (function (_React$Component) {
        // ...
        return WithProvider;
      })(Component);

    WithProvider.contextTypes = WrappedComponent.contextTypes
      ? merge(WrappedComponent.contextTypes, polarisAppProviderContextTypes)
      : polarisAppProviderContextTypes;
    var FinalComponent = hoistStatics(WithProvider, WrappedComponent);
    return FinalComponent;
  };
}

var Button$1 = withAppProvider()(Button);

export {
  // ...,
  Button$1,
};

當 Button 沒有被使用,你可以有效地清除掉 export { Button$1 }; 且保留所有剩下的代碼。那問題來了,“這段代碼會有任何副作用或它能被安全都清理掉嗎?”。很難說,尤其是這 withAppProvider()(Button) 這段代碼。withAppProvider 被調(diào)用,而且返回的值也被調(diào)用。當調(diào)用 merge 或 hoistStatics 會有任何副作用嗎?當給 WithProvider.contextTypes (Setter?) 賦值或當讀取 WrappedComponent.contextTypes (Getter) 的時候,會有任何副作用嗎?

實際上,Terser 嘗試去解決以上的問題,但在很多情況下,它不太確定。但這不會意味著 terser 由于無法解決這些問題而運作得不好,而是由于在 JavaScript 這種動態(tài)語言中實在太難去確定。

但我們可以通過 /*#__PURE__*/ 注釋來幫忙 terser。它給一個語句標記為沒有副作用。就這樣一個簡單的改變就能夠使下面的代碼被 tree-shake:

var Button$1 = /*#__PURE__*/ withAppProvider()(Button);

這會使得這段代碼被過濾,但仍然會有一些引入的問題,需要對其進行評估,因為它們產(chǎn)生了副作用。

為了解決這個問題,我們需要在 package.json 中添加 "sideEffects" 屬性。

它類似于 /*#__PURE__*/ 但是作用于模塊的層面,而不是代碼語句的層面。它表示的意思是(指"sideEffects" 屬性):“如果被標記為無副作用的模塊沒有被直接導出使用,打包工具會跳過進行模塊的副作用分析評估。”。

在一個 Shopify Polaris 的例子,原有的模塊如下:

index.js

import './configure';
export * from './types';
export * from './components';

components/index.js

// ...
export { default as Breadcrumbs } from './Breadcrumbs';
export { default as Button, buttonFrom, buttonsFrom } from './Button';
export { default as ButtonGroup } from './ButtonGroup';
// ...

package.json

// ...
"sideEffects": [
  "**/*.css",
  "**/*.scss",
  "./esnext/index.js",
  "./esnext/configure.js"
],
// ...

對于代碼 ?import { Button } from "@shopify/polaris"?; 它有以下的暗示:

  • 導入它:導入并包含該模塊,分析評估它并繼續(xù)進行依賴分析
  • 跳過它:不導入它,不分析評估它但會繼續(xù)進行依賴分析
  • 排除它:不導入它,不評估且不做依賴分析

以下是每個匹配到的資源的情況:

  • ?index.js?: 沒有直接的導出被使用,但被標記為有副作用 -> 導入它
  • ?configure.js?: 沒有導出被使用,但被標記為有副作用 -> 導入它
  • ?types/index.js?: 沒有導出被使用,沒有被標記為有副作用 -> 排除它
  • ?components/index.js?: 沒有導出被使用,沒有被標記為有副作用,但重新導出的導出內(nèi)容被使用了 -> 跳過它
  • ?components/Breadcrumbs.js?: 沒有導出被使用,沒有被標記為有副作用 -> 排除它。這也會排除所有如同 components/Breadcrumbs.css 的依賴,盡管它們都被標記為有副作用。
  • ?components/Button.js?: 直接的導出被使用,沒有被標記為有副作用 -> 導入它
  • ?components/Button.css?: 沒有導出被使用,但被標記為有副作用 -> 導入它

在這種情況下,只有 4 個模塊被導入到 bundle 中:

  • ?index.js?: 基本為空的
  • ?configure.js?
  • ?components?/?Button.js?
  • ?components?/?Button.css?

在這次的優(yōu)化后,其它的優(yōu)化項目都可以應(yīng)用。例如:從 ?Button.js? 導出 的buttonFrom 和 buttonsFrom 也沒有被使用。usedExports 優(yōu)化會撿起這些代碼而且 terser 會能夠從 bundle 中把這些語句摘除出來。

模塊合并也會應(yīng)用。所以這 4 個模塊,加上入口的模塊(也可能有更多的依賴)會被合并。index.js 最終沒有生成代碼.

將函數(shù)調(diào)用標記為無副作用

是可以告訴 webpack 一個函數(shù)調(diào)用是無副作用的,只要通過 /*#__PURE__*/ 注釋。它可以被放到函數(shù)調(diào)用之前,用來標記它們是無副作用的(pure)。傳到函數(shù)中的入?yún)⑹菬o法被剛才的注釋所標記,需要單獨每一個標記才可以。如果一個沒被使用的變量定義的初始值被認為是無副作用的(pure),它會被標記為死代碼,不會被執(zhí)行且會被壓縮工具清除掉。當 optimization.innerGraph 被設(shè)置成 true 時這個行為會被啟用。

file.js

/*#__PURE__*/ double(55);

壓縮輸出結(jié)果

通過 import 和 export 語法,我們已經(jīng)找出需要刪除的“未引用代碼(dead code)”,然而,不僅僅是要找出,還要在 bundle 中刪除它們。為此,我們需要將 mode 配置選項設(shè)置為 production。

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: 'production',
};

準備就緒后,然后運行另一個命令 npm run build,看看輸出結(jié)果有沒有發(fā)生改變。

你發(fā)現(xiàn) dist/bundle.js 中的差異了嗎?現(xiàn)在整個 bundle 都已經(jīng)被 minify(壓縮) 和 mangle(混淆破壞),但是如果仔細觀察,則不會看到引入 square 函數(shù),但能看到 cube 函數(shù)的混淆破壞版本(function r(e){return e*e*e}n.a=r)?,F(xiàn)在,隨著 minification(代碼壓縮) 和 tree shaking,我們的 bundle 減小幾個字節(jié)!雖然,在這個特定示例中,可能看起來沒有減少很多,但是,在有著復雜依賴樹的大型應(yīng)用程序上運行 tree shaking 時,會對 bundle 產(chǎn)生顯著的體積優(yōu)化。

結(jié)論

我們學到為了利用 tree shaking 的優(yōu)勢, 你必須...

  • 使用 ES2015 模塊語法(即 import 和 export)。
  • 確保沒有編譯器將您的 ES2015 模塊語法轉(zhuǎn)換為 CommonJS 的(順帶一提,這是現(xiàn)在常用的 @babel/preset-env 的默認行為,詳細信息請參閱文檔)。
  • 在項目的 package.json 文件中,添加 "sideEffects" 屬性。
  • 使用 mode 為 "production" 的配置項以啟用更多優(yōu)化項,包括壓縮代碼與 tree shaking。

你可以將應(yīng)用程序想象成一棵樹。綠色表示實際用到的 source code(源碼) 和 library(庫),是樹上活的樹葉?;疑硎疚匆么a,是秋天樹上枯萎的樹葉。為了除去死去的樹葉,你必須搖動這棵樹,使它們落下。

如果你對優(yōu)化輸出很感興趣,請進入到下個指南,來了解 生產(chǎn)環(huán)境 構(gòu)建的詳細細節(jié)。

Further Reading


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號