前言

JavaScript 中的 ESM(ES Modules) 规范和 CJS(CommonJS)规范是用于模块化开发的两种不同的模组系统。

早期 Javascript 这门语言是没有模块化的概念的,直到 Nodejs 诞生,才把模块系统引入 js``。Nodejs 已经在逐步支持 ESM,目前很多主流浏览器也已经原生支持 ESM

关于 CSJ 和 ESM

关于 CJS

CJSCommonJS 的模块系统,最初是为了在服务端 Nodejs 开发设计的,但也被广泛用于前端开发。CSJ 使用 require 函数来导入模块,并使用 module.exports 对象来导出模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
// math.js 定义模块
function add(a, b) {
return a + b;
}
module.exports = {
add,
};
exports.add = add;

// main.js 导入模块
const { add } = require('./math.js');
// 或 const add = require('./math.js');
console.log(add(2, 3)); // 輸出: 5

关于 ESM

ESMECMAScript 的模块系統,从 ECMAScript 6(ES6)开始引入并成为 JavaScript 的一部分。ESM 使用 import 和 export 关键字來定义和导入模块。

1
2
3
4
5
6
7
8
9
10
11
12
// math.js 定义模块
function add(a, b) {
return a + b;
}
export { add }
export default add;

// main.js 导入模块
import { add } from './util.js';
// 或 import add from './util.js';

console.log(add(2, 3)); // 輸出: 5

模块入口

我们知道有很多第三方库同时支持在 Nodejs 和浏览器环境执行,这种库通常会打包出 CJSESM 两种产物,CJS 产物给 Nodejs 用,ESM 产物给 Webpack 之类的 Bundler 使用。所以,当我们使用 requireimport 导入模块时,入口文件路径往往是不一样的。

那么问题来了,如何让 Nodejs 或者 Bundler 找到对应的入口文件呢? ​

一般我们通过 package.json main 字段定义 CJS 的入口文件,module 字段定义 ESM 的入口文件。如下:

1
2
3
4
5
6
7
8
9
10
{
"name": "emooa",
"version": "0.0.1",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/esm/index.d.ts",
"files": [
"dist",
],
}

这样,NodejsBundler 就知道分别从 ./dist/cjs/index.js./dist/esm/index.js 导入模块了。​

区别

非严格模式 vs 严格模式

CJS 默认是非严格模式,而 ESM 默认是严格模式。

CommonJS 模块是在 Node.js 中早期的模块化系统,最初设计时并没有考虑到 JavaScript 的严格模式(strict mode)。因此,CommonJS 模块的默认行为允许一些灵活的 JavaScript 特性。

ESM 模块是由 ECMAScript 规范引入的,是现代 JavaScript 的官方模块系统。严格模式(strict mode)是 ECMAScript 5 引入的,旨在消除 JavaScript 中的一些不良行为,并提高代码的安全性和可维护性。

  • CJS(CommonJS):默认非严格模式
    • 允许在函数中使用未声明的变量。
    • 可以在同一作用域中使用 this 来引用全局对象(例如 global)。
    • 允许删除变量、对象的属性等,这些在严格模式中是禁止的。
  • ESM(ECMAScript Modules):默认严格模式
    • 严格模式下禁止使用一些危险的语言特性,例如禁止使用 with 语句,变量必须声明后才能使用,this 在函数中不会指向全局对象等。
    • ESM 是从 ECMAScript 6(ES6) 开始设计的,目标是统一浏览器和 Node.js 环境中的模块标准。因此,ESM 模块在设计时就默认启用了严格模式,目的是提升代码质量和减少潜在的错误。

拷贝 vs 引用

CJS 导入的是值的拷贝,而 ESM 导入的是值的引用。

  • CJS(CommonJS): 值的拷贝
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// math.js
let age = 18;
function setAge(newAge) {
age = newAge
}
module.exports = {
age,
setAge
}

// main.js
const { age, setAge } = require('./math.js');

console.log(age); // 18
setAge(19);
console.log(age); // 18

  • ESM(ECMAScript Modules): 值的引用
1
2
3
4
5
6
7
8
9
10
11
12
// math.js
export let age = 18;
export function setAge(val) {
age = val;
}

// main.js
import { age, setAge } from './math.js';

console.log(age); // 18
setAge(19);
console.log(age); // 19

可以看到,main.js 文件引用了 math.js, 由于 ESM 是对值的引用,通过 setAge 修改 age 的值时,将影响到 math.js 模块中的内容,而 CJS 则不会。

动态 vs 静态

  • CJS(CommonJS): 动态加载

CJS 使用 require() 来加载模块,这个过程是运行时(CJS 输出的是一个对象,该对象需要在脚本运行完成后才生成)动态解析的。当你调用 require() 时,Node.js 会在运行时查找、加载和解析模块,并返回该模块的内容。由于 require() 可能在代码的任何地方调用,因此无法在编译时就确定所有的依赖关系。

  • ESM(ECMAScript Modules): 静态加载

ESM 使用 importexport 语法,这些语法是显式的,且必须在文件的顶部进行声明。这使得 JavaScript 引擎可以在编译时(即代码执行之前)就分析出所有的模块依赖关系。通过这种方式,ESM 可以在编译时进行优化(例如 tree shaking,去除未使用的代码)和循环依赖检测。

总结:基于这个差异,ESMCJS 好做 tree-shaking

注意:ECMAScript 2020(ES11) 已经引入 import() 函数,支持动态加载方案。import() 函数接受要加载的模块相对路径,返回一个 Promise 对象,内容是要加载的模块对象。

1
2
3
4
import('./esm_module.js')
.then(module => {
console.log(module);
})

同步 vs 异步

ESM(ES 模块)CJS(CommonJS)模块在加载机制上有一个根本的区别:ESM 是 静态异步加载,而 CJS 是 动态同步加载。我们可以通过分析它们的不同加载方式和特点来理解这一点。

  • CJS(CommonJS): 同步加载

    • 动态加载:在 CJS 中,require() 是可以放在任意位置的,JavaScript 引擎只有在执行到 require() 语句时才会加载模块的内容。这意味着 CJS 的模块是动态加载的,每次 require() 都会去获取所需模块。

    • 同步阻塞:require() 是同步的,模块的加载顺序依赖于代码的执行顺序。在加载过程中,模块的执行是同步的,并且是对值的拷贝,即在 require() 会阻塞后续代码执行。

  • ESM(ECMAScript Modules): 异步加载

    • 模块解析和依赖关系的预分析:在代码运行之前,JavaScript 引擎会先静态分析模块的依赖关系,将所有 import 语句提前解析,以确保在代码开始执行时,所有模块和依赖已经加载好。这使得 ESM 可以在不阻塞主线程的情况下并行加载依赖。

    • Top-Level Await 支持:ESM 支持顶层 await,这使得模块加载可以等待异步操作完成。这样,在加载 ESM 时,如果模块中有 await 操作,整个模块会异步加载并等待这些操作完成后再继续执行。

总结

  • CJS(CommonJS)

    • 非严格模式,关键字 require、module.exports
    • 返回的是值的拷贝,可以重新赋值
    • 动态加载:运行时查找、加载和解析模块
    • 同步加载:require 函数会阻塞后续代码执行
  • ESM(ECMAScript Modules)

    • 严格模式,关键字 import、export
    • 值的引用,不可以重新赋值,但是如果是对象,可以修改对象内的属性,会影响到全局
    • 静态加载:使用 importexport 语法,这些语法是显式的,且必须在文件的顶部进行声明。使得 js 引擎 可以在 编译时(即代码执行之前)就分析出所有的模块依赖关系。 ECMAScript 2020(ES11) 已经引入 import() 函数,支持动态加载方案。
    • 异步加载: 支持顶层 await 设计,模块加载可以等待异步操作完成,返回的是 promise 对象。