Node.js ESM 和 CJS 混用怎么迁移

FreeGuideOnline 23阅读 2026-07-04

javascript // CJS 导出 module.exports = function add(a, b) { return a + b; }; exports.add = (a, b) => a + b;

// CJS 导入 const add = require('./add'); const { add } = require('./math');


```javascript
// ESM 导出
export default function add(a, b) { return a + b; }
export const add = (a, b) => a + b;

// ESM 导入
import add from './add.js';          // 注意扩展名!
import { add } from './math.js';
import * as math from './math.js';

文件扩展名与 package.json

  • CJS 文件通常使用 .js;ESM 推荐使用 .mjs,或者在 package.json 中设置 "type": "module" 可将 .js 视为 ESM。
  • 若目录下有 package.json"type": "module",则该目录中 .js 文件按 ESM 解析;若需写 CJS,必须使用 .cjs 扩展名。
  • 没有 "type" 字段时,默认是 "commonjs"

关键限制速览

操作 CJS 中可执行 ESM 中可执行 备注
require() ESM - 报错:ERR_REQUIRE_ESM
import CJS ✅ (异步包装) - 只能引入 CJS 默认导出(module.exports),命名导出需特殊处理
require() CJS ✅(通过 createRequire ESM 内需要借助 module.createRequire
import() 动态导入 两者均支持动态 ESM 导入,返回 Promise

混用现状诊断:你的项目到底有多痛?

在动手前,先搞清楚项目里 CJS 和 ESM 的分布:

  1. 检查入口文件和配置:查看 package.json"main""exports" 指向的文件模块类型。检查自身源码是 require 还是 import 为主。
  2. 依赖分析:运行 npm ls --depth=0,然后检查第三方包的 package.json"type""module" 字段。很多现代包(如 node-fetch 3.x、chalk 5.x)已纯 ESM。
  3. 测试互操作限制:写一个临时脚本,尝试用 require 引入你怀疑的 ESM 包,或用 import 引入一个深层 CJS 模块,看是否报错。

典型痛点场景:

  • 你想使用某个纯 ESM 库,但你的项目是 CJS,无法直接 require
  • 你的项目改造为 ESM 后,Jest(默认 CJS)不能直接运行,需要配置。
  • 构建工具(Webpack、Rollup)配置混乱,因为入口文件既有 import 又有 require

迁移方案一:双模块格式兼容(渐进式过渡)

适用场景:项目团队逐步迁移,新旧代码需要共存较长时间,或需要同时提供 CJS 和 ESM 出口给下游使用。

核心技巧:利用动态导入和 createRequire 架起桥梁

1. 在 CJS 中加载 ESM 模块

因为 CJS 无法使用静态 import,只能通过动态 import() 异步加载 ESM。

// cjs-module.js
(async () => {
  const { default: chalk } = await import('chalk'); // chalk 5.x 是纯 ESM
  console.log(chalk.green('Hello from CJS using ESM!'));
})();

如果需要在顶层同步获取,可以将逻辑包裹在异步函数中,但无法完全同步。对于顶层导出依赖 ESM 的情况,这会改变模块的导出方式(变为异步),需要调整上层调用。

2. 在 ESM 中加载 CJS 模块

ESM 里有两种方法:

  • 默认导入import pkg from 'cjs-package' 会获取 module.exports 整个对象。如果想模拟 CJS 的命名导出,需手动解构。
  • 命名导入(Node.js 22+ 或通过静态分析):Node.js 14 起支持从 CJS 中导入“一些命名导出”,但需注意这些是动态计算的,且容易出错。更稳妥的仍然是默认导入或使用 createRequire

使用 createRequire 在 ESM 中制造一个 require 函数:

// esm-file.mjs
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const { add } = require('./cjs-math.cjs');
console.log(add(2, 3));

这种方法完全保留 CJS 的行为,同步且可靠,非常适合在 ESM 文件中大范围使用仍为 CJS 的工具库。

3. 配置 package.jsonexports 字段实现双格式入口

如果你开发的包要同时支持 CJS 和 ESM 消费者,可以这样配置:

{
  "name": "my-package",
  "exports": {
    ".": {
      "require": "./dist/index.cjs",
      "import": "./dist/index.mjs"
    }
  }
}

注意需提供真实的不同格式文件,或使用条件导出指向同一文件但通过构建工具区分。常见做法是用打包器(如 Rollup、esbuild)从同一源码生成 index.cjsindex.mjs


迁移方案二:彻底单向迁移到 ESM(推荐长期策略)

如果你有条件做一次彻底升级,将项目全部转为 ESM 是更洁净的方式。以下是标准步骤。

第一步:全局启用 "type": "module"

在根 package.json 中添加:

{
  "type": "module"
}

此后,所有 .js 文件默认解析为 ESM。对于必须保留的 CJS 文件,重命名为 .cjs 或将它们放在拥有自己 package.json"type": "commonjs" 的子目录中。

第二步:批处理替换语法

编写脚本或使用 codemod 工具自动转换大部分代码:

  • const X = require('X')import X from 'X'
  • const { a, b } = require('X')import { a, b } from 'X'(需确认 CJS 包支持命名导出,否则用 import X from 'X'; const { a, b } = X;
  • module.exports =export default
  • exports.foo =export const foo =
  • __filenameimport.meta.url 配合 fileURLToPath
  • __dirnamepath.dirname(fileURLToPath(import.meta.url))

手动处理动态 require 场景(使用条件、循环加载等),改为 import() 动态导入并处理 Promise。

第三步:处理全局变量差异

CJS 提供 __dirname__filenamerequiremoduleexports 等全局量,ESM 中没有。常用替换:

import { fileURLToPath } from 'url';
import path from 'path';

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

如果有深层依赖仍需要 require,可以使用前面提到的 createRequire

第四步:调整测试框架

  • Jest:需要配置 transform 或直接使用 jest.config.js 为 ESM(jest.config.mjs),并开启 --experimental-vm-modules。推荐使用 Vitest 这种原生 ESM 友好的测试框架,迁移成本极低。
  • Mocha:使用 .mocharc.cjs 或配置 "node-option" 包含 --experimental-require-module 等。或直接用 mocha 的 ESM 实验支持。

第五步:脚本和工具链适配

  • package.json 中的 "scripts" 可以保持不变,Node.js 会根据 "type" 解析 .js
  • 构建工具(Webpack、Rollup、tsup 等)需要确认入口文件解析正确:最好显式指定入口扩展名为 .mjs 或使用 "type":"module"
  • ESLint:更新配置,添加 parserOptions: { sourceType: "module" },环境设置 es2022: true

第六步:逐文件验证

使用 node --check 对每个 ESM 文件进行语法检查。然后通过单元测试覆盖全部的导入导出路径。特别关注之前使用了动态 require 和循环依赖的部分。


常见陷阱与解决方案

1. 无扩展名导入报错

现象Cannot find module '/path/to/file' 原因:ESM 要求模块标识符必须完全指定,包括 .js.mjs 等扩展名(导入目录下的 index.js 也需要完整路径)。 解决

// 错误
import foo from './foo';
// 正确
import foo from './foo.js';

2. CJS 命名导入不稳定

现象import { writeFile } from 'fs' 在 ESM 中可以使用,但 import { someUtil } from 'a-cjs-pkg' 可能为 undefined原因:Node.js 只会静态分析 CJS 包的 exports 对象中可静态检测到的属性。复杂的动态导出无法被检测。 解决:回退到默认导入再解构,或者要求包的作者提供 ESM 封装。

3. 循环依赖导致的死锁

ESM 的静态结构能更好地处理循环依赖问题,但混用期间如果 CJS 和 ESM 互相循环引用,可能导致 ReferenceErrorundefined解决:重构代码打破循环,或确保循环引用仅发生在同一个模块系统内部。迁移时先从叶子模块开始,逐步向上。

4. __dirname 缺失导致路径错误

使用 fileURLToPath + import.meta.url 替换后,务必保证路径运算的一致性。考虑统一使用 URL 对象来拼接路径:

const dir = new URL('.', import.meta.url);
const file = new URL('./data.json', import.meta.url);