Categories
程式開發

不要再依赖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/