CommonJS 如何让您的捆绑包变得更大
了解 CommonJS 模块如何影响应用程序的摇树
我们将在本文中研究什么是 CommonJS,以及它为什么会使您的 JavaScript 捆绑包变得异常大。
总结:为确保捆绑程序能够成功优化您的应用程序,避免依赖 CommonJS 模块,并在整个应用程序中使用 ECMAScript 模块语法。
什么是 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 打包器和压缩器,例如webpack和terser ,会执行不同的优化以减小应用程序的大小。它们会在构建时分析应用程序,尝试从您未使用的源代码中尽可能多地删除无用代码。
例如,在上面的代码片段中,最终捆绑包应该只包含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';
console.log(add(1, 2));
使用相同的webpack配置,我们可以构建应用程序并打开输出文件。它现在的大小是 40 个字节,输出如下:
(()=>{"use strict";console.log(1+2)})();
请注意,最终的捆绑包里没有utils.js中未使用的函数以及lodash !更进一步,terser(即webpack使用的 JavaScript 压缩器)在console.log中内联了add函数。
你可能会问,为什么使用 CommonJS 会导致输出包几乎变大了 16000 倍?当然,这只是随便举的一个例子,实际上大小差异可能不会那么大,但 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';
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 。
如果缩小器处理上面的源代码,它将:
- 删除未使用的函数
subtract和index_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.js和index.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行为之上增加额外的成本。
总结 #
为确保捆绑程序能够成功优化您的应用程序,请避免依赖 CommonJS 模块,并在整个应用程序中使用 ECMAScript 模块语法。
以下是一些可操作的提示,以验证您是否处于最佳路径上:
- 使用 Rollup.js 的 node-resolve 插件并设置
modulesOnly标志,以指定您只想依赖 ECMAScript 模块。 - 使用包
is-esm来验证 npm 包是否使用 ECMAScript 模块。 - 如果您使用的是 Angular,默认情况下,如果您依赖于不可摇树的模块,则将收到警告。