CommonJS 如何運用大型套件

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

這篇文章將探討什麼是 CommonJS,以及這個問題會使 JavaScript 套件大於必要時間的原因。

摘要:為確保套裝組合能成功將應用程式最佳化,請避免依附於 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

請注意,套件為 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 會擴大應用程式規模?

為了回答這個問題,我們會說明 webpackModuleConcatenationPlugin 的行為,然後討論靜態分析。這個外掛程式能將所有模組的範圍串連成一個封閉,讓程式碼更快在瀏覽器中執行時間。以下面這段程式碼為例:

// 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 匯入的符號,以及匯出的符號。

由於與 CommonJS 相比,ES 模組較為靜態分析,因此系統預設會啟用這項行為。

讓我們看看完全相同的範例,但這次將 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「runtime」:插入的程式碼,負責從封裝模組匯入/匯出功能。這次我們要求在執行階段使用 __webpack_require__ 以動態方式執行 add 函式,而不是將 utils.jsindex.js 的所有符號放在相同命名空間下。

這是因為使用 CommonJS 時,我們可以從任意運算式取得匯出名稱,舉例來說,以下程式碼是一個絕對有效的結構:

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

套件在建構期間無法得知匯出符號的名稱,因為這項資訊只能在執行階段期間,且在使用者的瀏覽器環境下使用。

如此一來,壓縮工具就無法瞭解 index.js 從依附元件中實際使用的功能,因此無法將其搖晃。我們也會觀察到第三方模組的同樣的行為。如果從 node_modules 匯入 CommonJS 模組,您的建構工具鍊將無法正確最佳化。

使用 CommonJS 搖身一變

CommonJS 模組在定義上會動態調整,因此很難分析。舉例來說,與 CommonJS 比較,ES 模組中的匯入位置一律是字串常值,其中 ES 是運算式。

在某些情況下,如果您目前使用的程式庫符合 CommonJS 使用方式的特殊慣例,則可透過第三方 webpack plugin,在建構期間移除未使用的匯出內容。雖然這個外掛程式支援樹狀搖晃,但不涵蓋依附元件可使用 CommonJS 的所有方式。也就是說,這個保證不會與 ES 模組相同。此外,這會在建構程序中使用預設的 webpack 行為增加額外費用。

結語

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

建議您參考下列實用提示,確認自己是否已採取最佳設定:

  • 使用 Rollup.js 的 node-resolve 外掛程式,並設定 modulesOnly 旗標,指定您只須依賴 ECMAScript 模組。
  • 使用 is-esm 套件驗證 npm 套件是否使用 ECMAScript 模組。
  • 根據預設,如果你使用 Angular,如果使用不可樹狀結構搖動的模組,系統將發出警告。