CommonJS 如何扩大您的 app bundle

了解 CommonJS 模块如何影响应用的摇树优化

在这篇博文中,我们将探讨什么是 CommonJS,以及它为何导致您的 JavaScript 软件包过小。

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

什么是 CommonJS?

CommonJS 是 2009 年推出的一项标准,确立了 JavaScript 模块的惯例。它最初设计为在网络浏览器之外使用,主要用于服务器端应用程序。

借助 CommonJS,您可以定义模块、从中导出功能,以及将其导入其他模块。例如,以下代码段定义了一个导出 5 个函数的模块: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)可执行不同的优化,以缩减应用的大小。在构建时分析您的应用,他们会尝试从您未使用的源代码中移除尽可能多的内容。

例如,在上面的代码段中,您的最终 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 后,如果我们查看 output 大小,会看到如下内容:

$ 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)})();

请注意,最终 bundle 不包含 utils.js 中我们用不到的任何函数,并且 lodash 也没有任何跟踪记录!更进一步,terserwebpack 使用的 JavaScript 缩减器)内嵌了 console.log 中的 add 函数。

您可能会问,为什么使用 CommonJS 会导致输出软件包增加将近 16,000 倍?当然,这是一个玩具示例,实际上,大小差异可能没有那么大,但 CommonJS 有可能为您的生产 build 增加很大的权重。

CommonJS 模块在一般情况下更难优化,因为它们比 ES 模块更具动态性。为确保您的捆绑器和缩减器能够成功优化您的应用,请避免依赖 CommonJS 模块,而在整个应用中使用 ECMAScript 模块语法。

请注意,即使您在 index.js 中使用 ECMAScript 模块,如果您使用的模块是 CommonJS 模块,应用的 bundle 大小也会受到影响。

为什么 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 导入哪些符号以及导出哪些符号。

默认情况下,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));

})();

请注意,最终的 bundle 包含一些 webpack“runtime”:即注入的代码,负责从捆绑的模块导入/导出功能。这一次,我们在运行时动态要求使用 __webpack_require__add 函数,而不是将 utils.jsindex.js 中的所有符号都放置在同一命名空间下。

这是必要的,因为通过 CommonJS,我们可以从任意表达式获取导出名称。例如,下面的代码就是一种绝对有效的构造:

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

打包器在构建时无法知道导出的符号的名称是什么,因为这需要在运行时(在用户浏览器上下文中)提供的信息。

这样,缩减器就无法从其依赖项中了解 index.js 究竟使用了什么,因此无法对其进行摇树优化。我们也将在第三方模块中观察到完全相同的行为。如果我们从 node_modules 导入 CommonJS 模块,您的构建工具链将无法对其进行正确优化。

使用 CommonJS 执行 Tree-shaking 操作

分析 CommonJS 模块要困难得多,因为按照定义它们是动态的。例如,ES 模块中的导入位置始终是字符串字面量,而 CommonJS 则是表达式。

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

总结

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

下面是一些切实可行的提示,可帮助您确认自己是否处于最佳途径:

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