樹狀搖晃

樹狀搖晃是 JavaScript 環境中常用的術語,用於消除無用程式碼。它依賴於 ES2015 模組語法的 靜態結構,即 importexport。名稱和概念已由 ES2015 模組打包器 rollup 推廣。

webpack 2 版本內建支援 ES2015 模組(別名harmony 模組)以及未使用的模組匯出偵測。新的 webpack 4 版本擴充此功能,提供一種透過 "sideEffects" package.json 屬性提供提示給編譯器的方法,用於表示專案中哪些檔案是「純粹的」,因此如果未使用的話,可以安全地修剪。

新增公用程式

讓我們在專案中新增一個新的公用程式檔案 src/math.js,它會匯出兩個函式

專案

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 組態選項為 開發,以確保套件不會被壓縮

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 方法。該函式是所謂的「無用程式碼」,表示未使用的 export,應該刪除。現在,讓我們執行我們的 npm 腳本,npm run build,並檢查輸出套件

dist/bundle.js(約在第 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 沒有被 import,但它仍然包含在套件中。我們將在下一節中修復它。

將檔案標記為無副作用

在 100% ESM 模組的世界中,識別副作用很簡單。然而,我們還沒有達到那個階段,因此在過渡期間,有必要向 webpack 編譯器提供關於程式碼「純淨性」的提示。

實現此目的的方法是 "sideEffects" package.json 屬性。

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

上述所有程式碼都不包含副作用,因此我們可以將屬性標記為 false,以告知 webpack 它可以安全地修剪未使用的 export。

如果你的程式碼確實有一些副作用,則可以提供一個陣列

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

陣列接受與相關檔案的簡單 glob 模式。它在幕後使用 glob-to-regexp(支援:***{a,b}[a-z])。不包含 / 的模式,例如 *.css,將被視為 **/*.css

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

最後,"sideEffects" 也可從 module.rules 設定選項 設定。

釐清 tree shaking 和 sideEffects

sideEffectsusedExports(更常稱為 tree shaking)最佳化是兩回事。

sideEffects 有效得多,因為它允許略過整個模組/檔案和完整的子樹。

usedExports 依賴 terser 來偵測陳述式中的副作用。這在 JavaScript 中是一項困難的任務,而且不如直接的 sideEffects 標記有效。它也不能略過子樹/依賴項,因為規範指出必須評估副作用。雖然匯出函式運作良好,但 React 的高階元件 (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 已呼叫,而且回傳值也已呼叫。呼叫 mergehoistStatics 時是否有任何副作用?指定 WithProvider.contextTypes(Setter?)或讀取 WrappedComponent.contextTypes(Getter?)時是否有任何副作用?

Terser 實際上會嘗試找出答案,但在許多情況下它並不確定。這並不表示 terser 沒有做好它的工作,因為它無法找出答案。在 JavaScript 這類動態語言中,要可靠地確定這一點太困難了。

但我們可以使用 /*#__PURE__*/ 註解來協助 terser。它將陳述式標記為沒有副作用。因此,一個小變更就可以 tree-shake 程式碼

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

這將允許移除這段程式碼。但對於需要包含/評估的匯入仍有疑問,因為它們可能包含副作用。

為了解決這個問題,我們在 package.json 中使用 "sideEffects" 屬性。

它類似於 /*#__PURE__*/,但是在模組層級而不是陳述層級。它表示 ("sideEffects" 屬性):「如果沒有使用標記為 no-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"; 這有以下含意

  • 包含它:包含模組、評估它並繼續分析相依性
  • 略過它:不要包含它、不要評估它,但繼續分析相依性
  • 排除它:不要包含它、不要評估它,也不要分析相依性

特別是針對每個相符的資源

  • index.js:沒有使用直接匯出,但標記為 sideEffects -> 包含它
  • configure.js:沒有使用匯出,但標記為 sideEffects -> 包含它
  • types/index.js:沒有使用匯出,未標記為 sideEffects -> 排除它
  • components/index.js:沒有使用直接匯出,未標記為 sideEffects,但使用重新匯出的匯出 -> 略過
  • components/Breadcrumbs.js:沒有使用匯出,未標記為 sideEffects -> 排除它。這也排除了所有相依性,例如 components/Breadcrumbs.css,即使它們標記為 sideEffects。
  • components/Button.js:使用直接匯出,未標記為 sideEffects -> 包含它
  • components/Button.css:沒有使用 export,但標記為 sideEffects -> 包含它

在這種情況下,只包含 4 個模組到 bundle

  • index.js:幾乎是空的
  • configure.js
  • components/Button.js
  • components/Button.css

在此最佳化之後,其他最佳化仍可套用。例如:Button.js 中的 buttonFrombuttonsFrom 匯出也未被使用。usedExports 最佳化會選取它,而 terser 可能可以從模組中刪除一些陳述式。

模組串接也會套用。因此,這 4 個模組加上入口模組(以及可能更多依賴項)可以串接。index.js 最後沒有產生任何程式碼

將函式呼叫標記為無副作用

可以使用 /*#__PURE__*/ 註解告訴 webpack 函式呼叫是無副作用(純粹)的。可以將它放在函式呼叫前面,將它們標記為無副作用。傳遞給函式的引數不會被註解標記,可能需要個別標記。當未使用的變數變數宣告中的初始值被視為無副作用(純粹)時,它會被標記為死程式碼,不會被執行,並被最小化程式移除。當 optimization.innerGraph 設為 true 時,會啟用此行為。

file.js

/*#__PURE__*/ double(55);

最小化輸出

因此,我們已提示我們的「無用程式碼」使用 importexport 語法予以刪除,但我們仍需要將它從套件中刪除。為此,請將 mode 設定選項設定為 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 並查看是否有任何變更。

您是否注意到 dist/bundle.js 有任何不同?整個套件現在已縮小並混淆,但是,如果您仔細查看,您將不會看到包含的 square 函數,但會看到 cube 函數的混淆版本 (function r(e){return e*e*e}n.a=r)。透過縮小和樹狀搖晃,我們的套件現在小了幾個位元組!雖然在這個人為範例中這似乎並不多,但當處理具有複雜相依樹狀結構的較大型應用程式時,樹狀搖晃可以大幅縮小套件大小。

結論

我們學到的是,為了利用樹狀搖晃,您必須...

  • 使用 ES2015 模組語法 (亦即 importexport)。
  • 確保沒有編譯器將您的 ES2015 模組語法轉換為 CommonJS 模組 (這是熱門 Babel 預設值 @babel/preset-env 的預設行為 - 請參閱 文件 以取得更多詳細資訊)。
  • "sideEffects" 屬性新增至專案的 package.json 檔案。
  • 使用 production mode 組態選項來啟用 各種最佳化,包括縮小化和樹狀搖晃(在開發模式中使用旗標值啟用副作用最佳化)。
  • 請務必為 devtool 設定正確的值,因為其中一些值無法在 production 模式中使用。

你可以將應用程式想像成一棵樹。你實際使用的原始碼和函式庫代表樹木綠色的活葉子。死碼代表樹木棕色、枯死的葉子,會被秋天消耗掉。為了清除枯葉,你必須搖晃樹木,讓它們掉落。

如果你有興趣進一步最佳化輸出,請跳到下一個指南,深入了解如何建置 production

16 貢獻者

simon04zacangeralexjovermavant1dmitriidprobablyupgishlumo10byzykpnevaresEugeneHlushkoAnayaDesigntorifatrahul3vsnitin315