JavaScript CommonJS require 与 ES Module import 的区别

作者:栖木时间:2026-10-30 18:47:39浏览:238

深入对比 JavaScript 中 CommonJS 的 require 与 ES Module 的 import 的核心机制差异,包括加载时机、值拷贝与实时绑定、动态引用、作用域上下文及工程实践中的混用与选型。

CommonJS 与 ES Module 是 JavaScript 领域两套设计哲学截然不同的模块化标准。它们最根本的区别在于:CommonJS 的 require 是运行时同步加载,导入的是导出值的浅拷贝与缓存对象;而 ES Module 的 import 采用静态解析机制,在代码执行前完成依赖图构建,导入的是对模块导出符号的只读实时绑定(Live Binding)。理解这两种机制在解析时机、数据引用与作用域环境上的差异,是解决现代工程构建报错与模块混用问题的关键。

一、基础语法与调用形式对比

在语法表现上,CommonJS(简称 CJS)最初由 Node.js 广泛推广,而 ES Module(简称 ESM)则是 ECMAScript 官方引入的标准语法规范。

CommonJS 依赖全局注入的 require 函数和 module.exports(或简写的 exports)对象实现模块导出与引入:

// math-cjs.js (CommonJS 导出)
function add(a, b) {
  return a + b;
}
module.exports = { add };

// main-cjs.js (CommonJS 导入)
const { add } = require('./math-cjs.js');
console.log(add(2, 3));

ES Module 则使用专有的关键字 export 和 import:

// math-esm.js (ES Module 导出)
export function add(a, b) {
  return a + b;
}

// main-esm.js (ES Module 导入)
import { add } from './math-esm.js';
console.log(add(2, 3));

从语义上看,require() 是一个可以直接在任何表达式中调用的普通 JavaScript 函数;而静态 import 属于语言级别的关键字声明,必须位于当前文件的顶层作用域,不能直接包裹在条件判断或函数块内。

二、加载机制:运行时同步加载 vs 静态三阶段解析

CommonJS 与 ES Module 在底层的运行生命周期存在本质分歧。在 Node.js 中,执行一个 require() 调用时,解释器会立即同步中断当前脚本的执行,转而读取并执行目标脚本代码,将其导出的对象存入模块缓存(require.cache),最后返回该对象。

相比之下,ES Module 规范为了适配浏览器端异步网络请求与模块分析,将模块加载拆分为三个明确独立的阶段:

  1. 构建与解析(Construction / Parsing):在不执行任何实际业务代码的前提下,从入口文件开始递归扫描所有的 import 语句,下载并解析为抽象语法树(AST),构建出完整的静态依赖图谱。
  2. 符号实例化(Instantiation):为每一个导出的变量在内存中分配专门的插槽(Bindings),并将导入方的标识符指向导出方对应的内存地址,此时变量尚未赋值。
  3. 求值与执行(Evaluation):按照依赖拓扑顺序,由叶子节点向根节点自底向上执行模块内部代码,将实际计算结果填入对应的内存插槽中。
JavaScript CommonJS require 与 ES Module import 的区别
图 1:ES Module 静态三阶段生命周期与 CommonJS 同步阻塞执行流程对比

由于 ES Module 在真正运行业务逻辑前就已经完全知晓依赖结构,现代前端打包工具(如 Rollup、Webpack、Vite)能够基于这种静态结构进行精确的死代码消除(Tree Shaking),剔除未被使用的导出函数。而 CommonJS 的动态特性使得打包工具极难在静态分析期安全删除未引用代码。

三、导出机制本质:值拷贝 vs 实时动态绑定 (Live Binding)

两者的另一个本质区别体现在数据的传递方式上。CommonJS 输出的是值的浅拷贝与快照,而 ES Module 输出的是对内存中变量的实时只读引用。

1. CommonJS 的值拷贝现象

在 CommonJS 中,当一个模块通过 module.exports 暴露基本数据类型变量时,外部获取到的是该变量当前状态的一份拷贝。如果模块内部后续通过内部方法更改了这个变量的值,外部已经获取到的变量不会随之同步更新:

// 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 (外部拿到的仅是导出时刻的数值拷贝)

原因在于 module.exports = { count, increment } 创建了一个普通字面量对象,将当时的 count 数值(1)赋值给对象的属性。后续调用 increment() 修改的是模块文件闭包作用域内的本地变量 count,与外部对象上的属性已完全解耦。

2. ES Module 的实时绑定 (Live Binding)

ES Module 的导入变量并非拷贝,而是直接建立与源模块符号内存地址的实时链接:

// counter-esm.js
export let count = 1;
export function increment() {
  count++;
}

// test-esm.js
import { count, increment } from './counter-esm.js';
console.log(count); // 输出: 1
increment();
console.log(count); // 输出: 2 (实时反映模块内部状态的变化!)

需要特别注意的是,在导入方(test-esm.js)中,count 是一个只读绑定(Read-only Binding)。直接在外部对导入变量执行赋值(如 count = 10;)会在编译/运行阶段抛出 TypeError: Assignment to constant variable 错误。变量的修改权严格保留在定义该变量的源模块内部。

JavaScript CommonJS require 与 ES Module import 的区别
图 2:CommonJS 的浅拷贝隔离与 ES Module 内存符号实时绑定对比

四、动态加载与条件引用的差异

在业务开发中,开发者有时需要根据环境变动、用户权限或运行时入参来动态引入不同的逻辑文件。CommonJS 与 ES Module 在处理动态依赖时的策略明显不同。

1. CommonJS 的原生动态灵活性

由于 require 是一个纯粹的执行期函数,它可以在任何控制流语句中无缝运行,甚至参数本身也可以是动态拼接的字符串:

// 运行时根据环境变量动态读取
let logger;
if (process.env.NODE_ENV === 'production') {
  logger = require('./loggers/prodLogger.js');
} else {
  logger = require('./loggers/devLogger.js');
}

// 动态拼接文件路径
const pluginName = 'analytics';
const plugin = require(`./plugins/${pluginName}.js`);

2. ES Module 的顶层限制与动态 import()

标准的静态 import ... from '...' 不允许嵌套在 if 分支或函数体内部,否则会在代码词法解析阶段直接报语法错误(SyntaxError)。

为了满足按需加载与代码分割(Code Splitting)的需求,ES 规范引入了函数形式的 import(specifier) 动态操作符。与 CommonJS 的同步返回不同,import() 会返回一个 Promise 对象,必须通过 await 或 .then() 获取导出内容:

// ES Module 中的动态条件加载
let logger;
if (process.env.NODE_ENV === 'production') {
  const prodMod = await import('./loggers/prodLogger.js');
  logger = prodMod.default;
} else {
  const devMod = await import('./loggers/devLogger.js');
  logger = devMod.default;
}
JavaScript CommonJS require 与 ES Module import 的区别
图 3:条件加载模型与依赖图可分析性架构对比

五、作用域与执行上下文差异

由于两者的运行宿主和加载包装方式不同,它们在上下文变量和关键字的指向细节上也存在若干常见差异:

  • 顶层 this 指向:在 CommonJS 模块的顶层作用域中,this 指向当前模块的 exports 对象;而在严格模式下的 ES Module 顶层作用域中,this 为 undefined。
  • 路径内置变量:CommonJS 模块在运行时会被 Node.js 包装为一个函数入参(包括 exports, require, module, __filename, __dirname),因此可直接访问 __dirname 和 __filename。ES Module 则不再注入这些变量,需要通过 import.meta.url 配合 Node.js 的 url 模块进行获取:
    // 在 ES Module 中获取当前目录与文件路径
    import { fileURLToPath } from 'url';
    import { dirname } from 'path';
    
    const __filename = fileURLToPath(import.meta.url);
    const __dirname = dirname(__filename);
  • 顶层 Await(Top-Level Await):在现代 Node.js 与主流浏览器支持的 ES Module 中,可以直接在模块顶层使用 await 关键字进行异步初始化;而 CommonJS 模块的同步包装函数默认不支持直接编写顶层 await。

六、核心特性综合决策矩阵

为了便于读者快速查阅与建立系统化的知识网络,以下矩阵将 CommonJS 与 ES Module 在核心维度的技术特性进行了对照归纳:

JavaScript CommonJS require 与 ES Module import 的区别
图 4:CommonJS 与 ES Module 核心特性综合决策矩阵

七、Node.js 中的混用与现代工程实践

在现阶段的 Node.js 工程中,常常需要面临两套规范并存的现状。掌握两者的互操作规则可以有效避免运行时报错:

1. Node.js 对 ESM 模式的判定方式

Node.js 默认将 .js 文件视为 CommonJS 模块。如果要启用 ES Module 解析,可以通过以下两种方式:

  • 在 package.json 中明确声明 "type": "module",此时当前项目下所有的 .js 文件均按照 ESM 解析。
  • 将模块文件后缀命名为 .mjs(显式声明为 ESM)或 .cjs(显式声明为 CommonJS)。

2. 互操作关键规则

规则一:ESM 文件可以引入 CommonJS 模块

在 ESM 模块中可以使用 import package from 'cjs-module' 引入 CommonJS 库。此时 CommonJS 的 module.exports 整体会被包裹为 ESM 的 default 导出对象。

规则二:CommonJS 模块无法通过同步 require() 引入纯 ESM 模块

如果尝试在 CommonJS 文件中使用 require('./esm-file.mjs'),Node.js 会抛出 ERR_REQUIRE_ESM 错误。若必须在 CJS 中加载纯 ESM 模块,唯一的合规做法是使用异步函数:const esmMod = await import('./esm-file.mjs');。

3. 选型建议与总结

现代浏览器原生已全面支持 ES Module,同时全生命周期的新版主流 NPM 库(如 lodash-es、chalk、node-fetch 等)均在积极转向 ESM-only。在搭建新的前端工程或现代 Node.js 服务端应用时,优先采用 ES Module 作为全项目的基准规范,以便充分发挥静态检查、更小打包体积(Tree Shaking)和平台通用性的核心优势。而在维护既有老旧服务端基础设施或传统自动化脚本时,保留 CommonJS 则能兼顾更低的历史迁移成本。

作者最新文章
相关文章
更多