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 在设计时并未将缩减生产软件包的大小考虑在内。同时,分析表明,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)})();

请注意,最终的 bundle 不包含 utils.js 中我们不使用的任何函数,并且没有来自 lodash 的跟踪记录!此外,terserwebpack 使用的 JavaScript 缩减器)会将 add 函数内嵌在 console.log 中。

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

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

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

为什么 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”,即负责从捆绑模块导入/导出功能的注入代码。这一次,我们不是将 utils.jsindex.js 中的所有符号都放在同一个命名空间下,而是在运行时动态要求使用 __webpack_require__add 函数。

这是必要的,因为借助 CommonJS,我们可以从任意表达式中获取导出内容名称。例如,以下代码就是一个绝对有效的结构:

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

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

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

使用 CommonJS 进行摇树优化

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

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

总结

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

下面提供了一些切实可行的提示,可帮助您验证自己是否走在了最优路线上:

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