CommonJS与ES Module模块加载机制差异及互通方案

作者:星河

时间:2026-10-30

浏览:666

深入对比CommonJS与ES Module的模块加载机制差异,涵盖静态分析、动态执行、值拷贝与只读引用绑定、循环引用处理,并提供Node.js双向互通及Dual Package配置实操方案。

文章目录

JavaScript 模块化生态长期存在 CommonJS(CJS)与 ES Module(ESM)两套核心规范。理解两者的根本差异,关键在于掌握“运行时同步执行求值”与“编译期静态语法分析三阶段”在加载模型上的本质分歧。本文将全面拆解两者的加载时机、导出值传递机制、循环引用处理方式,并提供在 Node.js 环境下切实可行的双向互通及双模块(Dual Package)发布方案。

一、核心加载机制深度对比

CommonJS 最初为服务端设计,假定模块代码存放在本地磁盘,因此采用了同步的 require() 加载模型。代码执行到该行时立即同步读取文件、封装函数并执行求值。而 ECMAScript 官方规范推出的 ES Module 旨在面向网络和浏览器环境,无法承受网络阻塞,因而将模块解析与代码执行严格拆分为异步三阶段:构造(Construction)、实例化(Instantiation)与求值(Evaluation)。

CommonJS与ES Module模块加载机制差异及互通方案
图1:CommonJS 运行时同步加载与 ES Module 静态三阶段生命周期对比

1. 导出值机制:浅拷贝快照 vs 动态只读引用 (Live Binding)

这是导致逻辑 Bug 最常见的一处机制差异:

  • CommonJS 产出的是值的拷贝:一旦 module.exports 被求值并赋值完毕,其导出的基本类型变量便是一个独立的快照。模块内部后续对该变量的修改,不会影响到外部引入的值。
  • ES Module 建立的是动态只读引用(Live Binding):导入的标识符并不是一个独立的值,而是直接连接到导出模块内部作用域对应内存槽的符号链接。当导出模块内部改变该变量时,导入端读取到的值会实时同步变化,同时导入端禁止直接对该变量进行赋值(抛出 TypeError: Assignment to constant variable)。
CommonJS与ES Module模块加载机制差异及互通方案
图2:CommonJS 值拷贝快照与 ES Module 动态只读引用(Live Binding)内存模型对比

2. 顶层作用域与环境内置变量差异

CommonJS 模块在运行时会被 Node.js 包裹进一个函数内部:

(function (exports, require, module, __filename, __dirname) {
  // 你的模块实际业务代码
});

因此在 CommonJS 中,全局的 this 默认指向 exports(最初为一个空对象 {}),同时可以直接使用 __dirname 与 __filename。但在 ES Module 规范中,代码默认运行于严格模式(strict mode),顶层 this 为 undefined,且标准环境不存在 __dirname 和 __filename,必须通过 import.meta.url 转换:

// ESM 获取当前目录与文件绝对路径的规范做法
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

3. 循环引用(Circular Dependency)处理差异

在面对模块相互引用(A 依赖 B,B 依赖 A)时:

  • CommonJS:当执行到循环点时,不会重新执行 A,而是从缓存中直接读取 A 当前“已执行并挂载在 exports 上的属性”。如果此时 A 尚未给 exports 赋值目标属性,消费端将拿到 undefined。
  • ES Module:在实例化阶段,所有符号引用关系均已建立。执行求值时遇到循环引用,并不会中断。如果直接在顶层执行解构访问尚未求值的 export let,会触发暂时性死区(TDZ, ReferenceError);但如果将访问逻辑放在函数内部(延迟到运行时调用),由于符号指针已经绑定,函数调用时即可安全获取到最终值。

二、Node.js 中的模块系统识别规则

Node.js 依据两项关键要素判定一个文件应以 CommonJS 还是 ES Module 解析:

  1. 文件显式扩展名:
    • .mjs:无论何时,强制按 ES Module 解析。
    • .cjs:无论何时,强制按 CommonJS 解析。
    • .js:取决于最近的父级 package.json 中的 type 字段。
  2. package.json 的 type 配置:
    • 如果配置了 "type": "module",则同目录及子目录下的所有 .js 文件均被当做 ESM 处理。
    • 若缺失或显式指定为 "type": "commonjs",所有 .js 文件按 CJS 处理。

三、CommonJS 与 ES Module 双向互通方案

在迁移或重构过渡期,往往需要让两个系统互相调用。两者的互通并非完全对称,必须依据运行上下文选择正确的载入策略。

CommonJS与ES Module模块加载机制差异及互通方案
图3:CommonJS 与 ES Module 互通调用决策路径与关键代码示例

1. ESM 项目消费 CommonJS 依赖

ESM 可以直接通过 import 引用 CommonJS 模块。Node.js 会自动将 CommonJS 模块的 module.exports 映射为 ESM 的 default 导出对象:

// 推荐:直接引入默认对象后进行访问
import lodash from 'lodash';
console.log(lodash.map([1, 2], x => x * 2));
注意具名解构的边界: Node.js 对 CommonJS 进行了静态词法启发式分析(cjs-module-lexer),能够识别形如 exports.foo = ... 的常见模式,并允许 import { foo } from 'cjs-pkg'。但如果该 CJS 模块是动态挂载属性(例如 Object.assign(module.exports, ...)),词法分析器便无法检测到具名导出,从而抛出 SyntaxError: The requested module does not provide an export named 'foo'。遇到该情况时,应统一改用默认导入后解构。

2. CJS 项目消费纯 ESM 依赖

当旧项目仍基于 CommonJS,而引入的外部库已经是 Pure ESM(如新版 node-fetch、chalk 等)时,执行 const pkg = require('pure-esm-pkg') 会直接触发 ERR_REQUIRE_ESM 错误。

唯一标准方案是使用 动态 import(),它可以在 CommonJS 文件内以异步 Promise 形式加载 ESM:

// CommonJS 文件中加载 ESM
async function run() {
  const chalk = (await import('chalk')).default;
  console.log(chalk.green('成功加载 ESM 依赖'));
}

run();

3. 库开发者打造双模块(Dual Package)发布规范

如果希望自己开发的 NPM 包既能在 CJS 项目被 require(),又能在 ESM 项目被静态 import,推荐使用现代 package.json 的 exports 条件导出 字段:

{
  "name": "my-universal-library",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.mjs"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    }
  },
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts"
}

关键规则配置说明:

  • import 条件:当调用方通过 import 语法引用时触发,指向构建出的 ESM 产物 .mjs。
  • require 条件:当调用方通过 require() 语法引用时触发,指向构建出的 CJS 产物 .cjs。
  • types 条件:必须位于对应条件下的最上方,以保证 TypeScript 编译器优先捕获类型声明。

四、常见误区与典型报错排查

常见认知说法 判定与成立条件 深层原因与修正说明
“ESM 只是语法更现代的 CommonJS” 不成立 不仅是语法糖,底层加载架构从根本上由同步执行变为异步三阶段,且变量传递从值拷贝变为了内存活链接。
“通过 Babel/TS 编译后,所有环境都能完美互通” 部分成立(需看编译目标) 若 Babel/TS 直接把 import 转译为 require,则仍是在 CJS 运行时执行,一旦目标环境引入原生 ESM 库依然会报错。必须根据运行时规范生成对应产物。
“在 Node.js 中可以在 CommonJS 里直接 require 一个 ESM 文件” 不成立 虽然 Node 内部探索过 require(esm),但遇到含顶层 await 的 ESM 模块在技术上根本无法同步返回,因此生产环境必须依赖动态 import()。
CommonJS与ES Module模块加载机制差异及互通方案
图4:CommonJS 与 ES Module 全生命周期关键机制与架构差异总结

五、结语

CommonJS 满足了早期服务端脚本同步、简单、灵活的诉求,而 ES Module 则是面向全平台、异步网络与构建期优化的现代化标准规范。在实际业务演进中,充分掌握加载生命周期(三阶段 vs 运行时同步)、内存语义(实时引用 vs 值快照)以及双向互通路径(动态 import() 与 package.json 条件导出),即可在平稳过渡技术栈的同时,规避各类隐晦的循环引用与模块解析异常。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章