CommonJS 如何運用大型套件

瞭解 CommonJS 模組對應用程式樹狀圖搖晃的影響

在這篇文章中,我們會探討 CommonJS 是什麼,以及為什麼您的 JavaScript 檔案包變得比必要大。

摘要:為確保套件組合器能順利最佳化應用程式,請避免依附 CommonJS 模組,並在整個應用程式中使用 ECMAScript 模組語法。

CommonJS 是 2009 年推出的標準 JavaScript 模組慣例。這項功能一開始是供網路瀏覽器以外的使用,主要用於伺服器端應用程式。

您可以使用 CommonJS 定義模組、從中匯出功能,並在其他模組中匯入這些功能。例如,以下程式碼片段定義可匯出以下五個函式的模組:addsubtractmultiplydividemax

// utils.js
const { maxBy } = require('lodash-es');
const fns = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => a / b,
  max: arr => maxBy(arr)
};

Object.keys(fns).forEach(fnName => module.exports[fnName] = fns[fnName]);

日後,其他模組可以匯入並使用部分或所有這些函式:

// index.js
const { add } = require('./utils.js');
console.log(add(1, 2));

使用 node 叫用 index.js 會在主控台輸出數字 3

由於 2010 年代初期瀏覽器缺乏標準化模組系統,CommonJS 也成為 JavaScript 用戶端程式庫的熱門模組格式。

CommonJS 對最終套件大小有何影響?

伺服器端 JavaScript 應用程式的大小不像瀏覽器那麼重要,因此 CommonJS 的設計並非以減少正式版套件大小為目標。同時,分析顯示,JavaScript 套件大小仍是導致瀏覽器應用程式速度變慢的首要原因。

JavaScript 套件和壓縮器 (例如 webpackterser) 會執行不同的最佳化作業,以縮減應用程式大小。這些工具會在建構期間分析應用程式,並盡可能移除您未使用的來源程式碼。

舉例來說,在上述程式碼片段中,最終的套件應只包含 add 函式,因為這是您在 index.js 中匯入的 utils.js 中唯一的符號。

讓我們使用下列 webpack 設定建構應用程式:

const path = require('path');
module.exports = {
  entry: 'index.js',
  output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
  },
  mode: 'production',
};

我們在此指定要使用正式版模式最佳化功能,並使用 index.js 做為進入點。叫用 webpack 後,如果我們探索輸出大小,會看到類似下列的內容:

$ cd dist && ls -lah
625K Apr 13 13:04 out.js

請注意,套件為 625KB。查看輸出內容,您會發現 utils.js 中的所有函式,以及 lodash 中的許多模組。雖然我們不會在 index.js 中使用 lodash,但它是輸出內容的一部分,因此會為我們的製作資產增加許多額外負擔。

接下來,請將模組格式變更為 ECMAScript 模組,然後再試一次。這次 utils.js 會如下所示:

export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export const multiply = (a, b) => a * b;
export const divide = (a, b) => a / b;

import { maxBy } from 'lodash-es';

export const max = arr => maxBy(arr);

index.js 會使用 ECMAScript 模組語法從 utils.js 匯入:

import { add } from './utils.js';

console.log(add(1, 2));

我們可以使用相同的 webpack 設定,建構應用程式並開啟輸出檔案。現在是 40 個位元組,並顯示以下輸出結果

(()=>{"use strict";console.log(1+2)})();

請注意,最終套件不包含我們未使用的 utils.js 函式,且沒有 lodash 的追蹤記錄!進一步來說,terser (webpack 使用的 JavaScript 壓縮器) 將 console.log 中的 add 函式內嵌。

您可能會想:為何使用 CommonJS 會使輸出套件的大小變成將近 16,000 倍?當然,這是個簡單的例子,實際上大小差異可能不會那麼大,但 CommonJS 很可能會為您的正式版建構增加大量重量。

CommonJS 模組通常比 ES 模組的動態還多,因此一般來說會難以進行最佳化。為確保套件組合器和壓縮器能順利最佳化應用程式,請避免依附 CommonJS 模組,並在整個應用程式中使用 ECMAScript 模組語法。

請注意,即使您在 index.js 中使用 ECMAScript 模組,如果您使用的模組是 CommonJS 模組,應用程式的套件大小就會受到影響。

為什麼 CommonJS 會擴大您的應用程式規模?

為了回答這個問題,我們將探討 webpack 中的 ModuleConcatenationPlugin 行為,然後討論靜態可分析性。這個外掛程式會將所有模組的範圍串連為單一閉包,讓程式碼在瀏覽器中執行速度更快。讓我們看看以下範例:

// utils.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
// index.js
import { add } from './utils.js';
const subtract = (a, b) => a - b;

console.log(add(1, 2));

以上是我們匯入 index.js 的 ECMAScript 模組。我們也定義了 subtract 函式。我們可以使用相同的 webpack 設定來建構專案,但這次將停用最小化原則:

const path = require('path');

module.exports = {
  entry: 'index.js',
  output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
  },
  optimization: {
    minimize: false
  },
  mode: 'production',
};

讓我們看看產生的輸出內容:

/******/ (() => { // webpackBootstrap
/******/    "use strict";

// CONCATENATED MODULE: ./utils.js**
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;

// CONCATENATED MODULE: ./index.js**
const index_subtract = (a, b) => a - b;**
console.log(add(1, 2));**

/******/ })();

在上方的輸出內容中,所有函式都位於同一個命名空間中。為避免衝突,Webpack 將 index.js 中的 subtract 函式重新命名為 index_subtract

如果壓縮器處理上述原始碼,會發生以下情況:

  • 移除未使用的函式 subtractindex_subtract
  • 移除所有註解和多餘的空格
  • console.log 呼叫中內嵌 add 函式的主體

開發人員經常會將這項未使用的匯入作業移除為樹動。唯有在建構期間,Webpack 能夠以靜態方式瞭解從 utils.js 匯入哪些符號,以及所匯出的符號,才能回溯觀察。

根據預設,ES 模組會啟用這項行為,因為相較於 CommonJS,這些模組更容易進行靜態分析

我們來看看完全相同的範例,但這次請將 utils.js 改為使用 CommonJS 而非 ES 模組:

// utils.js
const { maxBy } = require('lodash-es');

const fns = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => a / b,
  max: arr => maxBy(arr)
};

Object.keys(fns).forEach(fnName => module.exports[fnName] = fns[fnName]);

這項小幅更新會大幅改變輸出內容。由於這段文字太長,無法嵌入本頁面,因此我只分享其中的一小部分:

...
(() => {

"use strict";
/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(288);
const subtract = (a, b) => a - b;
console.log((0,_utils__WEBPACK_IMPORTED_MODULE_0__/* .add */ .IH)(1, 2));

})();

請注意,最終套件含有一些 webpack「執行階段」:插入的程式碼,負責從套裝模組匯入/匯出功能。這次我們並未將 utils.jsindex.js 的所有符號放在同一個命名空間下,而是在執行階段動態要求使用 __webpack_require__add 函式。

這是必要的,因為 CommonJS 可從任意運算式取得匯出名稱。舉例來說,下列程式碼是絕對有效的結構:

module.exports[localStorage.getItem(Math.random())] = () => {  };

因為需要使用者瀏覽器的執行階段資訊,因此 Bundler 無法在建構期間得知匯出的符號名稱。

這樣一來,縮減器就無法瞭解 index.js 從其依附元件使用哪些內容,因此無法透過樹狀結構移除這些內容。我們也會觀察第三方模組的行為是否完全相同。如果我們從 node_modules 匯入 CommonJS 模組,您的建構工具鏈將無法妥善最佳化。

使用 CommonJS 進行樹木搖晃

由於 CommonJS 模組的定義是動態的,因此分析起來會更加困難。舉例來說,在 ES 模組中,匯入位置一律是字串文字常值,而 CommonJS 則是運算式。

在某些情況下,如果您使用的程式庫遵循 CommonJS 使用方式的特定慣例,則可在建構期間使用第三方 webpack 外掛程式移除未使用的匯出項目。雖然這個外掛程式會新增對樹狀圖搖晃的支援,但並未涵蓋依附元件使用 CommonJS 的所有不同方式。也就是說,您取得的保證和 ES 模組的保證也不同。此外,除了預設的 webpack 行為之外,這會導致建構程序中出現額外費用。

結論

為確保套件組合器能順利最佳化應用程式,請避免依賴 CommonJS 模組,並在整個應用程式中使用 ECMAScript 模組語法。

以下提供幾個實用訣竅,協助您確認是否已採用最佳做法:

  • 請使用 Rollup.js 的 node-resolve 外掛程式,並設定 modulesOnly 標記,指定您要僅依賴 ECMAScript 模組。
  • 使用套件 is-esm 驗證 npm 套件是否使用 ECMAScript 模組。
  • 如果您使用 Angular,根據預設,如果您依附非樹狀可搖晃的模組,系統會發出警告。