在 5.3.2 我们对比了 ESM 与 CommonJS 的整体差异,其中互操作规则、加载时机以及静态导入特性是日常开发中最容易踩坑的三个细节。理解它们不仅能避免运行时错误,也能帮助我们写出更清晰、更高效的模块代码。
1. ESM 与 CommonJS 的互操作规则
在一个 Node.js 项目中,ESM 和 CommonJS 模块往往需要并存:可能是老代码使用 require,新模块使用 import,也可能是第三方包只提供了 CommonJS 版本。Node.js 对两者的互操作有明确的规则,违反规则会直接导致运行时报错。
基本互操作能力表:
- ✅ ESM 中可以
importCommonJS 模块 - ❌ CommonJS 中不能
requireESM 模块(会抛出ERR_REQUIRE_ESM) - ✅ CommonJS 中可以使用动态
import()加载 ESM 模块(因为import()返回 Promise,是异步操作) - ⚠️ ESM 中不能使用
require,但可以通过module.createRequire创建受限的require函数
从 ESM 导入 CommonJS 模块
这是最常见的互操作场景。当你用 import 加载一个 CommonJS 模块时,Node.js 会将 module.exports 整体作为默认导出(default export):
// math.cjs (CommonJS)
module.exports = { add: (a, b) => a + b };
// app.mjs (ESM)
import math from './math.cjs'; // math 就是 { add: ... }
console.log(math.add(1, 2)); // 3
如果 CommonJS 模块只导出了单个函数或值,import 同样会将其包装成 default 导出:
// greet.cjs
module.exports = function(name) { return `Hello ${name}`; };
// app.mjs
import greet from './greet.cjs';
console.log(greet('Node')); // Hello Node
需要注意的是,ESM 不会对 CommonJS 的 module.exports 进行“具名导出拆分”。也就是说,即便 module.exports 是一个对象,也不可以使用具名导入:
// 错误用法
import { add } from './math.cjs'; // SyntaxError 或运行时错误
命名导出仅适用于 ESM 自身的 export 语法,对 CJS 无效。如果想获得类似效果,要么在 ESM 中做一层包装导出,要么接受默认导入后解构。
从 CommonJS 加载 ESM 模块
CommonJS 的 require 是同步的,而 ESM 模块的解析和加载是异步的(因为需要支持 import 的网络加载等特性),因此 Node.js 明确禁止在 CommonJS 中使用 require() 加载 ESM 模块。任何这样的尝试都会抛出:
Error [ERR_REQUIRE_ESM]: require() of ES Module /path/to/module.mjs not supported.
Instead change the require of /path/to/module.mjs to a dynamic import().
动态 import() 是唯一的桥接方式,它返回一个 Promise:
// app.cjs
async function loadModule() {
const esmModule = await import('./module.mjs');
console.log(esmModule.default);
}
loadModule();
由于 import() 是异步的,调用它的 CommonJS 模块必须处理好异步流程(例如在 async 函数中 await)。这在顶层代码中可能带来一些不便,但足够解决问题。
module.createRequire 在 ESM 中使用 require
有时候我们在 ESM 文件中需要加载 CommonJS 模块,并且希望像 require 一样使用动态路径。Node.js 提供了 module.createRequire 在 ESM 中创建一个“类 require”函数,但它仅限于加载 CommonJS 模块或 JSON 文件,不能加载 ESM 文件。
// app.mjs
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const pkg = require('./package.json'); // 可以
const math = require('./math.cjs'); // 可以
// const esm = require('./module.mjs'); // 错误,不能 require ESM
这在处理配置文件或需要动态解析路径的场景时非常实用。
2. 加载时机:静态分析与异步加载
CommonJS 和 ESM 在加载时机上有着本质区别,这直接影响了代码的执行顺序和性能优化可能性。
CommonJS:运行时加载,同步执行
require() 是一个普通的函数,可以出现在代码的任何位置,包括条件语句内部。模块的加载和编译是同步进行的:当执行到 require() 时,Node.js 立即解析路径、读取文件、编译并执行模块代码,然后把 module.exports 返回。这意味着:
- 加载时机完全由代码的执行顺序决定。
- 可以动态构造模块路径,例如
require('./lang/' + lang)。 - 模块的依赖关系只能在运行时完全确定,构建工具很难进行静态优化(如 Tree Shaking)。
// 条件加载,CommonJS 允许
let parser;
if (format === 'json') {
parser = require('./json-parser');
} else {
parser = require('./xml-parser');
}
ESM:编译时静态分析,异步加载
import 声明会提升到模块作用域的顶部,并在代码执行前进行解析和加载。所有 import 的路径必须是字符串字面量(不能是变量或表达式),这使得引擎和打包工具(如 Webpack、Rollup)可以在编译阶段就确定整个模块依赖图。这就是所谓的“静态结构”。
加载过程是异步的,分为三个步骤:
- 解析(Parsing):读取并解析模块文件,不执行代码。
- 实例化(Instantiation):创建模块环境记录,确定所有导入/导出的绑定关系(注意是“活绑定”)。
- 求值(Evaluation):按依赖顺序执行模块顶层代码。
这种分阶段、异步的加载模型使得浏览器可以并行加载多个模块,也使得 Node.js 可以在不阻塞事件循环的情况下预处理依赖。但在 Node.js 中,ESM 的异步加载也会带来一个微妙的时序问题:import 语句虽然写在顶层,但其加载过程可能会被 await 等操作影响到执行顺序。
// ESM:路径必须是静态字面量
import parser from './json-parser.js'; // ✅ 合法
// import parser from getPath(); // ❌ 非法
// 动态加载只能用 import()
const module = await import('./module.mjs');
加载时机差异带来的实践影响
- 循环引用表现不同:CommonJS 遇到循环引用时,可能得到未执行完的
module.exports副本;ESM 通过活绑定,循环引用的值会被更新,但顶层执行顺序仍需谨慎设计。 - 启动性能:ESM 的静态分析使得依赖预加载成为可能,配合 HTTP/2 等多路复用技术能有更快的启动。CommonJS 则是顺序同步加载,大量
require可能拖慢启动,但通常通过缓存得到缓解。 - Tree Shaking:由于 ESM 的静态结构,打包工具可以安全地删除未使用的导出;CommonJS 的动态特性使得 Tree Shaking 几乎不可行。
3. 静态导入特性:活绑定、只读与优化
ESM 的 import 和 export 不仅仅是语法上的改变,它还引入了一些本质特性,开发者需要理解这些规则才能避免写出不符合预期的代码。
① 导入的绑定是“活绑定”(Live Binding)
ESM 的导入并不是复制值,而是建立了一个只读的引用绑定。当导出模块内部修改了导出变量的值,所有导入该变量的模块都会自动看到更新后的值。
// counter.mjs
export let count = 0;
export function increment() {
count++;
}
// app.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1,看到了变化
这与 CommonJS 的值拷贝行为截然不同:
// counter.cjs
let count = 0;
module.exports = { count, increment: () => { count++; } };
// app.cjs
const { count, increment } = require('./counter.cjs');
console.log(count); // 0
increment();
console.log(count); // 0,还是 0,因为 count 是拷贝的基本类型值
在 CommonJS 中,module.exports 对象上的属性是引用,但当你解构出基本类型时,得到的是值的副本,后续变化不会反映到已解构的变量。而 ESM 的活绑定则始终指向模块内部的原变量,这使得像 export let 这样的可变导出成为可能,但也要求开发者不可在导入侧对其重新赋值(见下一条)。
② 导入绑定是只读的
import 引入的变量不能被直接赋值,否则会触发 TypeError:
import { count } from './counter.mjs';
count = 5; // ❌ TypeError: Assignment to constant variable.
这并非因为绑定是 const,而是 ECMAScript 规范强制要求导入绑定是不可变的(immutable)。如果想改变量值,必须通过导出模块提供的函数(如 increment)来修改。这条规则确保模块的行为可预测,同时也避免了跨文件的副作用混乱。
需要注意的是,导入的对象属性如果本身是可变的,则可以被修改(例如 import obj 后 obj.prop = newValue 是允许的),但这通常是不推荐的做法,会破坏模块的封装性。
③ export default 与具名导出的差异
export default 在静态导入中同样遵守活绑定规则,但与具名导出有细微差异:
// default.cjs / default.mjs
let value = 1;
export default value;
setTimeout(() => { value = 2; }, 100);
// app.mjs
import value from './default.mjs';
console.log(value); // 1
setTimeout(() => { console.log(value); }, 200); // 仍然是 1
如果 export default 后面跟的是一个变量(而不是函数或类声明),那么这个默认导出绑定的是表达式的值,而不是变量本身的活绑定。因此上例中,默认导出捕获了当时的 1,后续 value 的变化不会反映到导入方。而如果导出的是对象或函数,对象引用是活的,内部属性可以变化。这一细微差别在重构时要格外留意。
④ import() 动态导入
虽然静态 import 要求路径是字面量,但 import() 作为函数,可以在运行时动态传入路径。它会返回一个 Promise,可用于按需加载、条件加载或延迟加载模块。import() 加载的 ESM 模块,其导出的绑定同样是活绑定。
if (condition) {
const module = await import('./heavy-module.mjs');
module.doWork();
}
动态导入与 CommonJS 的 require 在灵活性上相似,但仍然是异步的,并且只支持 ESM 模块(或在 CJS 中用动态 import 加载 ESM)。它在代码分割、微前端等场景中非常有用。
静态特性的工程价值
由于所有依赖关系在编译期已知,构建工具可以进行可靠的静态分析:
- 精确的 Tree Shaking:只有被导入的导出才会被打包。
- 提前生成模块依赖图:支持按需预加载、HTTP/2 服务器推送等。
- 循环依赖的早期检测:工具可以在构建时就发现循环引用并给出警告。
这些优势使得 ESM 成为现代前端构建和 Node.js 新型项目的推荐模块标准。
小结
ESM 与 CommonJS 的互操作规则严格但清晰:记住“ESM 可以导入 CJS,CJS 不能 require ESM,只能用动态 import”,能避免绝大多数兼容性错误。加载时机上,CJS 是运行时同步加载,ESM 是编译时静态解析加异步加载,这直接决定了模块使用方式和工具链优化的潜力。静态导入的活绑定和只读特性,则从根本上改变了我们思考模块间数据共享的方式。
在实际项目中,如果可能,优先统一使用 ESM 语法,能带来更好的开发体验和构建优化空间;当必须与大量遗留 CJS 代码共存时,理解上述规则能让你游刃有余地穿梭于两套系统之间。