Node.js 的模块系统经历了从 CommonJS 一统天下到 ESM 逐步普及的漫长演变,而 require(esm) 的出现标志着这场持续近十年的分裂终于走向统一。据 npm 官方统计,截至 2026 年初已有超过 65% 的流行包提供了 ESM 支持,但仍有大量存量项目卡在 CJS 到 ESM 的迁移过程中。如果你还在为 require vs import、module.exports vs export default、__dirname 在 ESM 中不可用等问题头疼,这篇文章将为你提供一套完整的解决方案。
📦 一、CJS vs ESM:两大模块系统的核心差异
1.1 CommonJS:Node.js 的原始模块系统
CommonJS(CJS)是 Node.js 从 2009 年诞生起就使用的模块系统。它的设计目标很简单——在服务端实现模块化,使用 require() 函数加载模块,module.exports 导出内容。CJS 的核心特点是同步加载、运行时解析,这意味着 require() 调用会阻塞当前线程直到模块加载完成。
// CJS 模块 - math.js
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
module.exports = { add, multiply };
// CJS 导入 - app.js
const math = require('./math.js');
console.log(math.add(1, 2)); // 3
console.log(math.multiply(3, 4)); // 12
CJS 的同步特性在服务端完全没有问题(文件系统 I/O 是同步的),但这也注定了它无法在浏览器端原生运行。更重要的是,CJS 的动态特性(可以在任何位置 require、条件 require、计算路径 require)虽然灵活,却让静态分析工具(Tree Shaking)无法工作。
1.2 ESM:JavaScript 的官方标准模块系统
ESM(ECMAScript Modules)是 TC39 标准化的模块系统,从 ES2015(ES6)开始进入 JavaScript 规范。它使用 import/export 语法,核心优势在于编译时静态分析——引擎在执行代码之前就能确定模块的依赖关系和导入导出关系。
// ESM 模块 - math.mjs
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
// ESM 导入 - app.mjs
import { add, multiply } from './math.mjs';
console.log(add(1, 2)); // 3
console.log(multiply(3, 4)); // 12
ESM 的静态结构使得打包工具(如 Rollup、esbuild、Vite)能够精确分析哪些导出被使用了,哪些没有,从而实现 Tree Shaking——移除未使用的代码,显著减小打包体积。
💡 提示: 在 Node.js 中使用 ESM 有两种方式:将文件扩展名改为
.mjs,或者在package.json中设置"type": "module"。推荐后者,因为.js文件会默认被当作 ESM 处理,代码风格更统一。
1.3 关键差异对比
| 特性 | CommonJS (CJS) | ESM |
|---|---|---|
| 语法 | require() / module.exports |
import / export |
| 加载方式 | 同步加载 | 异步加载 |
| 解析时机 | 运行时动态解析 | 编译时静态分析 |
this 值 |
当前模块对象 module.exports |
undefined |
| 循环依赖 | 返回部分完成的导出对象 | 通过 Live Bindings 获取最新值 |
| 动态导入 | 原生支持 require() |
需要 import() 函数 |
| Tree Shaking | ❌ 不支持 | ✅ 支持 |
| 浏览器原生支持 | ❌ 不支持 | ✅ 支持 |
| 顶层 await | ❌ 不支持 | ✅ 支持 |
require 关键字 |
✅ 可用 | ❌ 不可用 |
⚡ 关键结论: ESM 是 JavaScript 的官方标准和未来方向,但 CJS 在 Node.js 生态中仍然根深蒂固。理解两者的核心差异,是正确选择模块策略的前提。
🔄 二、require(esm) 与 Package Exports 实战
2.1 require(esm):终结模块分裂的关键特性
在 require(esm) 出现之前,CJS 模块想要使用 ESM 包只能通过动态 import() 实现,这带来了明显的痛点:
// ❌ 旧方式:必须使用 async/await,改变了执行流程
async function main() {
const { parse } = await import('json5');
const result = parse('{ a: 1 }');
console.log(result);
}
main(); // 顶层代码变成了异步的
这种写法有三个致命问题:一是顶层代码变成了异步执行,改变了程序的执行顺序;二是无法在模块顶层直接使用导入的值;三是对大型代码库来说,逐个文件迁移 ESM 的成本极高。
require(esm) 彻底解决了这个问题——你现在可以直接在 CJS 文件中 require() ESM 模块:
// ✅ 新方式:直接在 CJS 中 require ESM 模块
// app.cjs (CommonJS 文件)
const { parse } = require('json5'); // json5 是 ESM-only 包
const result = parse('{ a: 1 }');
console.log(result); // { a: 1 }
使用条件:
- ✅ 被导入的 ESM 模块不能包含顶层 await(Top-Level Await),否则 require() 会抛出
ERR_REQUIRE_ASYNC_MODULE错误 - ✅ 需要 Node.js 版本支持此特性(Node.js 22+ 逐步稳定,24+ 默认启用)
- ✅ 被导入的文件需要被识别为 ESM(
.mjs扩展名,或package.json中"type": "module")
⚠️ 警告: require(esm) 是单向桥梁——CJS 可以 require ESM,但 ESM 文件仍然不能使用
require()函数。如果你需要在 ESM 中加载 CJS 模块,直接使用import语法即可,Node.js 会自动处理。
2.2 Package Exports:现代包的入口定义方式
传统的 main 字段只能定义一个入口,而 exports 字段可以定义多个子路径入口,并支持条件导出:
{
"name": "my-toolkit",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./string": {
"types": "./dist/string.d.ts",
"import": "./dist/string.mjs",
"require": "./dist/string.cjs"
},
"./array": {
"types": "./dist/array.d.ts",
"import": "./dist/array.mjs",
"require": "./dist/array.cjs"
}
}
}
用户使用时:
// ESM 用户
import { capitalize } from 'my-toolkit/string';
// CJS 用户
const { capitalize } = require('my-toolkit/string');
条件导出按声明顺序匹配,第一个匹配的条件生效。推荐的顺序是:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
}
}
}
📌 记住:
types条件必须放在最前面!TypeScript 的模块解析会优先匹配types条件来获取类型定义。如果放在后面,TypeScript 可能会错误地使用.js文件的类型推断而非.d.ts文件的精确类型。
2.3 Dual Package 发布实践
Dual Package 是指同时支持 CJS 和 ESM 的包。使用 tsup 作为构建工具,配置非常简洁:
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts', 'src/string.ts', 'src/array.ts'],
format: ['cjs', 'esm'],
dts: true,
clean: true,
splitting: true,
outExtension({ format }) {
return { js: format === 'cjs' ? '.cjs' : '.mjs' };
}
});
配合 package.json 配置:
{
"name": "my-toolkit",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"files": ["dist"],
"scripts": {
"build": "tsup"
}
}
⚡ 关键结论: 对于 npm 包开发者,Dual Package 是 2026 年的最佳实践——同时服务 CJS 和 ESM 用户,让 require(esm) 负责桥接,无需强迫用户做选择。
⚠️ 三、常见踩坑与最佳实践
3.1 __dirname 和 __filename 在 ESM 中不可用
这是从 CJS 迁移到 ESM 时遇到的第一个错误。在 CJS 中,__dirname 和 __filename 是全局变量,但在 ESM 中它们不存在:
// ❌ ESM 中直接使用 __dirname 会报错
console.log(__dirname);
// ReferenceError: __dirname is not defined in ES module scope
// ✅ 正确做法:使用 import.meta.url
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const configPath = join(__dirname, 'config.json');
console.log(configPath); // /home/user/project/config.json
如果项目中大量使用 __dirname,可以封装一个工具函数减少重复代码:
// utils/dirname.js
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
export function getDirname(importMetaUrl) {
return dirname(fileURLToPath(importMetaUrl));
}
// 使用
import { getDirname } from './utils/dirname.js';
const __dirname = getDirname(import.meta.url);
3.2 JSON 导入方式的差异
CJS 中可以直接 require('./data.json'),但 ESM 中不能这样做。Node.js 提供了多种解决方案:
// ❌ ESM 中不能直接 require JSON
const data = require('./data.json');
// ReferenceError: require is not defined in ES module scope
// ✅ 方案 1:Import Attributes(Node.js 20.10+,推荐)
import data from './data.json' with { type: 'json' };
// ✅ 方案 2:使用 createRequire 桥接(兼容性最好)
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const data = require('./data.json');
// ✅ 方案 3:使用 fs 读取(适合动态路径场景)
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const data = JSON.parse(readFileSync(join(__dirname, 'data.json'), 'utf-8'));
💡 提示: 方案 1(Import Attributes)是最简洁的方案,但需要确保你的 Node.js 版本支持。方案 2(createRequire)兼容性最好,适合需要支持多个 Node.js 版本的库开发。
3.3 TypeScript 模块配置
TypeScript 的模块配置是另一个常见困惑点。以下是不同场景的推荐配置:
// tsconfig.json - Node.js 项目(推荐配置)
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "es2022",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
| 场景 | module | moduleResolution | 说明 |
|---|---|---|---|
| Node.js 应用(推荐) | nodenext |
nodenext |
完整支持 ESM 和 CJS |
| 前端项目(Vite/Webpack) | esnext |
bundler |
打包器处理模块解析 |
| Node.js 旧项目兼容 | commonjs |
node |
传统 CJS 模式 |
| 库开发 | nodenext |
nodenext |
兼容 CJS 和 ESM 用户 |
常见的配置错误:
// ❌ 错误:module 和 moduleResolution 不匹配
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "node"
// node resolution 不支持 package.json exports 字段!
}
}
// ✅ 正确:使用一致的配置
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext"
}
}
3.4 最佳实践总结
项目级建议:
- ✅ 新项目直接使用 ESM(
"type": "module") - ✅ 旧项目利用 require(esm) 渐进迁移,不要一次性重写
- ✅ TypeScript 项目使用
module: "nodenext"统一配置 - ❌ 避免在同一项目中混用
.cjs和.mjs扩展名(除非必要)
库开发建议:
- ✅ 使用
exports字段定义入口,放弃main+module的老方式 - ✅
types条件放在 exports 的最前面 - ✅ 使用 tsup / unbuild 等工具自动生成 CJS + ESM 双格式
- ❌ 避免发布 “Dual Package Hazard”(CJS 和 ESM 同时加载同一模块导致的实例不一致问题)
迁移策略:
| 场景 | 推荐方案 |
|---|---|
| 全新 Node.js 项目 | ESM("type": "module") |
| 存量 CJS 项目迁移 | require(esm) 渐进式迁移 |
| npm 包开发 | Dual Package(CJS + ESM) |
| 前端项目 | ESM(打包器负责模块解析) |
| 纯 CJS 项目(无迁移计划) | 保持 CJS,使用 require(esm) 桥接新依赖 |
⚠️ 警告: 不要在 ESM 模块中使用
require()加载模块——这是 CJS 的语法,在 ESM 中会报错。如果你确实需要在 ESM 中加载一个 CJS-only 的模块,直接使用import语法,Node.js 会自动处理兼容性。
🎯 总结
Node.js 模块系统的演进可以概括为三个阶段:
- CJS 一统(2009-2015): Node.js 使用 CommonJS,浏览器端无法原生使用
- ESM 渐进普及(2015-2024): ES2015 引入 ESM 标准,但 Node.js 生态迁移缓慢,CJS 与 ESM 长期并存
- require(esm) 统一(2024-至今): require(esm) 提供了无缝桥接,开发者不再需要做非此即彼的选择
对于 2026 年的开发者,我的建议是:新项目直接 ESM,存量项目渐进迁移,库开发者发布双格式。require(esm) 的存在让迁移不再是痛苦的全量重写,而是一个可以逐步推进的过程。
相关工具推荐:tsup(打包)、arethetypeswrong(检查包的类型导出)、publint(检查 package.json 配置)。