🧩 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 编译后产出的 .js 是 import/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 包的标配
相关推荐
寂寞狼牙冷10 小时前
封装fetch替换axios
前端
用户4611696572811 小时前
网站 HTTPS 不稳定?证书排查指南
前端
n876511 小时前
Playwright Java WEB UI 自动化实现框架与逻辑
java·前端·ui
keyipatience11 小时前
TypeScript学习(1)
前端·javascript·学习·typescript·html·css3
xifangge202511 小时前
基于 JavaScript 实现 CNC 数控切削参数与螺纹公差纯前端核算引擎
开发语言·前端·javascript
wangruofeng12 小时前
2026 Web 技术选型指南,AI 时代如何选择适合项目的技术栈
前端·aigc·ai编程
honkun612 小时前
vue 表格组件 vxe-table 修改滚动条样式,修改滚动条样式,统一宽度、颜色与自动隐藏
前端·javascript·vue.js
爱勇宝12 小时前
研究数字孪生的这半年,做了个 Demo,也有了一些新想法
前端·程序员
不可能片场12 小时前
工程实践 一个缓存把选片从十分钟压到一分钟
前端·electron
前端小万12 小时前
探索副业五个月,最终以放弃告终
前端·后端·面试