CommonJS 如何運用大型套件

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

本文將說明 CommonJS 的運作方式,以及為何會導致 JavaScript 組合包過大。

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

CommonJS 是 2009 年制定的標準,用於建立 JavaScript 模組的慣例。這個 API 最初是用於網路瀏覽器以外的環境,主要用於伺服器端應用程式。

您可以使用 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

請注意,套件大小為 625 KB。查看輸出內容,您會發現 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));

上方是 ECMAScript 模組,我們會在 index.js 中匯入該模組。我們也定義了 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 plugin移除未使用的匯出項目。雖然這個外掛程式會新增對樹狀圖搖晃的支援,但並未涵蓋依附元件使用 CommonJS 的所有不同方式。這表示您無法獲得與 ES 模組相同的保證。此外,這會在建構程序中加入額外費用,並覆寫預設的 webpack 行為。

結論

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

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

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