不要再依賴CommonJS了

在這篇文章中,我們將研究什麼是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模塊語法。

原文鏈接:

https://web.dev/commonjs-larger-bundles/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章