CommonJS 如何運用大型套件

瞭解 CommonJS 模組如何影響應用程式的樹狀結構

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

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

什麼是 CommonJS?

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 個位元組,具有下列 output

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

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

您可能會想:為何使用 CommonJS 會使輸出套件的大小變成將近 16,000 倍?當然,這是玩具範例,在現實中,大小差異可能不大,但 CommonJS 會大幅提高您的生產版本權重。

CommonJS 模組通常比 ES 模組的動態更多,因此一般來說要最佳化。為確保您的 Bundler 和壓縮程式能夠成功最佳化應用程式,請避免採用 CommonJS 模組,並在整個應用程式中使用 ECMAScript 模組語法。

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

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

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

// 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())] = () => {  };

組合器在建構期間無法得知匯出符號的名稱為何,因為這需要在使用者瀏覽器環境下只能在執行階段取得的資訊。

這樣一來,壓縮器就無法瞭解其依附元件的index.js實際使用了哪些項目,因而無法將其回溯。我們也會觀察第三方模組的行為是否相同。如果從 node_modules 匯入 CommonJS 模組,建構工具鍊將無法正確進行最佳化。

使用 CommonJS 進行樹木搖晃

CommonJS 模組是由定義動態決定,因此難以分析。舉例來說,ES 模組中的匯入位置一律為字串常值,而 CommonJS 可以是運算式,

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

結論

為確保 Bundler 能夠成功最佳化應用程式,請避免仰賴 CommonJS 模組,並在整個應用程式中使用 ECMAScript 模組語法。

以下提供幾個可行提示,有助於驗證您走在最佳路徑上:

  • 使用 Rollup.js 的 node-resolve 外掛程式,並設定 modulesOnly 旗標,指定只想使用 ECMAScript 模組。
  • 使用套件 is-esm 驗證 npm 套件是否使用 ECMAScript 模組。
  • 如果使用 Angular,系統會使用無法還原的模組 (非樹狀結構式模組) 顯示警告。