ESM(ES Modules)与 CommonJS 的区别:静态加载与动态加载的底层机制对比

各位同行,各位技术爱好者,大家好!

今天,我们将深入探讨 JavaScript 模块系统的两大巨头:CommonJS(CJS)与 ES Modules(ESM)。这不是一个简单的语法对比,我们将聚焦于它们最核心的区别——静态加载与动态加载的底层机制,并剖析这些机制如何影响我们的开发、工具链以及应用的性能。作为一名编程专家,我将带大家穿越模块加载的迷雾,一窥其究竟。

模块化:软件工程的基石

在深入细节之前,我们先回顾一下模块化的重要性。在没有模块化系统之前,JavaScript 应用的开发面临诸多挑战:

  1. 全局变量污染: 所有脚本共享一个全局作用域,变量名冲突是家常便饭。
  2. 依赖管理混乱: 脚本之间的依赖关系需要手动维护,顺序稍有不慎就会导致运行时错误。
  3. 代码复用困难: 难以组织和共享可复用的代码块。
  4. 维护成本高昂: 大型项目难以理解、测试和维护。

为了解决这些问题,各种模块化规范应运而生。在 Node.js 生态中,CommonJS 成为了事实标准;而在浏览器端和现代 JavaScript 的未来中,ES Modules 则登上了历史舞台。

CommonJS:运行时动态加载的先驱

CommonJS 模块系统诞生于服务器端 JavaScript 环境(Node.js),其设计哲学是同步加载,更符合服务器端文件系统访问的特性。

CommonJS 的核心机制

CommonJS 的核心是 require 函数和 module.exports 对象(以及 exports 快捷方式)。

  1. require 函数:

    • require 是一个函数,用于加载并执行其他模块。
    • 它接收一个模块标识符(通常是文件路径或模块名)作为参数。
    • require同步的,意味着在它返回模块的导出对象之前,后续代码不会执行。
    • 它会在运行时解析模块路径,查找文件。
    • 每个模块只会被 require 加载和执行一次。后续的 require 调用会从缓存中直接返回已导出的对象。
  2. module.exports 对象:

    • 每个 CommonJS 模块都有一个 module 对象,其中包含一个 exports 属性。
    • module.exports 是模块的真正导出对象。
    • 通过给 module.exports 赋值,可以导出任何 JavaScript 值(对象、函数、基本类型等)。
  3. exports 快捷方式:

    • 为了方便,CommonJS 也提供了一个 exports 变量,它初始化时指向 module.exports
    • 你可以通过 exports.propertyName = value 的方式添加导出。
    • 注意: 如果直接给 exports 赋值(如 exports = someValue),它会切断与 module.exports 的引用,导致 require 最终返回的仍然是原始的 module.exports 对象,而不是你赋给 exports 的新值。这是 CommonJS 中一个常见的陷阱。

CommonJS 加载流程:一个动态的过程

CommonJS 的加载过程可以概括为以下步骤:

  1. 路径解析 (Resolution):require(id) 被调用时,Node.js 会根据 id 查找模块文件。这涉及文件系统I/O,路径可以是相对路径、绝对路径,或者是 node_modules 中的模块名。
  2. 加载 (Loading): 找到文件后,Node.js 会读取文件内容。
  3. 封装 (Wrapping): 为了防止全局变量污染,Node.js 会将模块的代码封装在一个函数中。这个函数接收 exports, require, module, __filename, __dirname 等参数。
    (function (exports, require, module, __filename, __dirname) {
        // 模块的实际代码在这里
        // 例如:
        // const add = (a, b) => a + b;
        // module.exports = add;
    });
  4. 编译与执行 (Compilation & Execution): Node.js 会执行这个封装后的函数。在执行过程中,模块内的代码会填充 module.exports 对象。
    • 关键点: 模块的执行发生在 require 调用时,并且是同步的。这意味着模块的代码是在运行时被解释和执行的。
  5. 缓存 (Caching): 模块执行完毕后,其 module.exports 对象会被缓存起来。下次对同一模块的 require 调用将直接返回缓存中的对象,而不会重新执行模块代码。

CommonJS 代码示例

示例 1: 基本导出与导入

utils.js:

// utils.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;

// 方式一:通过 module.exports 导出单个值
// module.exports = add;

// 方式二:通过 module.exports 导出对象
module.exports = {
    add: add,
    subtract: subtract,
    multiply: (a, b) => a * b
};

console.log('utils.js 模块被加载和执行');

app.js:

// app.js
const mathUtils = require('./utils'); // 这里的 './utils' 是模块标识符

console.log('--- app.js 开始执行 ---');

const sum = mathUtils.add(5, 3);
console.log('Sum:', sum); // Output: Sum: 8

const difference = mathUtils.subtract(10, 4);
console.log('Difference:', difference); // Output: Difference: 6

const product = mathUtils.multiply(2, 6);
console.log('Product:', product); // Output: Product: 12

// 再次 require 同一个模块,不会再次执行模块代码,而是返回缓存
const anotherMathUtils = require('./utils');
console.log('再次 require utils.js');

console.log('--- app.js 结束执行 ---');

运行 node app.js 的输出:

utils.js 模块被加载和执行
--- app.js 开始执行 ---
Sum: 8
Difference: 6
Product: 12
再次 require utils.js
--- app.js 结束执行 ---

可以看到 utils.js 模块被加载和执行 只打印了一次,证明了缓存机制。

示例 2: exports 的陷阱

exporter.js:

// exporter.js
let count = 0;

exports.increment = () => {
    count++;
    return count;
};

exports.getCount = () => count;

// 错误示范:直接给 exports 赋值会切断引用
// exports = {
//     anotherFn: () => 'hello'
// };

console.log('exporter.js 模块被加载和执行');

importer.js:

// importer.js
const exporter = require('./exporter');

console.log('Initial count:', exporter.getCount()); // Output: Initial count: 0
exporter.increment();
console.log('After increment:', exporter.getCount()); // Output: After increment: 1

// 再次加载,仍然是缓存中的对象
const exporter2 = require('./exporter');
exporter2.increment();
console.log('Exporter 2 count:', exporter2.getCount()); // Output: Exporter 2 count: 2
console.log('Exporter 1 count again:', exporter.getCount()); // Output: Exporter 1 count again: 2

这里的关键是,CommonJS 模块导出的值是值的拷贝(对于基本类型)或者对象的引用(对于对象)。当一个模块被 require 时,它会得到 module.exports 对象的一个副本。如果 module.exports 是一个对象,那么 require 得到的是这个对象的引用,因此对导出对象的属性修改会反映在所有引用该模块的地方。

示例 3: 循环依赖处理

a.js:

// a.js
console.log('a.js started');
exports.a = 1;
const b = require('./b');
console.log('a.js got b:', b.b);
exports.a2 = 2; // 这部分在 b.js require 时是不可见的
console.log('a.js finished');

b.js:

// b.js
console.log('b.js started');
exports.b = 10;
const a = require('./a'); // 此时 a.js 正在执行中
console.log('b.js got a:', a.a, a.a2); // a.a2 此时是 undefined
exports.b2 = 20;
console.log('b.js finished');

main.js:

// main.js
console.log('main.js started');
const a = require('./a');
const b = require('./b');
console.log('In main, a:', a);
console.log('In main, b:', b);
console.log('main.js finished');

运行 node main.js

main.js started
a.js started
b.js started
b.js got a: 1 undefined
b.js finished
a.js got b: 10
a.js finished
In main, a: { a: 1, a2: 2 }
In main, b: { b: 10, b2: 20 }
main.js finished

从输出可以看出,当 b.js 尝试 require('./a') 时,a.js 还在执行中,此时 a.jsmodule.exports 只有 a 属性。a2 属性是在 b.js 导入之后才被添加的,因此在 b.js 中看不到。CommonJS 这种处理方式返回的是当前已完成部分的模块导出对象。

CommonJS 的优缺点

优点:

  • 简单直接: 语法简洁,易于理解和使用,尤其适合服务器端同步加载的场景。
  • 广泛应用: Node.js 生态系统的基石,拥有庞大的模块库。
  • 运行时动态性: require 可以在代码的任何地方被调用,可以根据条件动态加载模块。

缺点:

  • 同步加载: 在浏览器环境中会导致性能问题,因为文件必须从网络下载,同步加载会阻塞渲染。这也是为什么浏览器端需要像 Webpack 这样的打包工具将其转换为异步加载或合并文件。
  • 无法进行静态分析: 由于 require 是一个函数,可以在运行时动态构建模块路径,静态分析工具(如 Linter、Tree Shaking)难以确定模块之间的依赖关系。
    • 例如:require(condition ? './moduleA' : './moduleB')
  • 值的拷贝: 模块导出的值是拷贝,这在某些情况下可能导致意外行为(尽管对于对象是引用)。
  • 循环依赖处理不够优雅: 遇到循环依赖时,会返回模块当前已导出的部分,可能导致获取到不完整的模块对象。

ES Modules:静态分析与异步加载的未来

ES Modules(ESM)是 JavaScript 语言层面官方的模块化标准,旨在统一浏览器和 Node.js 的模块化方案。它的设计理念与 CommonJS 截然不同,核心是静态加载

ES Modules 的核心机制

ESM 的核心是 importexport 语句。

  1. import 语句:

    • import 是一个语句(不是函数),用于从其他模块导入功能。
    • 它接收一个模块标识符(通常是文件路径或 URL),但这个标识符必须是静态可分析的。
    • import 语句在代码执行之前,在解析阶段就被处理。
    • 它导入的是模块的实时绑定(live binding),而不是值的拷贝。这意味着如果被导入模块内部的值发生变化,导入方会看到最新的值。
  2. export 语句:

    • export 也是一个语句,用于声明模块要导出的功能。
    • 支持命名导出(export const name = ...)和默认导出(export default value)。
    • import 一样,export 也是在解析阶段被识别和处理的。

ES Modules 加载流程:一个静态的、分阶段的过程

ESM 的加载过程比 CommonJS 复杂,但更强大,因为它被设计为跨平台工作,并支持静态分析。它分为以下四个阶段:

  1. 构造 (Construction) / 解析 (Parsing):

    • 这是 ESM 最核心的静态特性所在。
    • JavaScript 引擎在执行任何代码之前,会递归地解析整个模块图。
    • 它会识别所有的 importexport 语句,构建一个模块依赖图(Module Dependency Graph)。
    • 在这个阶段,引擎只关心模块的结构,不执行任何代码。它会检查语法错误,并确定哪些模块需要被加载。
    • 关键点: 模块的依赖关系在代码执行前就已经确定。
  2. 加载 (Loading):

    • 根据构造阶段生成的模块依赖图,加载器(在浏览器中是浏览器自身,在 Node.js 中是 Node.js 模块加载器)开始异步地获取模块的源代码。
    • 这可能涉及网络请求(在浏览器中)或文件系统I/O(在 Node.js 中)。
    • 一旦获取到源代码,它会被解析成抽象语法树(AST)。
  3. 实例化 (Instantiation):

    • 加载完所有模块后,引擎会为每个模块创建一个模块环境记录 (Module Environment Record)
    • 这个记录包含了模块的所有导入和导出。
    • 在这个阶段,引擎会将导入与导出进行绑定 (linking)。也就是说,它会为每个 import 找到对应的 export,并建立一个实时绑定的引用。
    • 关键点: 绑定发生在代码执行之前。对于循环依赖,ESM 能够先完成绑定,再执行代码,因此不会出现 CommonJS 那种不完整的导出对象。
  4. 求值 (Evaluation):

    • 这是最后一个阶段,模块的实际代码被执行。
    • 所有顶级代码(包括变量声明、函数定义、类定义等)都会被执行。
    • 一旦模块代码执行完毕,其导出的值就最终确定并可以通过之前建立的实时绑定被其他模块访问。
    • 关键点: 模块的求值是单次的,后续导入会直接使用已求值的结果。

ES Modules 代码示例

示例 1: 命名导出与导入

math.mjs:

// math.mjs (或者在 package.json 中设置 "type": "module")
export const add = (a, b) => a + b;
export function subtract(a, b) {
    return a - b;
}
const multiply = (a, b) => a * b; // 未导出,外部不可见
export { multiply }; // 重新导出

let counter = 0;
export const increment = () => {
    counter++;
    return counter;
};
export const getCounter = () => counter;

console.log('math.mjs 模块被加载和执行');

app.mjs:

// app.mjs
import { add, subtract, multiply, increment, getCounter } from './math.mjs';
// 也可以使用别名导入
// import { add as sumFunction } from './math.mjs';

console.log('--- app.mjs 开始执行 ---');

console.log('Sum:', add(10, 5));         // Output: Sum: 15
console.log('Difference:', subtract(10, 5)); // Output: Difference: 5
console.log('Product:', multiply(10, 5));    // Output: Product: 50

console.log('Initial counter:', getCounter()); // Output: Initial counter: 0
increment();
console.log('After increment:', getCounter()); // Output: After increment: 1

// 再次导入,不会再次执行模块代码,且可以看到实时绑定
import { getCounter as getCounter2 } from './math.mjs';
console.log('getCounter2 after increment:', getCounter2()); // Output: getCounter2 after increment: 1
increment();
console.log('getCounter2 after another increment:', getCounter2()); // Output: getCounter2 after another increment: 2
console.log('Original getCounter:', getCounter()); // Output: Original getCounter: 2

console.log('--- app.mjs 结束执行 ---');

运行 node app.mjs (或者在 package.json 中设置 "type": "module" 后运行 node app.js):

math.mjs 模块被加载和执行
--- app.mjs 开始执行 ---
Sum: 15
Difference: 5
Product: 50
Initial counter: 0
After increment: 1
getCounter2 after increment: 1
getCounter2 after another increment: 2
Original getCounter: 2
--- app.mjs 结束执行 ---

这里再次证明了模块只执行一次,并且 getCounter 的值是实时变化的,说明了 ESM 的实时绑定特性。

示例 2: 默认导出与导入

config.mjs:

// config.mjs
const defaultSettings = {
    theme: 'dark',
    language: 'en-US',
    notifications: true
};

export default defaultSettings; // 默认导出

// 也可以有命名导出
export const version = '1.0.0';

console.log('config.mjs 模块被加载和执行');

main.mjs:

// main.mjs
import appConfig, { version } from './config.mjs'; // 导入默认导出和命名导出
// import * as Config from './config.mjs'; // 导入所有导出为一个命名空间对象

console.log('--- main.mjs 开始执行 ---');

console.log('App Config:', appConfig);
// Output: App Config: { theme: 'dark', language: 'en-US', notifications: true }

console.log('App Version:', version); // Output: App Version: 1.0.0

// 默认导出也可以是一个函数或类
// import MyClass from './myClass.mjs';

console.log('--- main.mjs 结束执行 ---');

示例 3: 动态 import()

dynamicModule.mjs:

// dynamicModule.mjs
export const message = "Hello from dynamic module!";
export const timestamp = Date.now();
console.log('dynamicModule.mjs 模块被加载和执行');

host.mjs:

// host.mjs
console.log('--- host.mjs 开始执行 ---');

// 动态导入,返回一个 Promise
const loadModule = async () => {
    console.log('尝试动态加载 dynamicModule.mjs');
    const module = await import('./dynamicModule.mjs');
    console.log('动态模块加载成功!');
    console.log('Message:', module.message);
    console.log('Timestamp:', module.timestamp);
};

// 只有当满足某个条件时才加载模块
setTimeout(() => {
    loadModule();
}, 1000);

console.log('host.mjs 其他代码继续执行...');

// 再次动态导入,会使用缓存
setTimeout(async () => {
    console.log('n再次尝试动态加载 dynamicModule.mjs');
    const module = await import('./dynamicModule.mjs');
    console.log('再次加载成功!');
    console.log('Message (cached):', module.message);
    console.log('Timestamp (cached - should be the same):', module.timestamp);
}, 2000);

console.log('--- host.mjs 结束执行 ---');

运行 node host.mjs:

--- host.mjs 开始执行 ---
host.mjs 其他代码继续执行...
--- host.mjs 结束执行 ---
尝试动态加载 dynamicModule.mjs
dynamicModule.mjs 模块被加载和执行
动态模块加载成功!
Message: Hello from dynamic module!
Timestamp: 1678886400000 (示例值)

再次尝试动态加载 dynamicModule.mjs
再次加载成功!
Message (cached): Hello from dynamic module!
Timestamp (cached - should be the same): 1678886400000 (示例值)

import() 是一个函数,它返回一个 Promise,允许你在运行时按需加载模块。这看起来像是 CommonJS 的 require,但它加载的仍然是 ESM 模块,并且返回的是一个包含所有导出的模块命名空间对象。它的本质是在运行时触发 ESM 的加载和求值阶段,但其解析和绑定机制仍然遵循 ESM 的静态规则。

4. 循环依赖处理 (ESM)

a-esm.mjs:

// a-esm.mjs
console.log('a-esm.mjs started');
import { b } from './b-esm.mjs';
export const a = 1;
console.log('a-esm.mjs got b:', b); // 此时 b 已经绑定,但在 b.mjs 中还没赋值
export const a2 = 2;
console.log('a-esm.mjs finished');

b-esm.mjs:

// b-esm.mjs
console.log('b-esm.mjs started');
import { a, a2 } from './a-esm.mjs';
export const b = 10;
console.log('b-esm.mjs got a:', a, a2); // a 此时是 1, a2 此时是 undefined
export const b2 = 20;
console.log('b-esm.mjs finished');

main-esm.mjs:

// main-esm.mjs
console.log('main-esm.mjs started');
import { a, a2 } from './a-esm.mjs';
import { b, b2 } from './b-esm.mjs';
console.log('In main, a:', a, a2);
console.log('In main, b:', b, b2);
console.log('main-esm.mjs finished');

运行 node main-esm.mjs (在 package.json 中设置 "type": "module"):

a-esm.mjs started
b-esm.mjs started
b-esm.mjs got a: 1 undefined
b-esm.mjs finished
a-esm.mjs got b: 10
a-esm.mjs finished
main-esm.mjs started
In main, a: 1 2
In main, b: 10 20
main-esm.mjs finished

与 CommonJS 相比,ESM 在 b-esm.mjs 中导入 a 时,尽管 a-esm.mjs 还在执行中,a 的值已经是 1 了,这是因为 export const a = 1 语句在实例化阶段已经被绑定。但是 a2 此时仍然是 undefined,因为它是在 b-esm.mjs 导入 a-esm.mjs 之后才被赋值的。这体现了 ESM 模块在实例化阶段的绑定优先于求值的特性,它能更好地处理循环依赖,因为它绑定的是变量引用,而不是值的拷贝。当 a2a-esm.mjs 中被赋值后,b-esm.mjs 中的 a2 引用会自动更新。

ES Modules 的优缺点

优点:

  • 静态分析能力: import/export 语句是编译时确定的,这使得工具(如 Linter、TypeScript、Webpack 等打包工具)能够进行深度静态分析,优化代码。
    • Tree Shaking (摇树优化): 未被 importexport 可以被打包工具自动移除,有效减小最终代码体积。
    • 提前错误检查: 模块路径错误、命名导入错误等可以在编译时发现。
  • 异步加载: 原生支持异步加载,在浏览器环境中可以优化性能,非阻塞地加载模块。
  • 实时绑定 (Live Bindings): 导入的变量是对原始导出变量的引用,如果原始变量在模块内部发生改变,导入方会看到更新后的值。这使得模块之间的交互更加动态和灵活。
  • 更完善的循环依赖处理: 通过其多阶段加载机制和实时绑定,ESM 能够更优雅地处理循环依赖,避免 CommonJS 中可能出现的不完整导出对象问题。
  • 统一标准: 旨在成为浏览器和 Node.js 的统一模块方案,未来趋势。

缺点:

  • Node.js 生态迁移成本: Node.js 长期使用 CommonJS,向 ESM 迁移需要处理兼容性问题,如 __dirname, __filename 的缺失,以及与 CommonJS 模块的互操作性。
  • 文件扩展名: 在 Node.js 中,默认情况下需要 .mjs 扩展名或在 package.json 中设置 "type": "module" 来指示模块类型,这有时会增加配置复杂性。
  • 动态导入的语法复杂性: import() 是异步的,需要 async/await.then() 处理,相对 require 稍微复杂。

静态加载与动态加载的底层机制对比

现在,让我们来一个直观的对比,聚焦于 CommonJS 的动态加载与 ES Modules 的静态加载,以及它们底层机制的差异。

特性 CommonJS ES Modules
加载时机 运行时动态加载 解析时静态加载 (除了 import())
import/require require() 是一个函数,可以在代码的任何地方调用,可以动态构造模块路径。 import 是一个语句,只能在模块的顶层使用,模块路径必须是静态字符串(编译时确定)。
导出机制 module.exports 是一个对象,exports 是其引用。导出的是值的拷贝(对于基本类型)或对象的引用 export 语句,支持命名导出和默认导出。导出的是实时绑定(live binding)。
同步/异步 同步加载 原生支持异步加载 (在浏览器中),在 Node.js 中通常表现为同步求值,但加载过程是异步的。 import() 总是异步。
缓存机制 require 模块后,其 module.exports 对象会被缓存。再次 require 直接返回缓存。 模块加载和求值后,结果会被缓存。再次 import 直接使用缓存,且绑定仍是实时的。
模块路径 require(path) 中的 path 可以是变量,允许动态构建。 import from 'path' 中的 path 必须是字面量字符串,无法动态构建。
this 指向 模块内的 this 指向 exports 对象。 模块内的 thisundefined(严格模式),避免全局污染。
特殊变量 模块内有 __filename, __dirname 模块内没有 __filename, __dirname(需通过 import.meta.url 模拟)。
Tree Shaking 不支持,因为依赖关系在运行时才能确定。 支持,因为依赖关系在解析时确定,未使用的导出可以被移除。
循环依赖 返回模块当前已导出部分的值的拷贝。可能导致获取到不完整的对象。 模块在求值前完成绑定,返回实时绑定。能更优雅地处理循环依赖。
适用场景 Node.js 服务器端应用,同步文件访问。 浏览器端(默认),现代 Node.js 应用,需要静态分析和优化。

为什么静态加载如此重要?

静态加载是 ESM 的核心优势,它带来了以下革命性的改变:

  1. 编译时优化:

    • Tree Shaking: 打包工具(如 Webpack, Rollup)可以在编译时分析模块依赖图,识别并删除未使用的代码(dead code),从而显著减小最终的打包文件体积。这是 CommonJS 无法做到的,因为 CommonJS 的 require 是动态的,工具无法在编译时确定哪些模块真正被使用了。
    • Scope Hoisting: 打包工具可以将多个模块的代码合并到同一个作用域中,减少函数调用开销,进一步优化性能。
    • 更好的错误检查: 编译时就能发现模块路径错误、导出导入名称不匹配等问题。
  2. 异步加载的实现:

    • 由于模块依赖关系是静态的,浏览器可以并行下载所有依赖的模块,而不需要等待前一个模块执行完毕才知道下一个需要下载什么。这对于网络请求密集的浏览器环境至关重要,能大大提升应用启动速度。
  3. 未来的可能性:

    • ECMAScript 标准委员会可以基于静态模块图设计更高级的功能,例如 Top-level await(顶级 await),它允许在模块的顶层直接使用 await,而不需要将其包裹在 async 函数中。这得益于模块加载的异步性和静态可分析性。

Node.js 中的模块互操作性与未来

Node.js 在很长一段时间内只支持 CommonJS。随着 ESM 的普及,Node.js 也逐渐加入了对 ESM 的支持。这引入了一些兼容性问题和新的约定:

  1. package.jsontype 字段:

    • package.json 中设置 "type": "module",则该包内的 .js 文件默认被视为 ESM。
    • 设置为 "type": "commonjs"(或不设置,这是默认值),则 .js 文件默认被视为 CommonJS。
    • 这使得开发者可以在同一项目中混合使用两种模块系统。
  2. 文件扩展名:

    • .mjs 文件总是被视为 ESM。
    • .cjs 文件总是被视为 CommonJS。
    • 这提供了覆盖 package.json type 字段的机制。
  3. 互操作性:

    • ESM 可以 import CommonJS 模块: ESM 模块可以像导入命名导出一样导入 CommonJS 模块的 module.exports 对象。CommonJS 模块的 default 导出就是其 module.exports 对象。

      // cjs-module.cjs
      module.exports = {
          message: 'Hello from CJS',
          data: 123
      };
      
      // esm-module.mjs
      import cjsModule from './cjs-module.cjs'; // 导入默认导出
      // import { message, data } from './cjs-module.cjs'; // 也可以这样,但实际上是解构 cjsModule.default
      console.log(cjsModule.message); // Output: Hello from CJS
    • CommonJS 模块无法直接 require ESM 模块: CommonJS 模块是同步加载的,而 ESM 模块的加载过程可能是异步的。直接 require ESM 模块会导致运行时错误。

      • 解决方案是使用动态 import() 函数在 CommonJS 模块中加载 ESM 模块,但这会返回一个 Promise,需要异步处理。
        
        // esm-module.mjs
        export const esmMessage = 'Hello from ESM';

      // cjs-app.cjs
      async function loadEsm() {
      const esm = await import(‘./esm-module.mjs’);
      console.log(esm.esmMessage); // Output: Hello from ESM
      }
      loadEsm();

  4. __filename, __dirname 缺失: 在 ESM 模块中,不再有全局的 __filename__dirname

    • 替代方案是使用 import.meta.url 和 Node.js 的 url 模块来构造路径:

      // esm-module.mjs
      import { fileURLToPath } from 'url';
      import { dirname } from 'path';
      
      const __filename = fileURLToPath(import.meta.url);
      const __dirname = dirname(__filename);
      
      console.log('__filename:', __filename);
      console.log('__dirname:', __dirname);

结语

CommonJS 以其简洁和同步特性,完美契合了早期 Node.js 服务器端开发的需要,为 JavaScript 模块化奠定了基础。然而,ES Modules 代表着 JavaScript 模块化的未来,它通过静态加载、实时绑定以及多阶段加载流程,实现了更强大的静态分析能力、更优的性能优化(如 Tree Shaking)和更灵活的异步加载,从而更好地适应了现代 Web 开发和 Node.js 的需求。理解这两种模块系统底层机制的差异,对于编写高效、可维护的 JavaScript 代码,以及充分利用现代工具链的优势至关重要。随着 ESM 的成熟和普及,我们正在迈向一个更加统一、高效和强大的 JavaScript 开发生态系统。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注