模块解析与 ESM/CJS 互操作:exports 字段、bundler 模式与双包问题

深入 TypeScript 与 Node.js 的模块解析机制:moduleResolution 的四种模式、package.json 的 exports 条件导出、ESM 与 CJS 互操作规则、双包危害(dual package hazard)、打包器模式差异,以及同时发布 ESM 与 CJS 的工程实践与常见报错排查。

引言

「Cannot find module」「ERR_REQUIRE_ESM」「require() of ES Module not supported」——这些报错几乎每个 TS/Node 工程师都遇到过。它们的根源是同一件事:JavaScript 有两套模块系统(ESM 与 CJS),而 TypeScript 又有四种模块解析模式,两者的笛卡尔积造成了大量的配置陷阱。

本文从模块解析的历史包袱讲起,覆盖 moduleResolution 的四种模式、exports 条件导出、ESM/CJS 互操作规则、双包危害,最后给出同时发布两种格式的工程实践与报错排查清单。

前置:/typescript-project-architecture-tsconfig/(tsconfig 架构)、/typescript-sdk-package-publishing/(包发布)、/typescript-monorepo-turborepo/(Monorepo)。


目录


1. 模块解析的历史包袱

1.1 两套模块系统

CommonJS 用 require() / module.exports 同步加载,2009 年随 Node 诞生;ES Modules 用 import / export,静态分析加异步加载,2015 年进标准。Node 长期只支持 CJS,ESM 到 Node 12 才落地、Node 14+ 才稳定,于是出现「同一段代码,不同环境解析方式不同」的分裂。

1.2 三方视角的差异

Node 运行时:看 package.json 的 type 字段与文件扩展名决定格式
TypeScript  :看 tsconfig 的 module / moduleResolution 决定如何找类型
打包器       :有自己的解析算法(vite/rollup/webpack 各有差异)

三者规则不完全一致,这正是「编辑器不报错、构建却失败」的根因。

一句话总结:JS 有 ESM 与 CJS 两套模块系统,Node/TypeScript/打包器三方解析规则各不相同——理解差异是排查一切模块报错的前提。


2. moduleResolution 的四种模式

2.1 四种模式对比

模式适用场景特点
classic遗留,几乎不用旧式相对路径查找
node10传统 CJS 项目模拟 Node CJS 解析
node16 / nodenext原生 ESM/CJS 项目尊重 exports,区分格式
bundlervite/esbuild/webpack类 bundler 解析,无扩展名

2.2 关键配置

// tsconfig.json
{
  "compilerOptions": {
    "module": "nodenext",          // 与 moduleResolution 联动
    "moduleResolution": "nodenext" // 或 bundler
  }
}

2.3 为什么 bundler 模式流行

bundler 模式允许:import "./foo"(省略扩展名、目录 index 解析)
node16 模式强制:import "./foo.js"(ESM 必须带扩展名)

前端项目用打包器产出,bundler 更贴合直觉;而发布给 Node 直接运行的库,必须用 nodenext 才能验证真实解析行为。

一句话总结:应用/前端用 bundler 省心,库/Node 服务用 nodenext 保真——module 与 moduleResolution 要成对设置。


3. exports 字段与条件导出

3.1 现代包入口

// package.json
{
  "name": "my-lib",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    },
    "./utils": "./dist/utils.js"
  }
}

exports 一旦存在,就封闭了包的可导入路径——未列出的子路径一律不可导入,这既提升封装性也强化了安全。

3.2 条件解析顺序

条件(condition)按声明顺序匹配,命中即止:
  types   → 类型(必须放最前,否则被忽略)
  import  → ESM 入口
  require → CJS 入口
  default → 兜底
{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

3.3 常见陷阱

四个高频错误:types 放在 import/require 之后会让 TS 找不到类型;只写 "." 不写 "./package.json" 会让工具读不到 package.json;exports 存在时 main/module 字段被忽略;路径必须以 "./" 开头,否则报 Invalid package target。

一句话总结:exports 用条件映射定义包入口,types 必须放最前——它封闭子路径、替代 main/module,是现代包分发的核心。


4. ESM 与 CJS 互操作规则

4.1 三条硬规则

1. ESM 可以 import CJS:默认导出 = module.exports
2. CJS 不能 require ESM(Node 20+ 支持 require(esm) 的实验特性除外)
3. ESM 无 __dirname / __filename / require → 用 import.meta.url

4.2 ESM 中拿 __dirname

import { fileURLToPath } from "node:url"
import { dirname } from "node:path"

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

4.3 CJS 中动态 import ESM

// CJS 文件里加载 ESM 包,只能异步
async function load() {
  const { render } = await import("some-esm-only-lib")
  return render()
}

4.4 互操作的命名导出问题

ESM import CJS 时,Node 用 cjs-module-lexer 静态分析 CJS 的命名导出
动态构造的导出(module.exports[k] = v)可能无法被识别
→ 只能拿到 default,具名导入会 undefined

一句话总结:ESM 能 import CJS、CJS 难 require ESM;ESM 里没有 __dirname 要用 import.meta.url——CJS 的具名导出依赖静态分析,动态导出会丢失。


5. 双包问题与 dual package hazard

5.1 什么是双包危害

当一个包同时提供 ESM 与 CJS 两份实现,应用里可能同时加载两份,导致:

1. instanceof 失效:两份 class 定义不同
2. 单例失效:模块级状态(缓存、连接池)出现两份
3. 符号/枚举不相等:Symbol() 与枚举比较失败

5.2 危害演示

// 应用同时 require 与 import 了 my-lib
const a = require("my-lib")
const b = await import("my-lib")
a === b.default          // false
new a.Client() instanceof b.Client   // false

5.3 缓解策略

方案一:只发 ESM(现代项目首选,Node 20+ / 打包器都支持)
方案二:CJS 为主 + ESM 薄封装(ESM 层 re-export CJS)
方案三:状态放到单一来源(如 globalThis 上的符号键)
方案四:用 exports 条件保证同环境只命中一份

方案二(CJS 为核心,ESM wrapper)能保证单例,因为两份实际指向同一 CJS 实例。

一句话总结:双包危害源于 ESM/CJS 两份实现被同时加载,导致 instanceof 与单例失效——首选只发 ESM,或让 ESM 层薄封装 CJS。


6. package.json 的 type 与扩展名

6.1 type 字段

{
  "type": "module"   // .js 视为 ESM
  // 或 "type": "commonjs"(默认)→ .js 视为 CJS
}

6.2 扩展名决定一切

.mjs 永远是 ESM、.cjs 永远是 CJS(与 type 无关);.js 由最近的 package.json 的 type 决定;.ts 由 TS 配置与所在包的 type 共同决定。

6.3 配置示例

// ESM 包
{ "type": "module", "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } } }
// 双格式包
{
  "type": "commonjs",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

一句话总结:.mjs/.cjs 扩展名硬绑定格式,.js 由最近的 type 字段决定——改扩展名前先确认所在包目录的 type。


7. 打包器模式:bundler 与解析

7.1 bundler 模式的宽松之处

moduleResolution: "bundler" 允许:
  import "./foo"      省略扩展名
  import "@/utils"    路径别名(配合 paths)
  import "./dir"      目录 index 解析

7.2 与 nodenext 的冲突

// bundler 模式下合法,nodenext 下报错
import { helper } from "./helper"   // 缺 .js 扩展名

发布为库时若用 bundler 写源码,下游用 Node 原生运行就会报「Cannot find module」。因此库代码应始终带扩展名。

7.3 一套配置两种用途

// tsconfig.json(应用:bundler)
{ "compilerOptions": { "module": "esnext", "moduleResolution": "bundler" } }
// tsconfig.lib.json(库:nodenext)
{ "compilerOptions": { "module": "nodenext", "moduleResolution": "nodenext" } }

一句话总结:bundler 模式宽松、nodenext 严格——应用可用 bundler,库必须用 nodenext 验证真实解析,且源码写全扩展名。


8. 发布同时支持 ESM 与 CJS

8.1 用 tsup 一次产出两份

// tsup.config.ts
import { defineConfig } from "tsup"

export default defineConfig({
  entry: ["src/index.ts"],
  format: ["esm", "cjs"],
  dts: true,
  clean: true,
  outExtension: ({ format }) => ({ js: format === "esm" ? ".mjs" : ".cjs" }),
})

8.2 对应 package.json

{
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  },
  "files": ["dist"]
}

8.3 类型也要双份

dts: true 只产一份 .d.ts。若两份实现签名不同(如 CJS 用 export =),需分别产出 index.d.mts / index.d.cts,并在 exports 中分条件指向:

{
  "exports": {
    ".": {
      "import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" },
      "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
    }
  }
}

8.4 验证

# 用 arethetypeswrong 检查导出与类型的匹配
npx @arethetypeswrong/cli --pack .

一句话总结:双格式发布 = tsup 产出 .mjs/.cjs + exports 分条件指向 + 类型也分 .d.mts/.d.cts,最后用 arethetypeswrong 校验。


9. 常见报错与排查

9.1 报错对照表

报错原因修复
ERR_REQUIRE_ESMCJS 里 require 了 ESM 包改用动态 import 或换 CJS 版本
Cannot find module扩展名/路径解析失败补 .js 或调 moduleResolution
类型找不到(红波浪线)exports 缺 types 条件在条件最前加 types
ERR_MODULE_NOT_FOUNDESM 缺扩展名import 路径写全 .js
双份单例双包危害只发 ESM 或 ESM 薄封装 CJS

9.2 排查步骤

排查顺序:先看报错文件是 ESM 还是 CJS(扩展名加最近 type),再查目标包 exports 是否提供对应条件,然后用 node --input-type=module -e "import('pkg')" 复现,用 npx @arethetypeswrong/cli --pack . 查包配置,最后确认 tsconfig 的 module/moduleResolution 成对。

一句话总结:报错排查的顺序是「先定位报错文件格式,再查目标包 exports,再复现与校验」——多数问题出在条件缺失或扩展名省略。


10. 迁移到纯 ESM 的路径

10.1 迁移前评估

阻塞项:依赖里是否有 ESM-only 且只能 require 的包
阻塞项:是否有 __dirname / require.resolve 等 CJS 专属 API
阻塞项:测试框架、构建工具、部署环境是否支持 ESM

10.2 分步迁移

第一步:tsconfig 改 module/moduleResolution 为 nodenext;第二步:源码 import 全部补 .js 扩展名;第三步:__dirname 换成 import.meta.url 推导;第四步:package.json 加 "type": "module",构建产出 .mjs;第五步:CI 用 Node 原生跑一遍,验证无 ERR_* 报错。

10.3 渐进策略

大仓不宜一次性切换:先把「叶子包」迁到 ESM,再逐步向上;CJS 包内部可用动态 import 调用 ESM 依赖,作为过渡。

一句话总结:迁 ESM 的关键是「补扩展名、替换 CJS 专属 API、验证依赖生态」——先迁叶子包,用动态 import 作为 CJS→ESM 的过渡桥。


延伸阅读

  • /typescript-project-architecture-tsconfig/ — tsconfig 与模块配置的组织
  • /typescript-sdk-package-publishing/ — 包发布与 exports 维护
  • /typescript-monorepo-turborepo/ — Monorepo 下的模块解析
  • /typescript-major-version-upgrade/ — 版本升级与破坏性变更
  • /typescript-build-performance-optimization/ — 构建性能与打包配置
  • TypeScript 专题 — TypeScript 专题

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. TS 中的 LLM 应用开发:AI SDK、流式响应、工具调用与类型安全
  2. 边缘运行时与适配器:Vercel Edge、Cloudflare Workers 与 Web API 兼容
  3. Node.js 性能剖析:V8 采样、clinic、火焰图与堆快照