JavaScript模块化开发入门:CommonJS与ES Module的区别

作者:服务小站时间:2026-11-03 23:31:34浏览:507

全面解析JavaScript模块化开发的核心概念,系统对比CommonJS与ES Module在语法规范、加载时机、数据传递语义、运行上下文及工程优化等维度的关键区别,并提供具体的互操作指南与选型建议。

在早期浏览器脚本时代,JavaScript 并没有语言级别的模块组织机制,所有变量都挂载在全局作用域下,极易引发命名冲突与依赖失序。随着应用复杂度提升,JavaScript 社区与官方委员会相继推出了两大主流模块化标准:2009 年随 Node.js 兴起的 CommonJS(简称 CJS),以及 2015 年在 ECMAScript 6 中正式确立的语言标准 ES Module(简称 ESM)。理解二者的根本区别,核心在于掌握“运行时同步执行”与“编译时静态解析”、以及“值的浅拷贝”与“动态只读引用”的底层逻辑。
JavaScript模块化开发入门:CommonJS与ES Module的区别
缩略图:CommonJS 与 ES Module 核心属性概览

一、规范背景与语法形式对比

在模块化方案成熟之前,JavaScript 开发者通常使用立即执行函数表达式(IIFE)或 AMD/CMD 方案来封装私有作用域。随着 Node.js 在 2009 年发布,CommonJS 规范作为服务端文件组织的核心标准确立下来;而 ES Module 则是 ECMAScript 官方在 2015 年推出的现代化标准,旨在为浏览器和服务器端提供一致的原生模块方案。

1. CommonJS 的语法规范

CommonJS 模块将每一个文件视为一个独立的模块实例。模块内部包含预置的 module、exports 以及 require 全局对象:

// math.js - CommonJS 导出方式
const PI = 3.1415926;

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

// 导出对象或属性
module.exports = {
  PI,
  add
};

// main.js - CommonJS 导入方式
const { PI, add } = require('./math.js');
console.log(add(2, 3)); // 输出: 5

在 CommonJS 中,require() 是一个可以在任意逻辑位置调用的普通 JavaScript 函数,这意味着你可以把它放在 if 分支判断或循环内部,实现动态路径加载。

2. ES Module 的语法规范

ES Module 采用静态保留关键字 import 和 export,将模块规范直接提升为语法层面的机制:

// math.js - ES Module 导出方式
export const PI = 3.1415926;

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

// 默认导出
export default function multiply(a, b) {
  return a * b;
}

// main.js - ES Module 导入方式
import multiply, { PI, add } from './math.js';
console.log(add(2, 3));        // 输出: 5
console.log(multiply(2, 3));   // 输出: 6

在 ES Module 中,静态的 import 声明必须出现在模块文件的最顶层,不能嵌套在任何 if 语句、函数或循环体中,这为编译阶段的静态依赖分析打下了基础。

JavaScript模块化开发入门:CommonJS与ES Module的区别
图 1:CommonJS 与 ES Module 核心架构全景对照

二、加载机制差异:运行时同步 vs 编译时三阶段

在理解模块化时,最核心的技术分水岭在于:模块何时被解析?何时开始执行? 这一根本机制决定了两者的行为差异与优化上限。

1. CommonJS 的运行时即时加载流程

CommonJS 最初专为服务端设计。由于本地文件系统的读取时间通常以微秒计算,CommonJS 采用了同步阻塞模式:

  • 当代码执行到 const a = require('./a.js') 时,引擎立即暂停后续执行。
  • 首先查询内置的 require.cache 缓存对象;若已存在缓存,则直接返回其缓存的导出对象。
  • 若未命中缓存,Node.js 会读取目标文件内容,使用函数包装器将其包裹起来:(function(exports, require, module, __filename, __dirname) { ... }),然后同步执行该脚本。
  • 脚本执行完毕后,将 module.exports 的值注入缓存,并返回给调用者,最后才继续执行 require 下方的代码。

2. ES Module 的三阶段处理流程

ES Module 必须同时服务于浏览器网络请求与服务器环境。网络延迟可能高达数百毫秒,若采用同步阻塞将会造成页面渲染严重冻结。因此,ESM 规范将模块处理划分为三个清晰解耦的独立阶段:

  1. 构建阶段(Loading & Parsing): 引擎自顶向下解析入口文件的 import 声明,递归定位并异步下载所有依赖脚本。在不执行代码的前提下,通过纯词法分析将文件转化为抽象语法树(AST),并生成“模块记录”(Module Record)。
  2. 实例化阶段(Instantiation): 引擎在内存中为所有导出的变量开辟内存插槽(Memory Slots),并将导入方的 import 引用精准指向导出方的内存地址。此时仅仅建立引用指针通道,仍然不执行任何函数或计算逻辑。
  3. 求值阶段(Evaluation): 引擎按照拓扑排序,从最深层的依赖模块开始执行顶层代码,将计算出的真实数值填充到此前开辟的内存插槽中。
JavaScript模块化开发入门:CommonJS与ES Module的区别
图 2:CommonJS 同步运行时加载与 ES Module 编译三阶段解耦机制

三、核心底层语义:值的浅拷贝 vs 动态只读引用

在日常开发中,许多初学者经常对“模块导出的值在外部为什么没有随之改变”产生困惑。这是由于 CommonJS 与 ES Module 在内存模型层面的本质差异决定的。

1. CommonJS:值的浅拷贝(值快照)

当通过 module.exports = { count, inc } 导出时,实际上是创建了一个 JavaScript 对象,并将 count 当前持有的基本类型值直接复制到该对象的属性上:

// counter-cjs.js
let count = 1;

function inc() {
  count++;
}

module.exports = {
  count,
  inc
};

// main-cjs.js
const { count, inc } = require('./counter-cjs');

console.log(count); // 输出: 1
inc();              // 模块内部的私有变量 count 累加变成了 2
console.log(count); // 依然输出: 1 !外部持有的是旧值的拷贝快照

由于 CommonJS 导出的是对象的浅拷贝,在模块内部执行 inc() 修改的是该模块闭包中的内部变量 count,而外部解构得到的 count 依然停留在初次执行加载时复制的数值上,两者在内存中已经脱钩。

2. ES Module:动态只读引用(Live Bindings)

与 CommonJS 完全不同,ES Module 的 export 导出的不是值的快照,而是对内存插槽的实时绑定通道(Live Binding):

// counter-esm.js
export let count = 1;

export function inc() {
  count++;
}

// main-esm.js
import { count, inc } from './counter-esm.js';

console.log(count); // 输出: 1
inc();              // 模块内部修改了 count
console.log(count); // 输出: 2 !动态引用实时感知内部变化

// 注意:导入的绑定是只读的
count = 10;          // 抛出 TypeError: Assignment to constant variable.

在 ES Module 中,外部文件的 count 变量像一个只读指针,始终直通导出模块所在的内存插槽。当模块内部修改 count 时,外部访问会立即看到最新的值;同时,ESM 规范强制规定导入的绑定具有只读性,外部文件绝不能直接重写导入的变量,保证了模块状态的单向可控。

JavaScript模块化开发入门:CommonJS与ES Module的区别
图 3:CommonJS 值浅拷贝与 ES Module 动态只读引用(Live Bindings)内存模型对比

四、运行环境与上下文差异

除了导入导出的执行机制,两者在运行上下文、顶层变量和异步特性上也存在显著差异:

1. 顶层 this 的指向

  • CommonJS: 模块顶层的 this 指向当前模块的导出对象,即 this === exports === module.exports。
  • ES Module: 严格遵循标准 JavaScript 的严格模式(Strict Mode),模块顶层的 this 永远是 undefined,防止意外污染全局对象。

2. 文件路径变量:__dirname 与 import.meta.url

在 Node.js CommonJS 环境下,每个模块在包裹函数中被直接注入了 __dirname(当前文件所在文件夹绝对路径)和 __filename(当前文件完整绝对路径)。而在 ES Module 中,这两个变量不复存在,需要通过 import.meta.url 结合 Node.js 原生模块进行转换:

// CommonJS 获取路径
const path = require('path');
const configPath = path.resolve(__dirname, 'config.json');

// ES Module 获取路径等价写法
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const configPath = resolve(__dirname, 'config.json');

3. 顶层 await(Top-level Await)

在现代 ES Module 中,原生允许在模块的最顶层直接编写 await 关键字,而无需将其包裹在 async function 自执行函数内。执行引擎会在求值阶段等待该 Promise 决议,再继续执行依赖该模块的其他模块。CommonJS 则无法在顶层使用 await,必须使用异步函数进行封装。

五、工程实践:互操作性与配置选型

在现阶段的 Node.js 与前端项目中,CommonJS 与 ES Module 常常共存。掌握两者之间的相互调用规则与配置策略,是避免诸如 ERR_REQUIRE_ESM 报错的关键。

1. Node.js 中的模块类型识别规则

Node.js 依据两个核心依据来判定一个 .js 文件到底是 CommonJS 还是 ES Module:

  • package.json 中的 "type" 字段:
    • 若声明 "type": "module",则当前项目目录下所有 .js 文件默认均作为 ES Module 处理。
    • 若未声明或声明 "type": "commonjs",则所有 .js 文件默认作为 CommonJS 处理。
  • 文件显式扩展名:
    • .mjs 文件无论 package.json 如何设置,均强制作为 ES Module 解析。
    • .cjs 文件无论 package.json 如何设置,均强制作为 CommonJS 解析。

2. 双向互操作规则(调用边界)

跨规范调用核心铁律:

1. ESM 可以调用 CommonJS: 使用默认导入 import pkg from './cjs.cjs' 或通过 createRequire 创建 require 实例均可安全引入。

2. CommonJS 无法同步引入 ESM: 绝不能使用 require('./esm.mjs'),否则会直接抛出 ERR_REQUIRE_ESM 错误。若必须在 CJS 中调用 ESM,必须使用动态函数 import('./esm.mjs') 异步加载并处理 Promise。

// 在 CommonJS 文件中调用 ES Module 必须采用动态 import()
async function loadData() {
  // 动态 import() 返回一个 Promise
  const { processData } = await import('./esm-utils.mjs');
  processData();
}
JavaScript模块化开发入门:CommonJS与ES Module的区别
图 4:项目模块规范选型决策与双向互操作路线图

六、核心区别总结表

为了便于速查与复习,以下将 CommonJS 与 ES Module 在日常开发所涉及的核心维度进行系统汇总:

对比维度 CommonJS (CJS) ES Module (ESM)
核心语法 require() / module.exports import / export / export default
规范来源 社区规范,由 Node.js 推广流行 (2009) ECMAScript 官方标准 (ES6 / 2015)
加载时机 运行时同步加载执行,可置于条件与循环内 编译阶段静态构建依赖树,三阶段异步解耦
导出值传递 值的浅拷贝(生成当前快照,后续与原值脱钩) 动态只读引用(Live Bindings)(实时保持同步)
Tree-shaking 难以进行静态死代码剔除 原生强力支持,编译期精准剔除未引用代码
顶层 this 指向当前模块导出对象 module.exports 严格模式下为 undefined
路径全局变量 原生注入 __dirname 与 __filename 通过 import.meta.url 组合转换获取
顶层 await 不支持,必须包裹在 async 函数内 原生语法级支持顶层 await
互操作边界 无法直接同步 require ESM;必须用动态 import() 可直接 import CJS 模块并获取其 default 导出

七、写在最后

理解 CommonJS 与 ES Module 的区别,不仅是熟悉两套语法差异,更是理解现代 JavaScript 工具链演进的基础。在今天的新项目构建中,优先选用 ES Module 已成为前端与 Node.js 社区的共识,它能够为现代构建工具(如 Vite、Rollup、Webpack)提供完整的静态分析能力,最大化减小产物体积;而在维护存量 Node.js 基础设施或使用尚未完成 ESM 改造的第三方库时,熟练运用 .cjs、动态 import() 和条件导出,也能确保整个开发体系平稳高效地运行。

作者最新文章

相关标签:

相关文章
更多