专业编程教程与实战项目分享平台

网站首页 > 技术文章 正文

宇宙厂:深入聊聊 CJS 和 ESM 模块化三点核心差异?

ins518 2025-05-15 18:22:34 技术文章 24 ℃ 0 评论

大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发!

自从 ES6 推出以来,JavaScript 代码结构和组织发生了重大变化。其为开发者提供了众多新功能,比如:编写更易于重用和管理的代码,以模块系统为要。然而,在 ES6 之前,CommonJS 占据了主导地位,仍然被广泛使用,尤其是在 Node.js 中。

要编写好的代码则必须了解 ES6 和 CommonJS import 之间的核心差异。这篇博文将探讨两者之间的几个差异,并研究操作原理以及各自的优点和局限性。

1. ES6 静态 vs. CommonJS 运行时 import 解析

1.1 ES6 静态 import 解析

  • ES6 import 在解析模块时解析,此时 代码并未执行
  • 模块加载器 分析 import 语句并将其解析为相应的模块
  • 由于 import 语句中的路径是静态评估的,支持静态分析 (Statically Analyzable) 和优化。而静态分析是指在不执行代码的情况下分析代码的过程,通常在编译或解析阶段,这也意味着无法包含任何运行时信息
//  不支持
import something from './utils/' + variableName;
  • 通过捆绑器实现高效的 Tree-Shaking(删除未使用代码的优化技术) 和 Dead Code Elimination(死代码消除)。后者用于编译器通过分析代码路径,识别出那些由于条件永远不会满足或逻辑上不会被执行的代码块,用于 UglifyJS、Terser 等压缩工具中
  • 支持 Dynamic import,可以在运行时确定模块路径,其返回一个 resolve 为模块对象的 Promise
////////////// 支持不同环境导入 ///////////
let myModule;
if (typeof window === "undefined") {
  myModule = await import("module-used-on-server");
} else {
  myModule = await import("module-used-in-browser");
}
/////////// 支持动态变量导入 //////////
Promise.all(
  Array.from({length: 10}).map(
    (_, index) => import(`/modules/module-${index}.js`),
  ),
).then((modules) => modules.forEach((module) => module.load()));

1.2 CommonJS 运行时 import 解析

针对 CommonJS 来说,其与 ESM 在模块处理方面有诸多不同。

  • 模块导入在运行时解析,即代码执行时,这意味着 模块在程序运行时以阻塞方式加载和执行
  • 模块的路径是根据运行时 require 语句的值动态确定的
const myModule = 'Module1';
const Modules = require(`../path/${myModule}`)
  • 允许条件导入,即 require 语句可以在代码中的任何位置声明
const v = Math.random();
let myModule = {};
if (v> 0.5) {
  myModule = require("./moduleA.cjs");
} else {
  myModule = require("./moduleB.cjs");
}
console.log("模块值:", myModule);
  • 打包器的静态分析和优化面临更大的挑战

2. ESM 实时绑定 (Live Bindings) 与导出引用

在 JavaScript 中,实时绑定和缓存值 的概念分别与 ES6 模块(import/export)和 CommonJS 模块(require/module.exports)中导入的处理方式相关。

在 ES6 模块中,import 会创建实时绑定 。这意味着 import 的值本质上是对原始导出值的引用。如果导出的值发生变化,import 的值将反映该变化。相当于在 import 值和导出值之间建立了动态链接,允许实时更新

// moduleA.mjs
export let count = 0;
export function increment() {
  count++;
}
// moduleB.mjs
import {count, increment} from './moduleA.js';
console.log(count);
// 输出 0
increment();
console.log(count);
// 输出 1

在 CommonJS 模块中,导入模块时将执行并缓存模块的值。如果导出模块使用的是对象,那么 CommonJS 的导入与 ESM 的 live-binding 非常类似。但是,如果导出的不是对象,配合 require 解构后将不再有 live-bindings 效果。

// 情况 1:count 是基础类型,配合解构后 live-bindings 不生效
const {count, increment} = require('./counter.cjs');
// 情况 2:count 是基础类型,如果不解构依然有 live-bindings 效果
const lib = require('./counter.cjs');
console.log(lib.count)
lib.increment()
console.log(lib.count)
// 情况 3:count 是引用类型,配合解构具有 live-bindings 效果
const {count, increment} = require('./counter.cjs');
console.log(count.value);

但是,总体来看,CommonJS 导出的依然是值的引用而非值的拷贝,这一点和网上甚嚣尘上的观点南辕北辙。

 这个结论在我的另外一篇文章《到底什么是 live-bindings 中有详细论述》

3. ESM 和 CommonJS 的性能对比

在比较 ESM 和 CommonJS 导出的性能因素时,需要综合考虑以下因素:

  • ESM 异步非阻塞加载 vs. CommonJS 同步阻塞解析

ES6 模块是异步解析,可与其他模块并行获取,从而减少初始加载时间。而 CommonJS 模块是同步解析,其会阻止后续代码执行,直到模块完全加载(类比 Node.js)。因此可能会导致初始加载时间变慢,尤其是在需要加载多个大型模块时。

这里的并行加载可以类比 Vite 的开发服务器,多个 import 并行加载,除非触发浏览器同域名资源并行加载上限。

//  两个文件同时加载
import {functionA} from './moduleA.js';
import {functionB} from './moduleB.js';
functionA();
functionB();
  • ESM 支持依赖项静态解析

ES6 模块的静态特性允许在构建过程中更有效地解析依赖项。可以在编译时分析和解析依赖项,从而使捆绑器等工具能够优化捆绑包大小并消除未使用的 import。

// ESM 静态导入声明
import defaultExport from "module-name";
import * as name from "module-name";
import {export1} from "module-name";
import {export1 as alias1} from "module-name";
import {default as alias} from "module-name";
import {export1, export2} from "module-name";
import {export1, export2 as alias2, /* … */} from "module-name";
import {"string name" as alias} from "module-name";
import defaultExport, {export1, /* … */} from "module-name";
import defaultExport, * as name from "module-name";
import "module-name";

相比之下,CommonJS 模块是在运行时动态解析的。这种动态解析会产生少量性能开销,因为 模块解析过程涉及在文件系统或缓存中搜索所需的模块

  • CommonJS 的 Tree-Shaking 比 ESM 更加复杂

ES6 模块支持静态分析,使 Tree-Shaking 能够通过删除未使用的代码来减小捆绑包大小并提高性能。相比之下,CommonJS 模块在定义上更加动态,因此分析起来困难得多。例如,ES 模块中的导入位置始终是字符串,而 CommonJS 中的导入位置则可能是表达式。打包器从 CommonJS 模块中消除未使用的导出非常困难,从而导致打包文件变大。

不过,像 @
rollup/rollup-plugin-commonjs
等插件可以通过将 CommonJS 模块先转换为 ESM 模块,然后也能在构建阶段利用 Tree-Shaking 的诸多能力。不过,相比于原生 ESM,一方面并非所有模块都能成功转化为 ESM,另一方面大量使用 CommonJS 也会导致打包时间明显变长。

如果无法转换为 ESM ,则 rollup 需要添加辅助函数(也可以理解为运行时)来模拟 CommonJS 的行为。

参考资料

https://medium.com/@rahul.jindal57/es6-imports-vs-commonjs-imports-8e9b66aa04bd

https://jameshfisher.com/2020/09/28/javascript-live-bindings-are-just-concatenation/

https://stackoverflow.com/questions/29168433/how-to-perform-a-variable-es6-import

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import

https://stackoverflow.com/questions/52965907/what-is-the-meaning-of-static-import-in-es6

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import

https://web.dev/articles/commonjs-larger-bundles

https://github.com/rollup/rollup-plugin-commonjs/issues/362

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表