全面理解 CJS 是如何运行 `require.main === module`(CJS vs ESM)

67 阅读11分钟

1. require.main === module 这行代码是用来干什么的?

这句代码不知道你们在CJS 中有没有用过/见过?有没有第一眼看不懂的感觉,我也是这样的。

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

require.main === module

它的意思是:

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

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

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

在 Node.js 中,require 经常被用作函数来调用:

require('./foo.js');

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

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 文件的对象,代表“当前模块”。

它最核心的字段包括:

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 上:

// app.js
console.log(require.main === module); // true

当你执行:

node app.js

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

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

// index.js
require('./app.js');

那么在 app.js 内部:

console.log(require.main === module); // false

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

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

这是最经典的 CommonJS 用法:

if (require.main === module) {
  startServer();
} else {
  module.exports = { startServer };
}

它的目的非常清晰:

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

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

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

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

require.main === module

在 ESM 中,通常使用的是:

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 里模拟“是不是主入口”这个判断。

6. 为什么 CJS 能直接写 require / module / exports,而 ESM 没有这些“怪东西”?

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

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

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

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

require
module
exports
__filename
__dirname

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

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

const fs = require('node:fs');
module.exports = { fs };

而这套机制的代价是:

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

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

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

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

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

import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);

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

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. 多维度深度对比 CJS vs ESM

维度 1:模块加载机制

这个大家都懂的,不展开解释了

// CommonJS:同步动态 require
if (process.env.NODE_ENV === 'development') {
  const devTools = require('./dev-tools');
  devTools.init();
}

// ESM:顶层静态导入
import { initDevTools } from './dev-tools.js';

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

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

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

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

// 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;

消费端:

// app.js
const math = require('./math');
console.log(math.add(1, 2));

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

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

// 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') 加载全量代码:
    const Button = require('antd/lib/button');
    
  2. Babel 插件自动重写(babel-plugin-import): 为了改善开发体验,antd v4 推荐使用 babel-plugin-import 插件。开发者源码中依旧写:
    // 源码
    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 模块}

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

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

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)

  • CommonJS:module.exports 导出的是执行时刻的值拷贝(针对基本类型)。内部变量变更后,外部导入处不会同步。
  • ESM:导出的是内部变量的只读实时绑定(Live Binding)。由于模块单例运行,跨文件 import 的变量会同步更新最新值。
// 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 同样无法同步)
// 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 模块中 import 或 require 了一个内部使用 __dirname / __filename 的 CommonJS 依赖或本地模块,能否正常兼容运行?

答案:100% 完全兼容!

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

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 部署包,应使用 esbuild 或 tsup 进行 Bundling,将第三方 ESM 依赖解包并打入产物中。


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

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

1. 默认导入 (Default Import)

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

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 可能不一致。
  • 推荐方案:采用 默认导入 + 局部解构:
    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=1 或 process.enableCompileCache() 可自动将编译字节码写入磁盘缓存。
  2. 代码加密保护:bytenode 依赖 CJS 函数包装器,不支持 ESM 源码直接生成 .jsc。纯 ESM 项目需先使用 esbuild 打平为单文件 CJS,再通过 V8 字节码工具编译。

6. 模块选型建议

ESM 的写法很爽,支持良好的Tree-sharking,在对体积要求高的web 项目应该全面采用ESM。

但在BFF/服务端,Electron preload/main层面还是建议使用CJS,利用打包工具编码时享受ESM 的红利,产物输出CJS 保持兼容性。

建议是两种写法都得学习,知道其优势&局限性。