在这篇文章中,我们将研究什么是CommonJS,以及为什么它会让你的JavaScript包大小过分膨胀。为了确保打包器(bundler)能成功优化你的应用程序大小,请避免依赖CommonJS模块,并在整个应用程序中使用ES2015模块语法。
本文最初发布于web.dev网站,经原作者Minko Gechev授权由InfoQ中文站翻译并分享。
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客户端库的流行模块格式。
服务端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,应用程序的打包大小也会受到影响。
为了回答这个问题,我们将研究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。
如果让一个压缩器处理上面的源代码,它将:
开发人员通常将这种移除未使用的导入的操作称为摇树优化(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相比,ES模块中的导入位置始终是一个字面量(前者则是一个表达式)。
在某些情况下,如果你使用的库遵循有关CommonJS用法的特别约定,则可以在构建时使用这个第三方webpack插件删除未使用的导出。但尽管这个插件增加了对摇树优化的支持,但并未涵盖依赖项使用CommonJS的所有可能方式。这意味着你无法获得与ES模块相同的保障。此外,除了默认的webpack行为外,它还会在构建过程中增加额外的成本。
总之,再次强调,为了确保打包器可以成功优化你的应用程序,请避免依赖CommonJS模块,并在整个应用程序中使用ES2015模块语法。
原文链接:
领取专属 10元无门槛券
私享最新 技术干货