Webpack Tree Shaking

2023-05-18 10:08 更新

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

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

添加一個(gè)通用模塊

在我們的項(xiàng)目中添加一個(gè)新的通用模塊文件 src/math.js,并導(dǎo)出兩個(gè)函數(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 不會(huì)被壓縮:

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

配置完這些后,更新入口腳本,使用其中一個(gè)新方法,并且為了簡(jiǎn)化示例,我們先將 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());

注意,我們沒(méi)有從 src/math.js 模塊中 import 另外一個(gè) square 方法。這個(gè)函數(shù)就是所謂的“未引用代碼(dead code)”,也就是說(shuō),應(yīng)該刪除掉未被引用的 export?,F(xiàn)在運(yù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 注釋。如果你觀察它下面的代碼,你會(huì)注意到雖然我們沒(méi)有引用 square,但它仍然被包含在 bundle 中。我們將在下一節(jié)解決這個(gè)問(wèn)題。

將文件標(biāo)記為 side-effect-free(無(wú)副作用)

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

通過(guò) package.json 的 "sideEffects" 屬性,來(lái)實(shí)現(xiàn)這種方式。

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

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

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

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

此數(shù)組支持簡(jiǎn)單的 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 配置選項(xiàng) 中設(shè)置 "sideEffects"。

解釋 tree shaking 和 sideEffects

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

sideEffects 更為有效 是因?yàn)樗试S跳過(guò)整個(gè)模塊/文件和整個(gè)文件子樹。

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

讓我們來(lái)看一個(gè)例子:

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

打包前的文件版本看起來(lái)是這樣的:

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

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

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

但我們可以通過(guò) /*#__PURE__*/ 注釋來(lái)幫忙 terser。它給一個(gè)語(yǔ)句標(biāo)記為沒(méi)有副作用。就這樣一個(gè)簡(jiǎn)單的改變就能夠使下面的代碼被 tree-shake:

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

這會(huì)使得這段代碼被過(guò)濾,但仍然會(huì)有一些引入的問(wèn)題,需要對(duì)其進(jìn)行評(píng)估,因?yàn)樗鼈儺a(chǎn)生了副作用。

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

它類似于 /*#__PURE__*/ 但是作用于模塊的層面,而不是代碼語(yǔ)句的層面。它表示的意思是(指"sideEffects" 屬性):“如果被標(biāo)記為無(wú)副作用的模塊沒(méi)有被直接導(dǎo)出使用,打包工具會(huì)跳過(guò)進(jìn)行模塊的副作用分析評(píng)估?!薄?/p>

在一個(gè) 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"
],
// ...

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

  • 導(dǎo)入它:導(dǎo)入并包含該模塊,分析評(píng)估它并繼續(xù)進(jìn)行依賴分析
  • 跳過(guò)它:不導(dǎo)入它,不分析評(píng)估它但會(huì)繼續(xù)進(jìn)行依賴分析
  • 排除它:不導(dǎo)入它,不評(píng)估且不做依賴分析

以下是每個(gè)匹配到的資源的情況:

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

在這種情況下,只有 4 個(gè)模塊被導(dǎo)入到 bundle 中:

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

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

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

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

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

file.js

/*#__PURE__*/ double(55);

壓縮輸出結(jié)果

通過(guò) import 和 export 語(yǔ)法,我們已經(jīng)找出需要?jiǎng)h除的“未引用代碼(dead code)”,然而,不僅僅是要找出,還要在 bundle 中刪除它們。為此,我們需要將 mode 配置選項(xiàng)設(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',
};

準(zhǔn)備就緒后,然后運(yùn)行另一個(gè)命令 npm run build,看看輸出結(jié)果有沒(méi)有發(fā)生改變。

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

結(jié)論

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

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

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

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

Further Reading


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

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)