🧩 ESM vs CJS 混用的 7 个「天坑」——从 TypeScript 编译到 Node 与浏览器

9 阅读4分钟

工程化实战|模块系统 | 你以为 "type": "module" 就完事了?生产环境中 ESM 和 CJS 混用导致的「这个模块明明存在却报错」「require is not defined」「default 导出变了个样」——几乎每个项目都会踩一遍。

问题场景

前阵子在修一个老项目升级的 Bug:项目原本是 CJS(CommonJS)体系,逐步迁移到 ESM。一切按照文档配置好 "type": "module",结果跑起来一堆报错:

Error [ERR_REQUIRE_ESM]: require() of ES Module xxx from xxx not supported.

更诡异的是,import pkg from 'some-lib' 明明可以正常运行,换另一台机器 clone 下来就跑不通了。调试了半天,发现是 依赖锁文件中混入了不同版本的模块系统……

这坑,你也大概率踩过。


原因分析

ESM 和 CJS 是两套完全不同的模块系统:

维度CJS(require)ESM(import)
加载时机同步(运行时)异步(静态分析)
导出机制module.exports 对象引用具名/默认导出,只读绑定
顶层的 thismodule.exportsundefined
文件识别.js / .cjs.mjs / 包内 "type": "module"
循环依赖可处理(返回部分导出)设计上不可靠

核心矛盾:CJS 的 require() 是同步的,但 ESM 的 import 是异步的。Node 不允许 require() 加载一个 ESM 模块——这就是 ERR_REQUIRE_ESM 的根源。


解决方案(含实操代码)

坑 1:require() 加载 ESM 包

最常见的场景:你用 CJS 的项目引用了一个只提供 ESM 的 npm 包。

// ❌ 报错:ERR_REQUIRE_ESM
const clipboard = require('clipboardy');

方案 A:动态 import()

// ✅ 只在 CJS 环境下有效
async function main() {
  const clipboard = await import('clipboardy');
  clipboard.writeSync('hello');
}
main();

方案 B:项目整体迁移到 ESM

// package.json
{
  "type": "module"
}

然后所有 .js 文件自动变为 ESM,require 改为 import

坑 2:import default 是 undefined

// 某个 CJS 模块导出
module.exports = { foo: 'bar' };

// ESM 导入
import mod from './cjs-module.js';
console.log(mod); // ??? 可能是 { default: { foo: 'bar' } } 而不是 { foo: 'bar' }

原因:CJS module.exports 被 Node 处理成 ESM 的 default 导出——它变成了 { default: { foo: 'bar' } }

// ✅ 正确解法:使用命名导入代替默认导入
import { foo } from './cjs-module.js';
// 或者用 *
import * as mod from './cjs-module.js';

坑 3:TypeScript 编译后模块格式不匹配

// tsconfig.json
{
  "compilerOptions": {
    "module": "ESNext",       // 编译输出 ESM
    "moduleResolution": "Node" // 但用 Node 解析策略
  }
}

后果:TypeScript 编译后产出的 .jsimport/export,但 moduleResolution: "Node" 不会给文件加 .js 后缀,导致运行时找不到模块。

✅ 正确组合(Node 18+):

{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist"
  }
}

此时 TSC 会自动补全 .js 后缀。

坑 4:package.json exports 字段错误配置

{
  "exports": {
    ".": "./dist/index.js",
    "./utils": "./dist/utils.js"
  }
}

这段配置会导致:ESM 和 CJS 用同一入口,但内部写法可能冲突。

✅ 正确做法——分条件导出:

{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

坑 5:__dirname 在 ESM 中不可用

// CJS 这样用没问题
const path = require('path');
console.log(__dirname); // 当前目录

// ESM 中 ❌ ReferenceError: __dirname is not defined

✅ ESM 替代方案:

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

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

建议抽取成工具函数,省得每次写。

坑 6:JSON 模块导入限制

// ESM 默认不允许直接 import JSON
import data from './config.json';
// ❌ 报错:Unknown file extension ".json"

✅ 解法 A:使用 assert/with(Node 17.5+ / 18+)

import data from './config.json' with { type: 'json' };

✅ 解法 B:用 fs 读

import { readFileSync } from 'fs';
const data = JSON.parse(readFileSync('./config.json', 'utf-8'));

坑 7:.mjs 和 .cjs 后缀的「隐形契约」

很多人不知道:.mjs = 强制 ESM,.cjs = 强制 CJS,不受 package.json"type" 字段影响

这是解决混用问题的最简单手段:

# 把 ESM 入口写成 .mjs,CJS 入口写成 .cjs
src/
  index.mjs      # ESM
  index.cjs      # CJS(拷贝或编译)
  index.d.ts     # 类型声明

然后在 package.json 中配置:

{
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

这样不管使用者是 CJS 还是 ESM 项目,都能正确加载。


实战检查清单

下次遇到模块系统问题,按这个顺序排查:

# 1. 确认当前文件的模块系统
  - .mjs → ESM | .cjs → CJS | .js → 看 package.json 的 "type"

# 2. 检查被导入包的导出方式
  - 查看 node_modules/pkg/package.json → "exports""type"

# 3. 检查 Node 版本(低于 16 对 ESM 支持很差)
  node -v

# 4. 使用 --experimental-xxx 标志了吗?18+ 基本不需要了

# 5. 确认 tsconfig.json 的 module / moduleResolution 配对正确

要点总结

  1. 最根治的方案:整个项目统一到 ESM("type": "module"),没必要混用
  2. 被迫混用时:利用 .mjs / .cjs 后缀的强制语义,比跑 "type" 配置更可靠
  3. 动态加载 CJS/ESM 桥梁import() 函数可以在 CJS 中加载 ESM 模块
  4. TypeScript 用户务必:用 "module": "NodeNext" + "moduleResolution": "NodeNext",否则编译产物无法运行
  5. 工具的 .d.ts 声明不受模块系统影响——模块系统问题是运行时问题,和类型提示无关
  6. exports 条件导出 为库的用户提供双格式兼容,这是现代 npm 包的标配