搞不清 CommonJS 与 ESM,你是否也有这些疑问🤔

1. 为什么你总会在 CJS 和 ESM 之间"卡住"?

很多开发者都遇到过这种困惑:

  • 为什么 requireimport 能同时存在?
  • 为什么 CJS 能动态 require(),而 ESM 不能?
  • 为什么 module.exportsexport default 看起来完全不是一回事?

本文通过问题驱动的方式,让我们一起看懂这两者的本质,才能理解为什么 Node.js、TypeScript、构建工具和 NPM 生态里,很多问题都会围绕它们展开。


2. 多维度深度对比 CJS vs ESM

维度 1:模块加载机制

  • CommonJSrequire() 是运行时同步调用的函数,允许放置在条件分支或函数内部:

    typescript 复制代码
    // CommonJS:同步动态 require
    if (process.env.NODE_ENV === 'development') {
      const devTools = require('./dev-tools');
      devTools.init();
    }
  • ESM

    1. 静态导入 (import ... from ...) :必须写在模块顶层,引擎在代码执行前完成静态解析并构建依赖图谱:

      typescript 复制代码
      // ESM:顶层静态导入
      import { initDevTools } from './dev-tools.js';
    2. 动态导入 (import()) :提供 import() 函数用于按需加载。与 CJS 同步的 require() 不同,import() 返回 Promise(异步加载):

      typescript 复制代码
      // ESM:动态条件导入(返回 Promise)
      if (process.env.NODE_ENV === 'development') {
        const devTools = await import('./dev-tools.js');
        devTools.init();
      }

问题:为什么 CJS 能直接写 require / module / exports,而 ESM 没有这些"怪东西"?

这其实是两套模块系统的底层设计差异决定的。

CJS 的这几个符号,本质上不是 JavaScript 语言原生语法,而是 Node 在运行时注入的"包装器参数"。每个 CommonJS 文件在加载时,都会被包装成类似这样的函数:

javascript 复制代码
(function (exports, require, module, __filename, __dirname) {
  // 你的代码
});

因此,下面这些东西在 CJS 中会"神奇地出现":

javascript 复制代码
require
module
exports
__filename
__dirname

它们并不是 ECMAScript 标准规定的语言术语,而是 Node 的模块加载器给每个文件偷偷塞进去的运行时环境。

所以你会看到 CJS 的代码中可以直接写:

javascript 复制代码
const fs = require('node:fs');
module.exports = { fs };

而这套机制的代价是:

  1. 模块解析与加载更依赖运行时实现;
  2. require() 可以动态发生在任何位置;
  3. 代码很难被静态分析器准确识别和剪枝。

ESM 则完全不同:它是 ECMAScript 的语言级标准,不依赖 Node 运行时向每个文件注入这些私有对象。ESM 的设计目标是"模块语法标准化",因此更强调:

  • import / export 是语言级声明;
  • 依赖关系在编译/解析阶段就可确定;
  • 更好地支持静态分析、Tree-Shaking、跨环境兼容。

所以你在 ESM 里看不到 requiremoduleexports 这类 Node 私有对象,因为它们根本不是 ESM 的标准语法。

ESM 里要获取文件路径时,通常用的是:

javascript 复制代码
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);

而不是直接使用 CJS 里的 __filename;对应地,模块入口判断也通常要用:

javascript 复制代码
import { pathToFileURL } from 'node:url';

const isMain =
  process.argv[1] &&
  import.meta.url === pathToFileURL(process.argv[1]).href;

一句话概括

CJS 的 require / module / exports 更像"Node 注入的运行时魔法",而 ESM 的 import / export 更像"语言标准中的模块语法"。


维度 2:Tree-Shaking 可靠性分析

1. 为什么 CommonJS 难以被 Tree-Shaking?

CommonJS 的 module.exports 本质是一个动态的 JavaScript 对象。导出属性支持运行时赋值、条件修改或解构。打包工具(如 Webpack、Rollup)在静态分析阶段无法判断属性是否会被反射(如 Object.keys())、动态计算或在运行时改变。

javascript 复制代码
// math.js (CommonJS)
function add(a, b) { return a + b; }
function unusedSquare(n) { return n * n; }

// 运行时动态赋值:打包工具无法静态确认 unusedSquare 是否被使用
const exportedMethods = { add };
if (process.env.ENABLE_MATH_EXTRA) {
  exportedMethods.unusedSquare = unusedSquare;
}
module.exports = exportedMethods;

消费端:

javascript 复制代码
// app.js
const math = require('./math');
console.log(math.add(1, 2));

打包工具扫描 app.js 时,只能识别到获取了整个 module.exports 对象。出于引用完整性考虑,打包工具必须保留 math.js 的完整代码。

相对地,ESM 采用静态导出与不可变符号绑定(Live Bindings),打包工具能准确识别未被 import 的符号并清理:

typescript 复制代码
// math.ts (ESM)
export function add(a: number, b: number) { return a + b; }
export function unusedSquare(n: number) { return n * n; } // 未被 import 则构建时剔除

2. CommonJS 如何实现按需加载?(以 antd v4 为例)

由于 CommonJS 无法自动做到函数级别的 Tree-Shaking,传统的 CommonJS UI 库或工具库(例如 Ant Design v4)采用了以下两种手段进行手动/插件化标记与按需加载:

  1. 子路径独立导出(Subpath Imports) : 将组件拆分为独立文件,避免直接 require('antd') 加载全量代码:

    javascript 复制代码
    const Button = require('antd/lib/button');
  2. Babel 插件自动重写(babel-plugin-import : 为了改善开发体验,antd v4 推荐使用 babel-plugin-import 插件。开发者源码中依旧写:

    javascript 复制代码
    // 源码
    import { Button, DatePicker } from 'antd';
    // 转换后
    import Button from 'antd/lib/button';
    import DatePicker from 'antd/lib/date-picker';
  3. package.json 中的 "sideEffects" 声明 : 打包工具读取 "sideEffects": false 告知打包流程:"未直接引用的子导出无副作用,可跳过处理"。


维度 3:NPM 生态与 Pure ESM 兼容隔离

在 Node.js v22 之前,大量 NPM 基础库(如 node-fetch v3+、chalk v5+、execa v6+)升级为 Pure ESM(不再提供 CJS 产物),导致 CJS 项目中调用出现隔离:

ESM 项目 →完全兼容 CommonJS 模块 \text{ESM 项目} \xrightarrow{\text{完全兼容}} \text{CommonJS 模块} ESM 项目完全兼容 CommonJS 模块

CommonJS 项目 →调用报错 ERR_REQUIRE_ESM Pure ESM 模块 \text{CommonJS 项目} \xrightarrow{\text{调用报错 ERR\_REQUIRE\_ESM}} \text{Pure ESM 模块} CommonJS 项目调用报错 ERR_REQUIRE_ESM Pure ESM 模块

在旧版 Node.js 中使用 require('node-fetch') 会直接报错:

text 复制代码
Error [ERR_REQUIRE_ESM]: require() of ES Module node-fetch not supported.

!NOTE 版本演进与破局提示 :在 Node.js v22.0.0+ 中,官方推出了 require(ESM) 特性,已部分打破了这一硬性隔离屏障。其原理是Node 在 CJS 的 require() 里插入了一层"ESM 适配器",让 CJS 能调用 ESM 模块,但前提是 ESM 不能带有异步初始化(Top-Level Await)。

维度 4:循环依赖与 Live Bindings(实时绑定)

1. 值拷贝 (CJS) vs 实时绑定 (ESM)

  • CommonJSmodule.exports 导出的是执行时刻的值拷贝(针对基本类型)。内部变量变更后,外部导入处不会同步。
  • ESM :导出的是内部变量的只读实时绑定(Live Binding)。由于模块单例运行,跨文件 import 的变量会同步更新最新值。
javascript 复制代码
// CJS:值拷贝,无法跨文件同步
// counter.cjs
let count = 0;
function increment() { count++; }
module.exports = { count, increment };

// appA.cjs
const { count, increment } = require('./counter.cjs');
console.log(count); // 0
increment();        // 修改了 counter.cjs 内部的 count,但导出对象的 count 依然是 0
console.log(count); // 仍然是 0 ❌ (无法同步)

// appB.cjs
const counter = require('./counter.cjs');
console.log(counter.count); // 仍然是 0 ❌ (不同文件 require 同样无法同步)
javascript 复制代码
// ESM:Live Binding,跨文件实时同步
// counter.mjs
export let count = 0;
export function increment() { count++; }

// appA.mjs
import { count, increment } from './counter.mjs';
increment();
console.log('AppA count:', count); // 1

// appB.mjs (在 AppA 后引入)
import { count } from './counter.mjs';
console.log('AppB count:', count); // 1 (实时同步)

2. 循环依赖处理

  • CommonJS :依赖动态 require() 的同步执行。发生循环引用时,可能返回未执行完的空 exports 对象 {},引发 TypeError
  • ESM:代码执行前先构建依赖图谱。只要变量在访问时已完成初始化,即能通过 Live Binding 读取,避免因返回空对象而崩溃。

维度 5:ESM 是如何做到兼容 CJS的?

常见疑问 :如果在 ESM 模块中 importrequire 了一个内部使用 __dirname / __filename 的 CommonJS 依赖或本地模块,能否正常兼容运行?

答案:100% 完全兼容!

  • 模块包装器(Module Wrapper)隔离 :Node.js 在加载 CommonJS 模块时,始终会使用经典的 CJS 函数包装器将该 CJS 源码包裹执行:

    javascript 复制代码
    (function (exports, require, module, __filename, __dirname) {
      // CJS 文件的实际代码,__dirname 是此处注入的形参/局部变量
    });
  • 独立作用域解析 :CJS 模块内部的 __dirname 属于该模块独立的闭包作用域,其值完全由该 CJS 文件在磁盘上的真实物理路径决定,与谁调用/导入了它(无论是 ESM 还是 CJS)毫无关系。因此,ESM 引入任何包含 __dirname 的 CJS 代码均不会发生作用域冲突或兼容性报错。

特性 CommonJS (CJS) ES Modules (ESM)
路径变量 原生 __dirname / __filename 无原生变量,需基于 import.meta.url 转换
Top-Level Await ❌ 不支持(必须包裹在 async 函数内) ✅ 支持(可在顶层直接 await
扩展名规范 允许省去扩展名 (require('./foo')) 现代 Node.js 强制保留扩展名 (import './foo.js')

在 ESM 中实现 __dirname__filename

typescript 复制代码
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';

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

console.log(__filename); // /path/to/project/src/index.js
console.log(__dirname);  // /path/to/project/src

局限性

  1. 打包至非 Node.js 环境(如 Cloudflare Workers、浏览器)时,import.meta.url 可能被替换为绝对 URL,且 node:path 无法直接运行。
  2. 全量打包(Bundle)为单文件后,import.meta.url 指向构建后的产物文件路径,丢失原源码文件物理层级。

3. "源码写 ESM,TSC 编译为 CommonJS" 的陷阱

配置 "module": "CommonJS" 让 TypeScript 编译产物存在以下问题:

  1. 无法解决第三方 Pure ESM 依赖 :TSC 仅转换项目自身代码为 require()。当源码含有 import fetch from 'node-fetch' 时,编译后变为 const fetch = require('node-fetch'),运行时依然引发 ERR_REQUIRE_ESM
  2. Top-Level Await 编译失败 :TSC 无法将 ESM 的顶层 await 语法转换为同步的 CJS 代码。

解决方案 :若需交付 CJS 部署包,应使用 esbuildtsup 进行 Bundling,将第三方 ESM 依赖解包并打入产物中。


4. 问题三:在 ESM 中引用 CommonJS 你需要注意什么?

Node.js 原生支持在 ESM 中导入 CommonJS 模块,但需要注意以下两种导入语法的区别:

1. 默认导入 (Default Import)

Node.js 会自动将 CJS 的 module.exports 挂载至 ESM 的 default 导出:

typescript 复制代码
import bencode from 'bencode';
const decoded = bencode.decode(buffer);

2. 具名导入 (Named Import)

Node.js 使用 C++ 词法分析器 cjs-module-lexer 扫描 CJS 的 exports.foo = ... 结构。

注意:CJS 具名导入无法实现 Tree-Shaking,且存在隐患。

  • 为何不推荐具名导入cjs-module-lexer 仅做词法匹配,非语法解析。遇到动态赋值(exports[key] = ...)、Object.assign 或导出对象二次加工时,词法分析会失败,抛出 SyntaxError: Named export 'foo' not found。此外,不同打包工具的分析规则与原生 cjs-module-lexer 可能不一致。

  • 推荐方案 :采用 默认导入 + 局部解构

    typescript 复制代码
    import bencode from 'bencode';
    const { decode, encode } = bencode;

澄清:"默认导入 + 解构" 会导致 Tree-Shaking 失效吗?

CommonJS 的 module.exports 本质是动态对象,打包工具无法对其做函数级死代码剔除。具名导入 CJS 仅是语法糖,打包工具编译后依然载入整个 CJS 模块。因此,使用默认导入解构不会增加体积包袱。


5. ESM 与 V8 字节码缓存 (Bytecode Cache)

  1. 冷启动性能 :Node.js 对 CJS 和 ESM 均支持 V8 字节码内存与磁盘缓存。Node.js v22+ 启用 NODE_COMPILE_CACHE=1process.enableCompileCache() 可自动将编译字节码写入磁盘缓存。
  2. 代码加密保护bytenode 依赖 CJS 函数包装器,不支持 ESM 源码直接生成 .jsc。纯 ESM 项目需先使用 esbuild 打平为单文件 CJS,再通过 V8 字节码工具编译。

6. 模块选型建议

  • 新建 TypeScript / 服务端 / CLI 项目 :首选全链路纯 ESM(配置 "type": "module""module": "NodeNext")。
  • 维护既有 CommonJS 项目 :升级至 Node.js v22+ 利用 require(ESM);涉及 Top-Level Await 依赖时使用动态 import()
  • 需兼容旧版 Node.js 环境 :使用 TypeScript + esbuild / tsup 交付 Bundled CJS 产物。

7. 全面理解 require.main === module

这句代码看起来像"比较两个对象是否相等",但它其实是 Node.js CommonJS 模块系统中的一个非常关键判断:

javascript 复制代码
require.main === module

它的意思是:

"当前这个模块是不是程序启动时的入口模块?"

如果为 true,说明这个文件就是被 node xxx.js 直接执行的那个入口;如果为 false,说明它只是被其它文件 require() 进来的依赖模块。

1. require 不是"只有函数",它本身也是对象

在 Node.js 中,require 看起来像一个函数:

javascript 复制代码
require('./foo.js');

但它其实是一个"函数对象",除了能调用之外,还挂着很多属性:

javascript 复制代码
console.log(typeof require); // 'function'
console.log(require.main);  // 入口模块 或 undefined
console.log(require.cache); // 缓存对象
console.log(require.resolve('./foo.js')); // 解析路径

也就是说:

  • require 是一个函数
  • 它同时也是一个对象
  • require.main 是它身上的一个属性

这和 JavaScript 里"函数也是对象"是完全一致的。

2. module 是当前模块的描述对象,不是导出对象

module 是 Node 自动注入给每个 CommonJS 文件的对象,代表"当前模块"。

它最核心的字段包括:

javascript 复制代码
console.log(module.id);
console.log(module.filename);
console.log(module.parent);
console.log(module.children);
console.log(module.loaded);
console.log(module.exports);

其中:

  • module.exports:最重要,决定这个模块对外暴露什么
  • module.filename:当前文件的绝对路径
  • module.parent:谁依赖了它
  • module.children:它依赖了哪些模块
  • module.loaded:是否完成加载

注意:module 不是"导出值本身",而是"当前模块的容器对象";真正对外导出的是 module.exports

3. require.main 指向的是"程序入口模块"

Node.js 在启动时,会记录一个"主模块"对象,挂在 require.main 上:

javascript 复制代码
console.log(require.main === module); // true 仅在入口文件中

举例:

javascript 复制代码
// app.js
console.log(require.main === module); // true

当你执行:

bash 复制代码
node app.js

app.js 就是入口模块,所以它里面的 modulerequire.main 指向同一个对象。

但如果 app.js 被另一个模块引入:

javascript 复制代码
// index.js
require('./app.js');

那么在 app.js 内部:

javascript 复制代码
console.log(require.main === module); // false

因为它不是入口,而只是被其它代码要求加载的模块。

4. 为什么它常被用来做"脚本入口判断"?

这是最经典的 CommonJS 用法:

javascript 复制代码
if (require.main === module) {
  startServer();
} else {
  module.exports = { startServer };
}

它的目的非常清晰:

  • 如果这个文件是直接运行的脚本,就启动程序
  • 如果它被别的模块 require(),就只暴露接口,不要自动执行

这是一种非常常见的"库/脚本双模式"设计。

5. module.exportsexports 的关系

许多人容易混淆:

javascript 复制代码
module.exports = { a: 1 };

和:

javascript 复制代码
exports.a = 1;

两者都能输出东西,但它们并不是同一个引用。

核心注意点:

  • module.exports 是真正的导出对象
  • exports 实际上是 module.exports 的快捷别名(初始化时指向同一个对象)
  • 一旦你重新赋值 module.exports = ...exports 就不再和它等价
javascript 复制代码
exports.foo = 1;          // 正常追加属性
module.exports = { bar: 2 }; // 直接替换整个导出对象

这个细节经常是 CommonJS 新手踩坑的地方。

6. require.main === module 的本质:判断"我是不是入口"

把它抽象成一句话:

javascript 复制代码
require.main === module

等价于:

"当前这个模块是不是 Node 进程启动时的主模块?"

它并不表示"这个对象里有个 main 字段",而是:

  • require 是一个带属性的函数对象
  • require.main 是它身上的入口引用
  • module 是当前模块对象
  • 如果两者相同,说明当前文件就是启动入口

7. ESM 中如何实现类似逻辑?

ESM 没有 require / module 这两个变量,因此不能直接写:

javascript 复制代码
require.main === module

在 ESM 中,通常使用的是:

javascript 复制代码
import { pathToFileURL } from 'node:url';

const isMain =
  process.argv[1] &&
  import.meta.url === pathToFileURL(process.argv[1]).href;

if (isMain) {
  console.log('我是入口文件');
}

它的语义是:

"当前模块的 URL,是否等于 Node 启动脚本的 URL?"

也就是在 ESM 里模拟"是不是主入口"这个判断。

8. 一句话总结

require.main === module 的真实含义就是:

这个文件是不是被直接执行启动的入口文件,而不是被别人 require()/import 进来的模块。

它背后依赖的知识点包括:

  • require 是函数对象,并挂有 maincache 等属性
  • module 是当前模块对象,并有 exportsfilenamechildren 等字段
  • module.exports 才是模块真正暴露出去的内容
  • ESM 没有 require.main,所以需要借助 import.meta.urlprocess.argv[1] 去模拟类似逻辑

这也是为什么它在 CommonJS 里如此经典:它非常直观地刻画了"脚本入口"和"可复用模块"的边界。

相关推荐
cindershade1 小时前
用 Web Workers 优化前端重计算任务:避免主线程卡顿的实战方案
前端
YIAN1 小时前
http无状态?State来展示!从基础路由到鉴权守卫,吃透 SPA 前端路由核心
前端·react.js
__zRainy__1 小时前
Node系列 · Node基础:全局变量与全局对象
开发语言·前端·javascript
码云骑士1 小时前
104-实战论文搜索引擎-ArXiv爬取-Milvus存储-RAG问答-Gradio前端
前端·python·搜索引擎·milvus
sunly_1 小时前
TypeScript总结:15、类型速查
前端·javascript·typescript
Brown.alexis1 小时前
es6知识点3-自备使用
前端·javascript·es6
2601_953988071 小时前
Ricon组态系统vs传统组态软件:为什么选择新一代Web组态平台
前端·后端·物联网·tcp/ip·数学建模·前端框架
天空之城--1 小时前
Claude Code 高效开发 Web 2D/3D 完全指南:心法、自定义 Skill 体系与社区技能包实战
前端·3d
IT_陈寒2 小时前
SpringBoot自动配置坑了我三天,原来漏了这个注解
前端·人工智能·后端