[譯] 如何打造可被搖樹優化的庫

如何打造可被搖樹優化的庫

如何打造可被搖樹優化的庫

Theodo,咱們致力於爲咱們的客戶打造可靠、快速的應用。咱們有一些改進現有應用的性能的項目。在其中一個項目裏,咱們經過對內部庫進行搖樹優化(tree shaking),成功將全部頁面的 gzip 壓縮後的 bundle 體積減小了足足 500KB。前端

在作這件事的時候,咱們意識到搖樹優化並非一種簡單地開啓或關閉就會奏效的技術。有不少因素會影響一個庫的搖樹優化效果。node

本文的目標是爲構建適配搖樹優化的庫提供詳細的指導。其中的步驟的總結以下:android

  • 在一個受控的環境下,針對一個已知的應用來檢查咱們的庫是否能夠被搖樹優化。
  • 使用 ES6 模塊讓打包工具(bundler)得以檢測到未引用的 export 語句。
  • 使用反作用(side effects)優化,讓你的庫不包含任何反作用。
  • 將庫的代碼邏輯分割到若干個小的模塊中,同時保留庫的模塊樹(module tree)。
  • 在轉譯(transpile)庫時,不要丟失模塊樹或者 ES 模塊特徵(ES modules characteristics)。
  • 使用最新版的可以支持搖樹優化的打包工具。

什麼是搖樹優化?爲何它很重要?

引用 MDN 文檔webpack

Tree shaking 是一個一般用於描述移除 JavaScript 上下文中的未引用代碼(dead code)行爲的術語。ios

它依賴於 ES2015 中的 importexport 語句,用來檢測代碼模塊是否被導出、導入,且被 JavaScript 文件使用。git

搖樹優化是一種實現移除未引用代碼(dead code elimination)的方式,實現的方式是檢測哪些導出項(export)在應用代碼裏未被引用。它會被相似 WebpackRollup 這樣的打包工具執行,最先由 Rollup 實現。github

那麼,爲何它叫搖樹優化? 咱們能夠把應用的導出項(exports)和導入項(imports)想象成一棵樹的樣子。樹上健康的葉子和樹枝表示引用了的導入項。而死亡的葉子表示未引用的代碼,它們和樹的其餘部分是分開的。這時候搖動這棵樹,那麼全部死亡的葉子會被搖下來,即未引用的代碼會被移除。web

爲何搖樹優化很重要? 它可以對你的瀏覽器應用產生巨大的影響。若是應用打包了更多的代碼,那麼瀏覽器將花費更多時間去下載、解壓、轉換和執行它們。所以,對於打造速度最快的應用而言,移除未引用的代碼相當重要。算法

網上有不少解釋搖樹優化和未引用代碼移除的文章和資源。這裏咱們會集中討論那些應用裏中引用的庫。當引用一個庫的應用可以成功地移除這個庫中未被引用的部分時,這個庫才能被認爲作到了搖樹優化(tree shakeable)。typescript

一個可搖樹優化的庫的例子

在嘗試讓一個庫變得可被搖樹優化以前,咱們先來看看如何識別一個可搖樹優化的庫。

在受控環境下識別一個不可搖樹優化的庫

這乍看上去好像很簡單,可是我注意到不少開發者都會認爲他們的庫是可搖樹優化的,僅僅是由於它們使用了 ES6 模塊(後文會細講),或者是由於它們有對搖樹優化很友好的配置。不幸的是,這並不意味着你的庫其實是可搖樹優化的!

因而,咱們被帶到了這個問題上:如何高效地檢查一個庫是否可搖樹優化?

要作到這件事,咱們須要理解兩件事情:

  • 最終移除咱們庫中未引用代碼的,是應用程序的打包工具,而不是庫本身的打包工具(若是庫有的話)。畢竟,只有應用程序本身知道使用了庫的哪部分。
  • 庫的職責是確保它本身可以被最終的打包工具進行搖樹優化

要檢查咱們的庫是否可搖樹優化,咱們能夠將它放在一個受控的環境下,使用一個引用了它的應用進行測試:

  1. 建立一個簡單的應用(咱們稱它爲「引用應用」),給它搭配一個你會配置的打包工具,這個打包工具須要支持搖樹優化(好比 Webpack 或者 Rollup)。
  2. 將被檢查的庫設置爲應用的依賴。
  3. 僅僅導入庫的一個元素,檢查應用的打包工具的輸出。
  4. 檢查輸出中是否只包含被導入的元素及其依賴。

這個策略可以將測試與咱們現有的應用隔離。它可讓咱們隨意地擺弄庫而不破壞任何東西。它還能讓咱們確保出現的問題不是來自於應用打包工具的配置上。

咱們接下來會將這種策略應用到一個叫作 user-library 的庫上,使用一個由 Webpack 進行打包的應用 user-app 進行測試。你也可使用其餘你更喜歡的打包工具。

user-library 的代碼以下所示:

export const getUserName = () => "John Doe";

export const getUserPhoneNumber = () => "***********";
複製代碼

它僅僅是在 index.js 文件裏導出了兩個函數,這兩個函數能夠經過 npm 包來使用。

讓咱們編寫簡單的 user-app

package.json

{
  "name": "user-app",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "webpack"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "webpack": "^5.18.0",
    "webpack-cli": "^4.3.1"
  },
  "dependencies": {
    "user-library": "1.0.0"
  }
}
複製代碼

注意,咱們使用 user-library 做爲依賴。

webpack.config.js

const path = require("path");

module.exports = {
  entry: "./src/index.js",
  output: {
    filename: "main.js",
    path: path.resolve(__dirname, "dist"),
  },
  mode: "development",
  optimization: {
    usedExports: true,
    innerGraph: true,
    sideEffects: true,
  },
  devtool: false,
};
複製代碼

要理解上面的 Webpack 配置,咱們須要先理解 Webpack 是如何進行搖樹優化的。搖樹優化進行的步驟以下所示:

  • 識別出應用的入口文件(entry file)(由 Webpack 配置文件指定)
  • 經過遍歷入口文件導入的全部的依賴和它們各自的依賴,來建立應用的模塊樹(module tree)
  • 對樹中的每個模塊,識別出它的哪些 export 語句沒有被其餘模塊所導入。
  • 使用像 UglifyJS 或者 Terser 這樣的代碼最小化(minification)工具來移除未引用的導出項,以及它們的相關代碼。

這些步驟僅在 生產模式(production mode) 下才會被執行。

生產模式的問題在於代碼最小化(minification)。它會讓咱們難以分辨搖樹優化是否生效,由於在打包後的代碼裏咱們看不到原來命名的函數。

爲了繞過這個問題,咱們會讓 Webpack 運行在開發模式(development mode)下,但仍然讓它識別哪些代碼未被引用而且會在生產模式下被移除。咱們將配置裏的 optimization 設置成以下所示:

optimization: {
    usedExports: true,
    sideEffects: true,
    innerGraph: true,
  }
複製代碼

其中的 usedExports 屬性可以讓 Webpack 識別哪些模塊的導出項沒有被其餘模塊引用。其餘兩個屬性會在後文討論。如今咱們暫且認爲它們可以提升咱們的應用被搖樹優化的效果。

咱們 user-app 的入口文件:src/index.js

import { getUserName } from "user-library";

console.log(getUserName());
複製代碼

打包以後,咱們來分析一下輸出:

/***/ "./node_modules/user-library/dist/index.js":
/*!*************************************************!*\ !*** ./node_modules/user-library/dist/index.js ***! \*************************************************/
/***/ ((__unused_webpack_module, exports) => {

var __webpack_unused_export__;

__webpack_unused_export__ = ({ value: true });

const getUserName = () => 'John Doe';

const getUserPhoneNumber = () => '***********';

exports.getUserName = getUserName;
__webpack_unused_export__ = getUserPhoneNumber;
/***/ })
複製代碼

Webpack 將咱們全部的代碼從新組織到了同一個文件裏。請看其中的 getUserPhoneNumer 導出項,注意到 Webpack 將它標記爲了未引用。它會在生產模式下被移除,而 getUserName 則會被保留,由於它被咱們的入口文件 index.js 所使用。

一個搖樹優化後的庫對應的一張簡單的模塊圖

咱們的庫被搖樹優化了!你能夠再寫一些導入項,重複上面的步驟再查看輸出的代碼。咱們的目的是知道 Webpack 會把咱們庫裏沒有被引用的代碼標記爲未引用。

對於咱們這個簡單的 user-library 來講,一切看上去都還不錯。讓咱們將它變得複雜一些,與此同時咱們會關注搖樹優化的一些條件和優化項。

使用 ES6 模塊來讓打包工具得以識別未被使用的 export

這項要求很是常見,並且不少文檔都有詳細的解釋,但在我看來它們卻有些誤導性。我時常能聽到一些開發者說,咱們應該使用 ES6 模塊來讓咱們的庫可以被搖樹優化。雖然這句話自己是徹底正確的,但 其中包含一種錯誤的觀念,這種觀念覺得僅僅使用 ES6 模塊就足以讓搖樹優化很好地工做。 哎,要是真的這麼簡單,你也毫不會閱讀本文至此!

不過,使用 ES6 模塊確實是搖樹優化的必要條件之一。

JavaScript 代碼的打包格式有不少種:ESM、CJS、UMD、IIFE 等。

爲簡單起見,咱們只考慮兩種格式:ECMA Script 模塊(ESM 或 ES6 模塊)和 CommonJS 模塊(CJS),由於它們在應用庫中受到了最爲普遍的使用。大多數庫會使用 CJS 模塊,由於這樣可以讓它們可以運行在 Node.js 應用裏(不過 Node.js 如今也支持 ESM 了)。在 CJS 誕生好久以後的 2015 年,ES 模塊才伴隨 ECMAScript 2015(也被稱做 ES6)出現,被認爲是 JavaScript 的標準模塊系統。

CJS 格式的例子:

const { userAccount } = require("./userAccount");

const getUserAccount = () => {
  return userAccount;
};

module.exports = { getUserAccount };
複製代碼

ESM 格式的例子:

import { userAccount } from "./userAccount";

export const getUserAccount = () => {
  return userAccount;
};
複製代碼

這兩種格式有着很大的區別:ESM 的導入是靜態的,而 CJS 的導入是動態的。 這意味着咱們能夠在 CJS 中作到如下的事情,可是在 ESM 中不行:

if (someCondition) {
  const { userAccount } = require("./userAccount");
}
複製代碼

雖然這樣看上去更加靈活,但它也意味着打包工具不能在編譯或打包期間構造出一棵有效的模塊樹someCondition 這個變量只有在運行時才能知道它的值,致使打包工具在編譯期間不管 someCondition 的值是什麼都會把 userAccount 給導入進來。這也致使打包工具沒法檢查這些導入項是否真的被使用了,因而把全部 CJS 格式的導入項打包進 bundle 裏。

讓咱們修改 user-library 的代碼來體現這一點。同時,爲了讓這個庫顯得更貼近現實,它如今有兩個文件:

src/userAccount.js

const userAccount = {
  name: "user account",
};

module.exports = { userAccount };
複製代碼

src/index.js

const { userAccount } = require("./userAccount");

const getUserName = () => "John Doe";

const getUserPhoneNumber = () => "***********";

const getUserAccount = () => userAccount;

module.exports = {
  getUserName,
  getUserPhoneNumber,
  getUserAccount,
};
複製代碼

咱們保持 user-app 的入口文件不變,這樣咱們依然不會用到 getUserAccount 函數及其依賴。

/*!*************************************************!*\ !*** ./node_modules/user-library/dist/index.js ***! \*************************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {

const { userAccount } = __webpack_require__(/*! ./userAccount */ "./node_modules/user-library/dist/userAccount.js")

const getUserName = () => 'John Doe'

const getUserPhoneNumber = () => '***********'

const getUserAccount = () => userAccount

module.exports = {
  getUserName,
  getUserPhoneNumber,
  getUserAccount
}
/***/ }),

/***/ "./node_modules/user-library/dist/userAccount.js":
/*!*******************************************************!*\ !*** ./node_modules/user-library/dist/userAccount.js ***! \*******************************************************/
/***/ ((module) => {

const userAccount = {
  name: 'user account'
}

module.exports = { userAccount }
/***/ })
複製代碼

這三個導出項所有都出如今打包輸出裏,而且沒有被 Webpack 標記爲未引用。對於源文件 userAccount 也是如此。

如今,讓咱們來看看將上面的例子改形成 ESM 以後的結果。咱們作的修改是將 requireexports 的語法所有改爲對應的 ESM 的語法。

/*!*************************************************!*\ !*** ./node_modules/user-library/dist/index.js ***! \*************************************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   "getUserName": () => /* binding */ getUserName
/* harmony export */ });
/* unused harmony exports getUserAccount, getUserPhoneNumber */
/* harmony import */ var _userAccount_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./userAccount.js */ "./node_modules/user-library/dist/userAccount.js");

const getUserName = () => 'John Doe';

const getUserPhoneNumber = () => '***********';

const getUserAccount = () => userAccount;

/***/ }),
/***/ "./node_modules/user-library/dist/userAccount.js":
/*!*******************************************************!*\ !*** ./node_modules/user-library/dist/userAccount.js ***! \*******************************************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

/* unused harmony export userAccount */
const userAccount = {
  name: 'user account'
};
/***/ })
複製代碼

注意,getUserAccountgetUserPhoneNumber 都被標記爲了未引用。並且另外一個文件裏的 userAccount 也被標記了。得益於 innerGraph 優化,Webpack 可以將 index.js 文件裏的 getUserAccount 導入項連接到 userAccount 導出項。這讓 Webpack 能夠從入口文件開始,遞歸遍歷它全部的依賴,進而知道每個模塊的哪些導出項未被引用。 由於 Webpack 知道 getUserAccount 沒有被使用,因此它能夠到 userAccount 文件裏對 getUserAccount 的依賴作相同的檢查。

使用 ESM 格式的庫的導出模塊圖

ES 模塊讓咱們能夠在應用代碼裏尋找那些被引用的和未被引用的導出項,這也解釋了爲何這種模塊系統對於搖樹優化是如此的重要。它還解釋了爲何咱們應該使用像 lodash-es 這樣導出了兼容 ESM 的構建產物的依賴。這裏 lodash-es 庫是很受歡迎的 lodash 庫的 ESM 版本。

話雖如此,針對搖樹優化,僅僅使用 ES 模塊仍然不是最佳的方法。在咱們的例子裏,咱們發現 Webpack 會在每一個文件遞歸地檢查導出的代碼是否被引用。對於咱們的例子來講,Webpack 其實能夠直接忽略掉 userAccount 文件,由於它惟一的導出項是未引用的!這就將咱們引入到文章接下來對反作用(side effect)的討論。

本文這一部分的總結以下:

  • ESM 是搖樹優化的條件之一,但僅憑它不足以讓搖樹優化達到理想效果。
  • 確保你的庫老是提供一份 ESM 格式的編譯產物! 若是你的庫的用戶須要 ESM 和 CJS 格式的編譯產物,能夠經過 package.json 中的 mainmodule 屬性來設置。
  • 若是能夠的話,確保老是使用 ESM 格式的依賴,不然它們不能被搖樹優化。

使用反作用優化來讓你的庫不包含反作用

根據 Webpack 的文檔,搖樹優化能夠被分爲如下兩種優化措施:

  • 引用導出(usedExports):斷定一個模塊的哪些導出項是被引用的或未被引用。
  • 反作用(sideEffects):略過那些不包含任何被引用的導出項而且不包含反作用的模塊。

爲了闡釋反作用的含義,讓咱們看看以前用到的例子:

import { userAccount } from "./userAccount";

function getUserAccount() {
  return userAccount;
}
複製代碼

若是 getUserAccount 沒有被使用,打包工具是否能夠認爲 userAccount 模塊能夠從打包輸出中移除呢?答案是否認的!userAccount 能夠作各類可以影響應用的其餘部分的事情。它能夠向全局可訪問的值裏注入一些變量,好比 DOM。它還能夠是一個 CSS 模塊,會向 document 裏注入樣式。不過我以爲最好的例子是 polyfill。咱們一般會像下面這樣引入它們:

import "myPolyfill";
複製代碼

如今這個模塊必定有反作用了,由於一旦它被導入到其餘模塊,它就會影響到整個應用的代碼。打包工具會將這個模塊視爲可能被刪除的候選者,畢竟咱們沒有使用它的任何導出項。不過移除它會破壞咱們應用的正常運行。

WebpackRollup 這樣的打包工具也所以會默認地將咱們庫中的全部模塊視爲包含反作用。

可是在前面的例子裏,咱們知道咱們的庫不包含任何反作用!所以,咱們能夠告訴打包工具這一點。大多數打包工具能夠讀取 package.json 文件裏的 sideEffects 屬性。這個屬性若是沒有指定,那麼它默認會被設爲 true(表示這個包裏全部的模塊都含有反作用)。咱們能夠將它設爲 false(表示全部模塊都不包含反作用)或者也能夠指定一個數組,列舉出含有反作用的源文件。

咱們將這個屬性添加到 user-library 庫的 package.json 中:

{
  "name": "user-library",
  "version": "1.0.0",
  "description": "",
  "sideEffects": false,
  "main": "dist/index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}
複製代碼

而後從新運行 Webpack 進行打包:

/*!*************************************************!*\ !*** ./node_modules/user-library/dist/index.js ***! \*************************************************/
/***/ (__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
  /* harmony export */ __webpack_require__.d(__webpack_exports__, {
    /* harmony export */ getUserName: () => /* binding */ getUserName,
    /* harmony export */
  });
  /* unused harmony exports getUserAccount, getUserPhoneNumber */

  const getUserName = () => "John Doe";

  const getUserPhoneNumber = () => "***********";

  const getUserAccount = () => userAccount;
  /***/
};
複製代碼

咱們發現,源文件 userAccount 已經在打包輸出中被移除了。咱們仍然可以看到 getUserAccount 函數在引用 userAccount,不過此函數已經被 Webpack 標記爲了未引用代碼,它會在代碼最小化的過程當中被移除。

反作用優化下的模塊圖

sideEffects 選項對於那些經過一個 index 文件從其餘內部源文件導出 API 的庫尤爲重要。 若是沒有反作用優化,打包工具就必須解析全部包含導出項的源文件。

正如 Webpack 的提示sideEffects 很是地高效,由於它可以讓打包工具略過整個的模塊或源文件,以及它的整個子樹」

對於前文介紹的兩種優化措施在介入打包過程上的不一樣之處,簡單來講以下所述:

  • sideEffects 讓打包工具略過一個被導入的模塊,若是從這個模塊導入的東西所有都沒有被使用的話。
  • usedExports 讓打包工具移除在一個模塊裏未被引用的導出項。

那麼,上面兩種措施,一個是「略過文件」,一個是「將導出項標記爲未被使用」。前者影響下的打包輸出又和後者有什麼不一樣之處呢?

大多數狀況下,對一個庫進行搖樹優化,有和沒有反作用優化其實會產生如出一轍的輸出。最終的 bundle 裏包含的代碼量是同樣的。不過在某些狀況下,若是分析未引用的導出項的相關代碼的過程過於複雜,那麼有和沒有反作用優化的結果就不同了。本文接下來的部分將包含這兩種狀況的例子,咱們將看到只有小的模塊和開啓反作用優化的組合產生了最好的打包產物。

這一部分的總結以下:

  • 搖樹優化包含兩個部分:引用導出(used exports)優化反作用(side effects)優化
  • side effects 優化相比起檢測每一個模塊中未被使用的導出項,有更高的效率
  • 不要在你的庫裏引入任何反作用。
  • 必定要經過 package.json 文件裏的 sideEffects 屬性告訴打包工具:你的庫不包含任何反作用。

經過保留庫的模塊樹並將代碼分割到小的模塊中,來讓你的庫從 side effects 優化中充分獲益

你可能注意到咱們在本文先前的例子裏的 user-library 並無被打包到一個單獨的文件裏,而是直接暴露手動加入的 .js 源文件。

一般,一個庫會因爲如下緣由被打包:

  • 使用了一些自定義的 import 路徑。
  • 使用的是像 Sass 或者 TypeScript 這樣的語言,它們須要轉換到好比 CSS 或者 JavaScript 這樣的語言。
  • 須要知足於提供多種模塊格式(ESM、CJS、IIFE 等)的需求。

WebpackRollupParcelESBuild 這樣的流行的打包工具被設計爲用來提供一個可以傳輸給瀏覽器使用的 bundle。它們也所以傾向於建立一個單獨的的文件,而後將你的全部代碼從新組合並輸出到這個文件裏,從而只有一個單獨的 .js 文件須要經過網絡進行傳輸。

從搖樹優化的角度來講,這致使了一個問題:反作用優化不復存在了,由於沒有模塊可以被略過。

咱們將列舉兩種狀況來講明:對搖樹優化來講,分割模塊搭配反作用優化是必須的。

一個庫模塊導入一個 CJS 格式的依賴

爲了演示這個問題,咱們將使用 Rollup 來打包咱們的庫。同時,咱們將讓庫的其中一個模塊導入一個 CJS 格式的依賴:Lodash

rollup.config.js

export default {
  input: "src/index.js",
  output: {
    file: "dist/index.js",
    format: "esm",
  },
};
複製代碼

userAccount.js

import { isNil } from "lodash";

export const checkExistance = (variable) => !isNil(variable);

export const userAccount = {
  name: "user account",
};
複製代碼

注意,咱們如今將導出 checkExistance,而後將它導入到咱們庫的 index.js 文件裏。

如下是打包輸出的文件 dist/index.js

import { isNil } from "lodash";

const checkExistance = (variable) => !isNil(variable);

const userAccount = {
  name: "user account",
};

const getUserAccount = () => {
  return userAccount;
};

const getUserPhoneNumber = () => "***********";

const getUserName = () => "John Doe";

export { checkExistance, getUserName, getUserPhoneNumber, getUserAccount };
複製代碼

全部文件都被打包到了一個單獨的文件裏。注意 Lodash 也在此文件的頂部被導入。咱們在 user-app 裏仍然導入和之前同樣的函數,這意味着 checkExistance 函數依然未被引用。然而,在運行 Webpack 打包 user-app 以後,咱們發現即便 checkExistance 函數被標記爲了未引用,整個 Lodash 庫仍然被導入了:

/***/ "./node_modules/user-library/dist/index.js":
/*!*************************************************!*\ !*** ./node_modules/user-library/dist/index.js ***! \*************************************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

"use strict";
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   "getUserName": () => (/* binding */ getUserName)
/* harmony export */ });
/* unused harmony exports checkExistance, userAccount, getUserPhoneNumber, getUserAccount */
/* harmony import */ var lodash__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! lodash */ "./node_modules/user-library/node_modules/lodash/lodash.js");
/* harmony import */ var lodash__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(lodash__WEBPACK_IMPORTED_MODULE_0__);

const checkExistance = (variable) => !isNil(variable);

const userAccount = {
  name: "user account",
};

const getUserPhoneNumber = {
  number: '***********'
};

const getUserAccount = () => {
  return userAccount
};

const getUserName = () => 'John Doe';

/***/ }),

/***/ "./node_modules/user-library/node_modules/lodash/lodash.js":
/*!*****************************************************************!*\ !*** ./node_modules/user-library/node_modules/lodash/lodash.js ***! \*****************************************************************/
/***/ (function(module, exports, __webpack_require__) {

/* module decorator */ module = __webpack_require__.nmd(module);
var __WEBPACK_AMD_DEFINE_RESULT__;/** * @license * Lodash <https://lodash.com/> * Copyright OpenJS Foundation and other contributors <https://openjsf.org/> * Released under MIT license <https://lodash.com/license> * Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE> * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors */
// ...
複製代碼

Webpack 沒法對 Lodash 進行搖樹優化,由於它的模塊格式是 CJS。這挺讓人失望的,畢竟咱們都很明顯地組織好了咱們的庫,讓 Lodash 僅在 userAccount 模塊裏被導入,並且這個模塊也沒有被咱們的應用所引用。若是模塊結構可以保留,Webpack 就能受益於反作用優化,從而可以檢測到 userAccount 的導出項都未被引用而後直接略過這個模塊,這樣的話 Lodash 就不會被打包了。

在 Rollup 中,咱們能夠使用 preserveModules 選項來保留庫的模塊結構。其餘打包工具也有相似的選項。

export default {
  input: "src/index.js",
  output: {
    dir: "dist",
    format: "esm",
    preserveModules: true,
  },
};
複製代碼

Rollup 如今可以保留本來的文件結構了,咱們再次運行 Webpack,獲得如下的打包輸出:

/***/ "./node_modules/user-library/dist/index.js":
/*!*************************************************!*\ !*** ./node_modules/user-library/dist/index.js ***! \*************************************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   "getUserName": () => (/* binding */ getUserName)
/* harmony export */ });
/* unused harmony export getUserAccount */

const getUserAccount = () => {
  return userAccount
};

const getUserName = () => 'John Doe';

/***/ })
複製代碼

Lodash 如今和 userAccount 模塊一塊兒被略過了。

在使用 CJS 格式的依賴時,保留模塊結構可以改善搖樹優化效果

代碼分割

保留分割後的模塊結構以及開啓反作用優化也有助於 Webpack 的代碼分割。它對於大型應用來講是一個關鍵的優化措施,被普遍應用於那些含有多個頁面的 Web 應用。像 NuxtNext 這樣的框架都會給各個頁面配置代碼分割。

爲了演示代碼分割帶來的好處,咱們先來看看若是咱們的庫被打包到了一個單獨的文件時會發生什麼。

user-library/src/userAccount.js

export const userAccount = {
  name: "user account",
};
複製代碼

user-library/src/userPhoneNumber.js

export const userPhoneNumber = {
  number: "***********",
};
複製代碼

user-library/src/index.js

import { userAccount } from "./userAccount";
import { userPhoneNumber } from "./userPhoneNumber";

const getUserName = () => "John Doe";

export { userAccount, getUserName, userPhoneNumber };
複製代碼

爲了對咱們的應用進行代碼分割,咱們會使用 Webpack 的 import 語法

user-app/src/userService1.js

import { userAccount } from "user-library";

export const logUserAccount = () => {
  console.log(userAccount);
};
複製代碼

user-app/src/userService2.js

import { userPhoneNumber } from "user-library";

export const logUserPhoneNumber = () => {
  console.log(userPhoneNumber);
};
複製代碼

user-app/src/index.js

const main = async () => {
  const { logUserPhoneNumber } = await import("./userService2");
  const { logUserAccount } = await import("./userService1");

  logUserAccount();
  logUserPhoneNumber();
};

main();
複製代碼

打包產生的文件如今有三個:main.jssrc_userService1_js.main.jssrc_userService2_js.main.js。仔細查看 src_userService1_js.main.js 的內容,咱們能夠發現整個 user-library 都被打包了:

(self["webpackChunkuser_app"] = self["webpackChunkuser_app"] || []).push([
  ["src_userService1_js"],
  {
    /***/ "./node_modules/user-library/dist/index.js":
      /*!*************************************************!*\ !*** ./node_modules/user-library/dist/index.js ***! \*************************************************/
      /***/ ( __unused_webpack_module, __webpack_exports__, __webpack_require__ ) => {
        "use strict";
        /* harmony export */ __webpack_require__.d(__webpack_exports__, {
          /* harmony export */ userAccount: () => /* binding */ userAccount,
          /* harmony export */ userPhoneNumber: () =>
            /* binding */ userPhoneNumber,
          /* harmony export */
        });
        /* unused harmony export getUserName */
        const userAccount = {
          name: "user account",
        };

        const userPhoneNumber = {
          number: "***********",
        };

        const getUserName = () => "John Doe";

        /***/
      },

    /***/ "./src/userService1.js":
      /*!*****************************!*\ !*** ./src/userService1.js ***! \*****************************/
      /***/ ( __unused_webpack_module, __webpack_exports__, __webpack_require__ ) => {
        "use strict";
        __webpack_require__.r(__webpack_exports__);
        /* harmony export */ __webpack_require__.d(__webpack_exports__, {
          /* harmony export */ logUserAccount: () =>
            /* binding */ logUserAccount,
          /* harmony export */
        });
        /* harmony import */ var user_library__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(
          /*! user-library */ "./node_modules/user-library/dist/index.js"
        );

        const logUserAccount = () => {
          console.log(user_library__WEBPACK_IMPORTED_MODULE_0__.userAccount);
        };

        /***/
      },
  },
]);
複製代碼

雖然 getUserName 被標記爲了未引用,但 userAccount 並無被標記,即便 userService2 僅僅使用了 userPhoneNumber。爲何會這樣呢?(譯註:原文中上面的代碼是 userService1 的而不是 userService2

咱們須要記住,引用導出(used exports)優化在檢查導出項是否被引用的時候,是在模塊層面上檢查的。只有從這個層面上 Webpack 才能移除那些未被使用的代碼。對於咱們的庫模塊來講,userAccountuserPhoneNumber 其實都被使用了。在這個狀況下,Webpack 並不能區分清 userService1userService2 在導入項上的區別,正以下圖所示(你會發現 userAccountuserPhoneNumber 都被標註爲綠色):

代碼分割致使搖樹優化出現的問題

這意味着 Webpack 在僅依靠引用導出優化的條件下,並不能獨立地針對每一個 chunk 進行搖樹優化。

如今,讓咱們在打包庫時保留模塊結構,這樣反作用優化就能工做:

Webpack 仍然輸出了 3 個文件,不過這一次 src_userService2_js.main.js 僅僅包含了 userPhoneNumber 裏的代碼:

(self["webpackChunkuser_app"] = self["webpackChunkuser_app"] || []).push([
  ["src_userService2_js"],
  {
    /***/ "./node_modules/user-library/dist/userPhoneNumber.js":
      /*!***********************************************************!*\ !*** ./node_modules/user-library/dist/userPhoneNumber.js ***! \***********************************************************/
      /***/ ( __unused_webpack_module, __webpack_exports__, __webpack_require__ ) => {
        "use strict";
        /* harmony export */ __webpack_require__.d(__webpack_exports__, {
          /* harmony export */ userPhoneNumber: () =>
            /* binding */ userPhoneNumber,
          /* harmony export */
        });
        const userPhoneNumber = {
          number: "***********",
        };

        /***/
      },

    /***/ "./src/userService2.js":
      /*!*****************************!*\ !*** ./src/userService2.js ***! \*****************************/
      /***/ ( __unused_webpack_module, __webpack_exports__, __webpack_require__ ) => {
        "use strict";
        __webpack_require__.r(__webpack_exports__);
        /* harmony export */ __webpack_require__.d(__webpack_exports__, {
          /* harmony export */ logUserPhoneNumber: () =>
            /* binding */ logUserPhoneNumber,
          /* harmony export */
        });
        /* harmony import */ var user_library__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(
          /*! user-library */ "./node_modules/user-library/dist/userPhoneNumber.js"
        );

        const logUserPhoneNumber = () => {
          console.log(
            user_library__WEBPACK_IMPORTED_MODULE_0__.userPhoneNumber
          );
        };

        /***/
      },
  },
]);
複製代碼

src_userService1_js.main.js 也和上面相似,僅僅包含了咱們庫裏的 userAccount 模塊。

保留模塊樹可讓 Webpack 獨立地對分割的 chunk 進行搖樹優化

在上圖中咱們看到,userAccountuserPhoneNumber 仍然被識別爲被引用的導出項,畢竟它們都在應用裏被引用了至少一次。不過,這一次反作用優化讓 Webpack 得以略過 userAccount 模塊,由於它從未被 userService2 所導入。一樣的事情也發生在了 userPhoneNumberuserService1 之間。

咱們如今理解了:保留庫中原始的模塊結構是很重要的。不過,若是原始模塊結構裏只有一個模塊,好比 index.js 文件,而且其中包含着全部的代碼的話,那麼保留這種模塊結構是毫無用處的。要打造一個對搖樹優化有着良好適配的庫,咱們必須將庫的代碼劃分到若干個小的模塊中,同時每一個模塊負責咱們代碼邏輯的一部分。

若是要使用「樹」的比喻,咱們須要將樹上每片葉子視爲一個模塊。更小、更弱的葉子在樹被搖動的時候更容易掉落!若是樹上的葉子更少、更強,那麼搖樹的結果可能就不同了。

本部分的總結以下:

  • 爲了可以充分利用反作用優化,咱們應該保留庫的模塊結構
  • 庫應該被劃分爲多個小的模塊,每一個模塊僅導出整個庫的代碼邏輯的一小部分。
  • 只有在反作用優化的幫助下,咱們在應用裏才能對引用的庫進行搖樹優化。

在轉譯庫代碼時不要丟失模塊樹以及 ES 模塊的特徵

**打包工具並非惟一可以影響你的庫被搖樹優化的東西。**轉譯工具也會對搖樹優化形成負面影響,由於它們會移除 ES 模塊,或者丟失模塊樹。

轉譯工具的目的之一是讓你的代碼可以在那些不支持 ES 模塊的瀏覽器中工做。不過,咱們也須要記住:咱們的庫並不總會直接地被瀏覽器所加載,而是會被應用所導入。因此,鑑於如下兩條理由,咱們不能針對特定瀏覽器來轉譯咱們的庫代碼:

  • 在編寫庫代碼的時候,咱們並不知道咱們的庫會被用到哪些瀏覽器裏,只有使用庫的應用才知道。
  • 轉譯咱們的庫代碼會讓它們變得不可被搖樹優化。

若是你的庫因爲某些緣由確實須要被轉譯,那麼你須要保證轉譯工具不會移除 ES 模塊的語法,以及不會移除本來的模塊結構,緣由正如前文所述。

據我所知,有兩個轉譯工具會移除掉上述的兩個內容。

Babel

Babel 可以使用 Babel preset-env 來讓你的代碼兼容指定的目標瀏覽器(target browsers)。這個插件默認會將庫代碼裏的 ES 模塊移除。爲了不它的發生,咱們須要把 modules 選項設爲 false

module.exports = {
  env: {
    esm: {
      presets: [
        [
          "@babel/preset-env",
          {
            modules: false,
          },
        ],
      ],
    },
  },
};
複製代碼

TypeScript

在編譯你的代碼時,TypeScript 會根據 tsconfig.json 文件裏的 targetmodule 選項來轉換你的模塊。

爲了不它的發生,咱們要targetmodule 選項設置到至少 ES2015ES6

此部分的總結以下:

  • 確保你的轉譯工具和編譯器不會將庫代碼裏的 ES 模塊語法移除。
  • 若是須要檢查上述問題是否存在,能夠查看庫的轉譯/編譯產物裏有沒有 ES 模塊的導入語法。

使用最新版的可進行搖樹優化的打包工具

JavaScript 的搖樹優化在 Rollup 的帶動下流行了起來。Webpack 自從 v2 以來就支持了搖樹優化。各打包工具都在搖樹優化上作得愈來愈好。

還記得咱們在上文講到的 innerGraph 優化嗎?它可以讓 Webpack 將模塊的導出項和其餘模塊的導入項關聯起來。這項優化是在 Webpack 5 中被引入的。咱們在本文裏雖然一直在使用 Webpack 5,不過仍是有必要認識到這項優化改變了整個業界。它可以讓 Webpack 遞歸地尋找未被使用的導出項!

爲了展現它究竟是怎麼作的,考慮 user-library 中的 index.js 文件:

import { userAccount } from "./userAccount";

const getUserAccount = () => {
  return userAccount;
};

const getUserName = () => "John Doe";

export { getUserName, getUserAccount };
複製代碼

咱們的 user-app 僅使用了其中的 getUserName

import { getUserName } from "user-library";

console.log(getUserName());
複製代碼

如今,咱們對比一下在有和沒有 innerGraph 優化的狀況下,打包輸出有什麼不一樣。注意,這裏 usedExportssideEffects 優化都是開啓的。

沒有 innerGraph 優化(好比使用 Webpack 4):

/*!*************************************************!*\ !*** ./node_modules/user-library/dist/index.js ***! \*************************************************/
/*! exports provided: userAccount, userPhoneNumber, getUserName, getUserAccount */
/*! exports used: getUserName */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
 "use strict";
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() { return getUserName; });
/* unused harmony export getUserAccount */
/* harmony import */ var _userAccount_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./userAccount.js */ "./node_modules/user-library/dist/userAccount.js");

const getUserAccount = () => {
  return _userAccount_js__WEBPACK_IMPORTED_MODULE_0__[/* userAccount */ "a"]
};

const getUserName = () => 'John Doe';

/***/ }),

/***/ "./node_modules/user-library/dist/userAccount.js":
/*!*******************************************************!*\ !*** ./node_modules/user-library/dist/userAccount.js ***! \*******************************************************/
/*! exports provided: userAccount */
/*! exports used: userAccount */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
 "use strict";
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() { return userAccount; });
const userAccount = {
  name: 'user account'
};

/***/ }),
複製代碼

innerGraph 優化(好比使用 Webpack 5):

/***/ "./node_modules/user-library/dist/index.js":
/*!*************************************************!*\ !*** ./node_modules/user-library/dist/index.js ***! \*************************************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   "getUserName": () => (/* binding */ getUserName)
/* harmony export */ });
/* unused harmony export getUserAccount */

const getUserAccount = () => {
  return userAccount
};

const getUserName = () => 'John Doe';

/***/ })
複製代碼

Webpack的 innerGraph 優化示例

Webpack 5 可以徹底移除 userAccount 模塊,可是 Webpack 4 不行,即便 getUserAccount 被標記爲了未引用。這是由於 inngerGraph 優化的算法可以讓 Webpack 5 將模塊中未引用的導出項和它對應的導入項連接起來。在咱們的例子裏,userAccount 模塊僅被 getUserAccount 函數所使用,所以能夠被直接略過。

Webpack 4 則沒有這項優化。開發者在使用這個版本的 Webpack 的時候所以應該提升警戒,限制單個源文件裏的導出項數量。 若是一個源文件包含多個導出項,Webpack 會包含全部對應的導入項,即便對於真正被須要的導出項來講有些導入項是多餘的。

總的來講,咱們應該確保老是使用最新版的打包工具,這樣咱們就能從最新的搖樹優化中獲益。

總結

對一個庫進行的搖樹優化,並非在配置文件裏隨便加一行來啓用就能得到很好的效果。它的優化質量取決於多個因素,本文僅僅列出了其中的一小部分。不過,不管咱們遇到的問題是什麼,本文裏作過的如下兩件事情是對任何想要對庫進行搖樹優化的人很重要的:

  • 爲了肯定咱們庫的搖樹優化效果程度,咱們須要在一個受控的環境下使用咱們瞭解的打包工具來進行測試。
  • 爲了檢查咱們庫的配置有沒有問題,咱們不只僅是須要檢查各類配置文件,還要檢查打包輸出。 咱們在本文裏一直都在對 user-libraryuser-app 的例子作這種事情。

我真切地但願本文可以爲你提供幫助,讓你正在進行的構建擁優化程度最高的庫的任務變得可能!

延申閱讀

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索