Node.js 模块系统深度解析:CJS、ESM、require(esm) 全面指南

深入解析 Node.js CommonJS 与 ESM 模块系统的核心差异,详解 require(esm) 工作原理、Package Exports 配置与 TypeScript 模块设置,附完整代码示例与避坑指南。

前端开发 2026-06-04 12 分钟

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 模块系统的演进可以概括为三个阶段:

  1. CJS 一统(2009-2015): Node.js 使用 CommonJS,浏览器端无法原生使用
  2. ESM 渐进普及(2015-2024): ES2015 引入 ESM 标准,但 Node.js 生态迁移缓慢,CJS 与 ESM 长期并存
  3. require(esm) 统一(2024-至今): require(esm) 提供了无缝桥接,开发者不再需要做非此即彼的选择

对于 2026 年的开发者,我的建议是:新项目直接 ESM,存量项目渐进迁移,库开发者发布双格式。require(esm) 的存在让迁移不再是痛苦的全量重写,而是一个可以逐步推进的过程。

相关工具推荐:tsup(打包)、arethetypeswrong(检查包的类型导出)、publint(检查 package.json 配置)。

📚 相关文章