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

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

问题场景

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

javascript 复制代码
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 对象引用 具名/默认导出,只读绑定
顶层的 this module.exports undefined
文件识别 .js / .cjs .mjs / 包内 "type": "module"
循环依赖 可处理(返回部分导出) 设计上不可靠

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


解决方案(含实操代码)

坑 1:require() 加载 ESM 包

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

js 复制代码
// ❌ 报错:ERR_REQUIRE_ESM
const clipboard = require('clipboardy');

方案 A:动态 import()

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

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

json 复制代码
// package.json
{
  "type": "module"
}

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

坑 2:import default 是 undefined

js 复制代码
// 某个 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' } }

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

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

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

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

✅ 正确组合(Node 18+):

json 复制代码
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist"
  }
}

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

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

json 复制代码
{
  "exports": {
    ".": "./dist/index.js",
    "./utils": "./dist/utils.js"
  }
}

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

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

json 复制代码
{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

坑 5:__dirname 在 ESM 中不可用

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

// ESM 中 ❌ ReferenceError: __dirname is not defined

✅ ESM 替代方案:

js 复制代码
import { fileURLToPath } from 'url';
import { dirname } from 'path';

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

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

坑 6:JSON 模块导入限制

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

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

js 复制代码
import data from './config.json' with { type: 'json' };

✅ 解法 B:用 fs 读

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

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

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

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

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

然后在 package.json 中配置:

json 复制代码
{
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

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


实战检查清单

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

bash 复制代码
# 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 包的标配
相关推荐
Csvn1 小时前
🎯 Web 性能 API 集合:Performance Observer 的 5 个冷门妙用
前端
Csvn1 小时前
深入 React 闭包陷阱:从根源上理解并根治 stale closure
前端
whyfail2 小时前
前端学 Spring Boot(8):接口为什么越用越慢?
前端·spring boot·后端
用户059540174462 小时前
LangChain 记忆测试踩坑实录:这两个坑让我排查了 4 小时
前端·css
程序员黑豆2 小时前
鸿蒙应用开发:@Monitor 装饰器使用教程
前端·harmonyos
SamChan903 小时前
在Web应用中集成PDF多语言翻译功能:PDFTranslator API实战指南
前端·python·ai·pdf·yapi·机器翻译
kyriewen3 小时前
AI Agent 9秒删光了生产数据库——我给自己的项目做了5个紧急检查
前端·ai编程·claude
IT_陈寒3 小时前
JavaScript的this又双叒叕让我怀疑人生了
前端·人工智能·后端
陈随易4 小时前
MCP协议第5次更新,从打电话到微信聊天的巨大变革
前端·后端·程序员