JavaScript模块化规范:CommonJS与ES Module区别及使用

作者:星河时间:2026-11-03 09:49:45浏览:143

深入解析 JavaScript 两大主流模块化规范 CommonJS 与 ES Module 的核心概念、使用语法、底层加载机制与动态引用差异,并提供 Node.js 环境下的互操作实践指南。

JavaScript 进阶 规范标准:CommonJS / ECMAScript 2015 (ES6) 运行环境:Node.js & 现代浏览器
JavaScript模块化规范:CommonJS与ES Module区别及使用
图 0:CommonJS 与 ES Module 核心特性与架构概览

在 JavaScript 语言发展的早期,语言规范自身并没有提供模块化机制,社区为了解决全局变量污染与依赖混乱问题,诞生了以 Node.js 为代表的 CommonJS (CJS) 规范;随后,ECMAScript 2015 (ES6) 正式推出了语言层面的官方模块系统 ES Module (ESM)。两者的核心差异在于:CommonJS 属于运行时同步加载,导出的是值的浅拷贝;而 ES Module 采用编译时静态结构解析与异步加载,导出的是值的动态只读引用(Live Bindings)。本文将针对两者的语法规范、执行生命周期、内存引用模型以及在现代工程中的互操作方案进行系统梳理。

一、CommonJS 规范的语法与运作方式

CommonJS 最早在服务端由 Node.js 普及并作为默认模块系统。在 CommonJS 规范中,每一个 JavaScript 文件都被视为一个独立的模块实例。模块内部声明的所有变量与函数都处于该模块的私有作用域中,不会泄漏到全局作用域。

1. 模块导出:module.exports 与 exports

CommonJS 通过 module.exports 对象对外暴露内容。在每个模块执行前,Node.js 会在顶层注入 exports 变量,该变量初始时是指向 module.exports 的一个内存引用。

// utils/math.js
const PI = 3.1415926;

function add(a, b) {
  return a + b;
}

// 方式一:直接在 exports 上挂载属性
exports.PI = PI;
exports.add = add;

// 方式二:直接覆盖 module.exports(常用于导出单例函数或类)
module.exports = {
  PI,
  add,
  multiply(a, b) {
    return a * b;
  }
};
避坑提醒:切勿直接给 exports 赋新值

由于模块最终对外返回的是 module.exports 指向的对象,如果执行 exports = { add },只是切断了 exports 与 module.exports 的关联引用,并不会改变 module.exports,导致导出失效。正确的做法是操作 exports.xxx = ... 或直接赋值 module.exports = ...。

2. 模块导入:require()

使用内置全局函数 require(modulePath) 来同步导入模块。调用 require 时,运行时会执行目标模块的脚本并返回其 module.exports 对象的拷贝引用。

// app.js
const { add, multiply, PI } = require('./utils/math.js');

console.log(add(2, 3));        // 输出: 5
console.log(multiply(4, PI));  // 输出: 12.5663704

// CommonJS 允许在条件分支中动态调用 require
if (process.env.NODE_ENV === 'development') {
  const devHelper = require('./devHelper.js');
  devHelper.init();
}

CommonJS 采用同步磁盘 I/O 读取并具有模块缓存机制。当某个模块第一次被 require 时,该模块会被完整执行一次,其导出对象被存入 require.cache。后续再次引入相同路径的模块时,直接从缓存读取,不会重复执行模块内部代码。

二、ES Module 规范的语法与运作方式

ES Module 是 ECMAScript 官方在 ES2015 规范中确立的标准。它被设计为面向浏览器和服务器的通用模块系统,最大的特点在于其依赖关系的静态化:在代码执行前的编译/语法解析阶段,模块的导入与导出依赖关系就已经确定。

1. 模块导出:命名导出与默认导出

ES Module 提供了两种导出方式:命名导出(Named Exports)与默认导出(Default Export)。

// utils/calculator.mjs

// 命名导出:声明时直接导出
export const VERSION = '2.0.0';

export function calculateTax(amount, rate) {
  return amount * rate;
}

// 统一集中命名导出
const DEFAULT_RATE = 0.08;
export { DEFAULT_RATE };

// 默认导出:每个模块只能有一个 default 导出
export default class Calculator {
  constructor(base = 0) {
    this.base = base;
  }
}

2. 模块导入:静态 import 与动态 import()

静态 import 语句必须出现在模块的顶层作用域,不能包裹在 if、循环或函数内,这保证了编译器能够无障碍地分析依赖图(Dependency Graph)。

// main.mjs

// 1. 混合导入默认与命名导出
import Calculator, { VERSION, calculateTax } from './utils/calculator.mjs';

// 2. 重命名导入
import { DEFAULT_RATE as taxRate } from './utils/calculator.mjs';

// 3. 命名空间整体导入
import * as Calc from './utils/calculator.mjs';

// 4. 动态按需导入:返回 Promise
if (condition) {
  const module = await import('./analytics.mjs');
  module.sendReport();
}

3. 在不同环境中启用 ES Module

  • Node.js 环境:
    1. 在 package.json 中声明 "type": "module",此时项目内所有 .js 文件均被解析为 ESM。
    2. 或者将文件扩展名显式命名为 .mjs(即便 package.json 未配置,Node.js 也会以 ESM 解析)。
  • 现代浏览器环境: 在 HTML 脚本标签上显式声明 type="module",浏览器将自动以异步跨域安全的模块模式下载并执行:
    <script type="module" src="./main.js"></script>
JavaScript模块化规范:CommonJS与ES Module区别及使用
图 1:CommonJS 与 ES Module 核心维度架构对比

三、CommonJS 与 ES Module 的关键区别深度解析

很多初学者仅把两者的区别看作语法上 require 与 import 的形式不同。但在实际工程与性能优化中,底层运作机制的差异直接决定了构建产物体积、热更新效率以及程序的执行正确性。

1. 加载时机与执行机制:运行时加载 vs 编译时静态解析

CommonJS 属于运行时求值(Runtime Evaluation)。当你调用 require('./module') 时,JavaScript 引擎会立刻暂停当前脚本,同步读取文件、解析执行,并把 module.exports 的值注入到当前变量中。因为 require() 是一个常规的函数调用,它可以写在任何表达式、if 分支或循环中:

// CommonJS 支持完全动态的路径计算与条件加载
const moduleName = condition ? './pluginA' : './pluginB';
const plugin = require(moduleName);

ES Module 采用静态分析(Static Analysis)与分阶段处理。ECMAScript 规范将 ESM 的加载与执行严格划分为三个阶段:

  1. 构建阶段(Construction):查找、下载并解析所有涉及的 .mjs 文件,解析 import 与 export 声明生成 Module Record(模块记录)。如果存在语法错误或找不到导入符号,在此阶段就会报错,尚未执行任何一行用户代码。
  2. 实例化阶段(Instantiation):在内存中分配模块环境记录(Module Environment Record),将所有的 export 与对应的 import 建立符号指针链接(链接内存槽位),此时内存槽中尚未填充实际的值。
  3. 求值阶段(Evaluation):按照依赖拓扑图从叶子节点到根节点依次执行模块内部代码,计算表达式并将求得的值填入实例化时开辟的内存槽中。
JavaScript模块化规范:CommonJS与ES Module区别及使用
图 2:生命周期对比:CommonJS 同步加载 vs ES Module 三阶段流水线

2. 值的绑定机制:值的拷贝 vs 动态只读引用(Live Bindings)

这是 CommonJS 和 ES Module 最根本的行为区别,直接影响数据同步与状态维护:

  • CommonJS 导出的是值的浅拷贝(值缓存):在执行完模块导出语句后,导出的原始类型(数字、字符串、布尔值)即成为一份独立的副本。模块内部后续对该变量进行修改,外部引入的值不会自动更新。
  • ES Module 导出的是动态只读引用(Live Bindings):导入方并不拥有一份独立的值拷贝,而是拥有一个指向导出模块内部环境记录的符号链接。当模块内部更新该变量时,导入方读取到的值会实时发生变化;同时,导入方不能直接修改导入的值,赋值操作会抛出 TypeError。

以下示例清晰地展现了这一差异:

/* ----- CommonJS 表现 ----- */
// counter-cjs.js
let count = 1;
function increment() {
  count++;
}
module.exports = { count, increment };

// test-cjs.js
const { count, increment } = require('./counter-cjs.js');
console.log(count);      // 输出: 1
increment();
console.log(count);      // 依然输出: 1!(因为导入的是解构时的数字浅拷贝)
/* ----- ES Module 表现 ----- */
// counter-esm.mjs
export let count = 1;
export function increment() {
  count++;
}

// test-esm.mjs
import { count, increment } from './counter-esm.mjs';
console.log(count);      // 输出: 1
increment();
console.log(count);      // 实时输出: 2!(动态指针绑定,实时同步)

count = 10;             // 报错: TypeError: Assignment to constant variable! (外部只读)
JavaScript模块化规范:CommonJS与ES Module区别及使用
图 3:数据导出机制对比:值拷贝缓存 vs 动态只读引用 (Live Bindings)

3. 运行上下文与全局内置对象的差异

从 CommonJS 切换到 ES Module 时,很多开发者会遭遇“变量不存在”的报错,这是由于顶层运行环境上下文不同:

  • 顶层 this 的指向:在 CommonJS 模块中,顶层的 this 指向 exports(即 module.exports);而在 ES Module 中,顶层 this 严格定义为 undefined。
  • Node.js 特有内置全局变量:CommonJS 模块被包裹在一个函数闭包内,因此拥有预设的 __dirname(当前文件所在目录)和 __filename(当前文件绝对路径)。而在 ES Module 中,这些变量均不存在,必须使用 import.meta.url 转换:
    // 在 ESM (.mjs 或 "type": "module") 中获取当前路径的标准解法
    import { fileURLToPath } from 'url';
    import { dirname } from 'path';
    
    const __filename = fileURLToPath(import.meta.url);
    const __dirname = dirname(__filename);

四、Node.js 环境下的互操作与最佳实践

在实际项目演进中,往往无法在瞬间将所有代码库完全迁移为 ESM,CommonJS 和 ES Module 在同一项目中共存是长期的常态。正确处理两者的互操作是保障系统稳定运行的关键。

1. ES Module 引入 CommonJS 模块

在 ES Module 中引入 CommonJS 模块,Node.js 提供了良好的向下兼容。可以通过默认导入方式获取 CommonJS 的 module.exports:

// 在 ESM (main.mjs) 中引入 CommonJS
import cjsPackage from './legacy-cjs.cjs';
cjsPackage.doSomething();

// 如果需要引入特定纯 CJS 专属工具,还可以借助 createRequire 构建同步 require 函数:
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const packageJson = require('./package.json');
注意:解构命名导入的限制

在 ESM 中对 CommonJS 模块使用解构命名导入(例如 import { add } from './math.cjs')依赖 Node.js 内部的 cjs-module-lexer 静态分析。对于高度动态赋值的 module.exports,静态分析可能无法提前识别导出的属性,导致报错。推荐始终使用默认导入,然后从默认对象中解构。

2. CommonJS 引入 ES Module 模块

核心限制:CommonJS 模块无法使用同步的 require() 引入 ES Module。如果在 CommonJS 文件中执行 require('./esm.mjs'),Node.js 会抛出不可捕获的运行时异常:ERR_REQUIRE_ESM。

唯一正确的引入方式是使用动态 import()。由于动态 import() 本身返回 Promise,因此必须采用异步方式消费:

// 在 CommonJS (app.cjs) 中引入 ES Module
async function loadESM() {
  // 必须使用动态 import(),不能使用 require()
  const { default: Calculator, VERSION } = await import('./calculator.mjs');
  const calc = new Calculator();
  console.log('ESM Version:', VERSION);
}

loadESM();
JavaScript模块化规范:CommonJS与ES Module区别及使用
图 4:Node.js 环境下 CJS 与 ESM 互操作决策路线与操作步骤

3. 核心差异快速速查表

对比项目 CommonJS (CJS) ES Module (ESM)
规范提出方 Node.js 社区规范 ECMAScript 官方标准 (ES2015+)
关键字语法 require() / module.exports import / export / import()
加载与解析阶段 运行时动态同步加载 编译时静态解析 + 运行时异步执行
导出数据本质 值的浅拷贝(值缓存副本) 符号的动态只读引用(Live Bindings)
Tree Shaking 支持 极难(动态赋值无法预测) 原生无缝支持(基于静态拓扑结构)
作用域顶级 this 指向 module.exports undefined
路径元数据获取 __dirname, __filename import.meta.url 结合 fileURLToPath
浏览器支持 原生不支持(必须借助 Webpack/Vite 等打包) 现代浏览器原生支持(<script type="module">)
互相调用姿势 引入 ESM:必须使用异步 import() 引入 CJS:推荐默认导入 import pkg from '...'

五、总结与工程选型建议

CommonJS 作为伴随 Node.js 成长十余年的经典模块方案,以其简单直观的运行时同步模型支撑起了丰富的 npm 生态;而 ES Module 作为现代 ECMAScript 的统一标准,凭借静态结构分析、原生异步拓扑、支持 Tree Shaking 与动态 Live Bindings,代表着 JavaScript 工程化演进的未来方向。

在实际项目工程中:

  • 对于新建的前端应用与现代 Node.js 服务,应优先使用 "type": "module" 全面采用 ES Module 规范,以便享受现代构建工具的极致树摇优化与跨端复用能力。
  • 对于开发公共开源类库(npm package),建议在 package.json 中通过 exports 字段配置 Dual Package(双模块导出),分别提供 import(ESM)和 require(CJS)入口,最大化保障下游不同架构项目的消费兼容性。
  • 在维护老旧 CommonJS 项目且需消费纯 ESM 依赖时,应当熟练掌握动态 import() 或采用 .mjs / .cjs 显式扩展名隔离技术,避免出现 ERR_REQUIRE_ESM 阻塞系统运行。
作者最新文章
相关文章
更多