CommonJS 如何扩大您的 app bundle

了解 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 在设计时并未考虑缩减正式版 bundle 的大小。与此同时,分析表明,JavaScript 软件包大小仍是导致浏览器应用运行速度变慢的主要原因。

JavaScript 捆绑器和缩减工具(例如 webpackterser)会执行不同的优化来减小应用的大小。它们会在构建时分析您的应用,并尝试尽可能从您未使用的源代码中移除内容。

例如,在上面的代码段中,您的最终 bundle 应仅包含 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 字节输出如下:

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

请注意,最终的软件包不包含我们未使用的 utils.js 中的任何函数,并且没有 lodash 的任何痕迹!更进一步,terserwebpack 使用的 JavaScript 缩减器)将 console.log 中的 add 函数内嵌到了代码中。

您可能会问,为什么使用 CommonJS 会导致输出软件包增加将近 16,000 倍?当然,这只是一个简单的示例,在现实中,大小差异可能不会那么大,但 CommonJS 可能会显著增加正式版 build 的大小。

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.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 函数的正文

通常,开发者会将这种移除未使用的导入项的操作称为“树摇”。之所以能够进行 Tree Shaking,是因为 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 模块在定义上是动态的,因此分析起来要困难得多。例如,与 CommonJS 相比,ES 模块中的导入位置始终是字符串字面量。

在某些情况下,如果您使用的库遵循了有关使用 CommonJS 的特定惯例,则可以使用第三方 webpack 插件在构建时移除未使用的导出内容。虽然此插件添加了对 tree-shaking 的支持,但并未涵盖依赖项可以使用 CommonJS 的所有不同方式。这意味着,您无法获得与 ES 模块相同的保证。此外,除了默认的 webpack 行为外,在构建流程中,这会增加额外费用。

总结

为确保捆绑器能够成功优化您的应用,请避免依赖 CommonJS 模块,并在整个应用中使用 ECMAScript 模块语法。

以下是一些实用提示,可帮助您验证自己是否走上了最佳路径:

  • 使用 Rollup.js 的 node-resolve 插件,并设置 modulesOnly 标志,以指定您只想依赖 ECMAScript 模块。
  • 使用软件包 is-esm 验证 npm 软件包是否使用 ECMAScript 模块。
  • 如果您使用的是 Angular,默认情况下,如果您依赖于不可摇摆树的模块,则会收到警告。