🧩 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 包的标配
相关推荐
szp200513 小时前
为了在浏览器里跑多线程 ONNX 推理,我把自己的支付浮层弄挂了
前端·webassembly
kisshyshy13 小时前
从 useRef 到 Web Worker:理解 React 可变对象与浏览器多线程
前端·javascript·react.js
fatcoder13 小时前
玩转Nginx 04 — 反向代理:给 nginx 接上后端
前端·后端·nginx
Data_Journal14 小时前
什么是 CAPTCHA,它是如何工作的?
java·大数据·服务器·前端·数据库
计算机魔术师14 小时前
我看了这个更新,把原来的检索方案推翻了
前端
zhanghaha131415 小时前
HTML系列教程:3_HTML 基础标签 — 标题、段落、超链接、图像
前端·html
李高钢15 小时前
C# WPF Prism 进阶(二):区域(Region)与模块化(Module)
java·前端·数据库
xyphf_和派孔明15 小时前
Vite 与 Webpack 对比及常见面试题
前端·webpack·vite
明月_清风15 小时前
Pi Agent 深度解析:开源极简终端 AI 编码代理的终极指南
前端·后端·ai编程
fatcoder16 小时前
玩转Nginx 03 — location 匹配规则:让不同的路径各回各家
前端·后端·nginx