在這篇文章中,我們將研究什麼是CommonJS,以及爲什麼它會讓你的JavaScript包大小過分膨脹。爲了確保打包器(bundler)能成功優化你的應用程序大小,請避免依賴CommonJS模塊,並在整個應用程序中使用ES2015模塊語法。
本文最初發佈於web.dev網站,經原作者Minko Gechev授權由InfoQ中文站翻譯並分享。
什麼是CommonJS?
CommonJS是2009年的標準,爲JavaScript模塊建立了約定。它最初打算在Web瀏覽器之外的場景中使用,主要用於服務端應用程序。
使用CommonJS,你可以定義模塊,從中導出功能,並將它們導入其他模塊中。例如,下面的代碼片段定義了一個模塊,其導出五個函數:add,subtract,multiply,divide和max:
// 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');
console.log(add(1, 2));
使用node調用index.js將在控制檯中輸出數字3。
由於2010年代初期瀏覽器中缺乏標準化的模塊系統,CommonJS也成爲了JavaScript客戶端庫的流行模塊格式。
CommonJS如何影響最終的打包大小?
服務端JavaScript應用程序的大小並不像瀏覽器中那樣重要,所以CommonJS並沒有在設計時考慮到包大小的控制。與此同時,有分析表明JavaScript的包體積仍然是拖慢瀏覽器應用的主要因素之一。
JavaScript打包器和壓縮器(minifier),例如webpack和terser,會執行多種優化措施以減小應用程序的大小。它們在構建時分析你的應用程序,嘗試儘可能刪掉那些沒用到的源代碼。
例如,在上面的代碼片段中,你的最終打包應該只包括add函數,因爲這是你從utils.js中導入到index.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 2015,然後重試。這次,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將使用ES2015模塊語法從utils.js導入:
import { add } from './utils';
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模塊,並在整個應用程序中使用ES2015模塊語法。
請注意,即使你在index.js中使用了ES2015,但如果你使用的模塊是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';
const subtract = (a, b) => a - b;
console.log(add(1, 2));
如上所示,我們有一個ES2015模塊,然後將其導入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。
如果讓一個壓縮器處理上面的源代碼,它將:
- 刪除未使用的subtract和index_subtract函數
- 刪除所有註釋和多餘的空格
- 在console.log調用中內聯add函數的主體
開發人員通常將這種移除未使用的導入的操作稱爲搖樹優化(tree-shaking)。因爲webpack能夠靜態地(在構建時)瞭解我們從utils.js導入及導出的符號,所以它才能實現搖樹優化。
ES模塊默認啓用此行爲,因爲與CommonJS相比,它們更容易進行靜態分析。
我們來看完全相同的示例,但是這次將utils.js更改爲使用CommonJS模塊:
// 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.js和index.js中的所有符號放在同一個命名空間下,而是在運行時動態請求使用__webpack_require__的add函數。
這是必需的,因爲使用CommonJS,我們可以從任意表達式中獲取導出名稱。例如,下面的代碼是絕對有效的構造:
module.exports[localStorage.getItem(Math.random())] = () => { … };
打包器無法在構建時知道導出的符號是什麼名稱,因爲這裏需要的信息在用戶瀏覽器的上下文中,而且僅在運行時可用。
這樣壓縮器就無法從index.js的依賴項中瞭解它到底使用了哪些內容,因此無法將無用代碼優化掉。我們還能觀察到第三方模塊也有完全相同的行爲。如果我們從node_modules導入CommonJS模塊,你的構建工具鏈將無法正確優化它。
基於CommonJS實現搖樹優化
由於CommonJS模塊是動態定義的,因此它們分析起來要困難得多。例如,與CommonJS相比,ES模塊中的導入位置始終是一個字面量(前者則是一個表達式)。
在某些情況下,如果你使用的庫遵循有關CommonJS用法的特別約定,則可以在構建時使用這個第三方webpack插件刪除未使用的導出。但儘管這個插件增加了對搖樹優化的支持,但並未涵蓋依賴項使用CommonJS的所有可能方式。這意味着你無法獲得與ES模塊相同的保障。此外,除了默認的webpack行爲外,它還會在構建過程中增加額外的成本。
結論
總之,再次強調,爲了確保打包器可以成功優化你的應用程序,請避免依賴CommonJS模塊,並在整個應用程序中使用ES2015模塊語法。
原文鏈接: