TypeScript 模块解析深度指南:ESM、CJS 与 Bundler 模式的完全攻略

深入解析 TypeScript 模块解析机制,覆盖 ESM 与 CJS 的核心差异、moduleResolution 四种模式对比、Cannot find module 终极排查方案,以及 CJS 到 ESM 的渐进式迁移策略。

前端开发 2026-06-04 15 分钟

在 Stack Overflow 2025 年度开发者调查中,TypeScript 的模块解析错误(Cannot find module)连续第三年位列"最令人沮丧的错误"前三名。超过 67% 的 TypeScript 开发者表示曾在 ESM 与 CJS 的模块系统切换中浪费超过 8 小时排查问题。如果你也曾被 "type": "module""moduleResolution": "bundler" 的组合搞得焦头烂额,这篇文章将彻底终结你的困惑。

🔧 一、JavaScript 模块系统的演进与核心差异

1.1 从 IIFE 到 ESM:二十年的模块化之路

JavaScript 的模块化历程堪称一部"补丁史"。2009 年 CommonJS 随 Node.js 诞生,用 require()module.exports 解决了服务端的模块化问题。同年 AMD(Asynchronous Module Definition)用 define() 解决了浏览器端的异步加载。2015 年,ES2015 正式引入 ESM(ECMAScript Modules),用 import / export 语法统一了前端和后端的模块标准。

但现实是残酷的。截至 2026 年,npm 上仍有约 38% 的包只提供 CommonJS 格式,而 Node.js 生态中 ESM 的采用率刚过 50%。这意味着你无法逃避 CJS 与 ESM 共存的现实。

1.2 CJS 与 ESM 的五大核心差异

理解 CJS 和 ESM 的差异是掌握模块解析的前提。很多开发者只知道语法不同,却忽略了底层机制的本质区别:

特性 CommonJS (CJS) ESM
加载方式 运行时同步加载 编译时静态分析 + 异步加载
语法 require() / module.exports import / export
值的绑定 值的拷贝(copy) 值的引用(live binding)
循环依赖 返回部分完成的对象 通过 TDZ 处理,可能得到 undefined
顶层 this module.exports(即当前模块对象) undefined
动态导入 原生支持(require() 可在任意位置调用) 需使用 import() 表达式

⚠️ **警告:**ESM 的 import 语句必须在文件顶层使用(静态声明),不能在 iftry 等块级作用域中调用。如果需要条件导入,必须使用 import() 动态表达式。这是一个极其常见的错误来源。

一个容易被忽视的关键差异是值的绑定方式。CJS 导出的是值的拷贝,而 ESM 导出的是值的活引用(live binding)。看下面的对比:

CJS 的值拷贝行为(容易踩坑)

// counter.cjs
let count = 0;
function increment() { count++; }
module.exports = { count, increment };

// main.cjs
const { count, increment } = require('./counter.cjs');
increment();
console.log(count); // 0 — count 是值的拷贝,不会随 increment() 改变!

ESM 的活引用行为(符合直觉)

// counter.mjs
export let count = 0;
export function increment() { count++; }

// main.mjs
import { count, increment } from './counter.mjs';
increment();
console.log(count); // 1 — count 是活引用,始终指向最新值

💡 **提示:**这个差异在实现响应式状态管理、计数器、缓存等场景时至关重要。如果你从 CJS 迁移到 ESM,务必检查所有依赖"导出值不会变"假设的代码。

1.3 文件扩展名与 MIME Type 的隐形陷阱

在 Node.js 中,.js 文件到底被当作 CJS 还是 ESM 处理,取决于两个因素:文件扩展名最近的 package.json 中的 "type" 字段

  • 没有 "type" 字段(或 "type": "commonjs"):.js = CJS,.mjs = ESM
  • "type": "module".js = ESM,.cjs = CJS
// package.json — 启用 ESM
{
  "type": "module",
  "engines": { "node": ">=18" }
}

📌 **记住:**如果你在 "type": "module" 的项目中写了一个 .js 文件并使用 require(),Node.js 会直接报错:ReferenceError: require is not defined in ES module scope。这是最常见的 ESM 迁移错误之一。

🎯 二、TypeScript 模块解析的四种策略

2.1 modulemoduleResolution 的关系

TypeScript 的模块解析由 tsconfig.json 中的两个关键字段控制:

  • module:决定 TypeScript 编译输出的模块格式(CJS / ESM / AMD / UMD 等)
  • moduleResolution:决定 TypeScript 如何查找模块(即 import './foo' 到底对应哪个文件)

这两个字段必须搭配使用,错误的组合会导致编译通过但运行时报错,或者编译时就报 Cannot find module

以下是 TypeScript 5.x+ 支持的四种 moduleResolution 策略及其适用场景:

moduleResolution 适用场景 特点
"classic" 历史项目、AMD/UMD 已废弃,不要使用
"node" CJS 项目(Node.js 10-15) 模拟 Node.js CJS 解析逻辑
"node16" / "nodenext" 现代 Node.js 项目(16+) 支持 ESM + CJS 双模式,严格遵循 Node.js 解析规则
"bundler" 使用打包工具的项目(Vite/Webpack/Rspack) 更宽松的解析规则,不要求文件扩展名

⚠️ 警告:"moduleResolution": "node" 虽然名字里有 “node”,但它模拟的是 Node.js CJS 的解析逻辑,不支持 ESM 的 exports 字段映射。如果你使用 "node" 但代码中有 ESM 的 import,可能会遇到解析错误。

2.2 Bundler 模式 vs Node16/NodeNext 模式

这是 2024-2026 年 TypeScript 开发者最常遇到的选择题。两种模式的核心差异在于对文件扩展名的要求exports 字段的支持

Bundler 模式(适合 Vite / Webpack / Rspack 用户):

// tsconfig.json — Bundler 模式
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler",
    "target": "ES2022",
    "jsx": "react-jsx",
    "esModuleInterop": true,
    "allowImportingTsExtensions": true
  }
}

Bundler 模式的优势是宽松——你不需要在 import 路径中写文件扩展名,可以导入 .ts 文件(通过 allowImportingTsExtensions),不需要 exports 映射。因为打包工具(Vite、Webpack 等)会自行处理模块解析。

Node16/NodeNext 模式(适合纯 Node.js 项目):

// tsconfig.json — Node16 模式
{
  "compilerOptions": {
    "module": "Node16",
    "moduleResolution": "Node16",
    "target": "ES2022",
    "esModuleInterop": true,
    "declaration": true,
    "outDir": "./dist"
  }
}

Node16 模式严格遵循 Node.js 的 ESM 解析规则:import 路径必须包含文件扩展名(.js,不是 .ts),必须正确配置 package.jsonexports 字段。

// ❌ Node16 模式下会报错 —— 缺少扩展名
import { formatDate } from './utils'

// ✅ Node16 模式下正确 —— 使用 .js 扩展名(即使源文件是 .ts)
import { formatDate } from './utils.js'

💡 **提示:**在 Node16 模式下,import 路径写 .js 而不是 .ts,因为 TypeScript 编译后 .ts 会变成 .js。TypeScript 编译器会自动将 .js 映射到对应的 .ts 源文件进行类型检查。

⚡ **关键结论:**如果你的项目使用 Vite/Webpack 等打包工具,选择 "bundler" 模式;如果你在写纯 Node.js 库或 CLI 工具,选择 "node16" 模式。不要在打包项目中使用 "node16",否则你需要在所有 import 路径中加 .js 扩展名。

2.3 Cannot find module 终极排查方案

Cannot find module 是 TypeScript 开发者最常遇到的错误之一。以下是系统化的排查流程:

第一步:检查 moduleResolution 与依赖的匹配度

# 检查目标包是否支持当前 moduleResolution 模式
# 方法:查看依赖的 package.json 中是否有 exports 字段
cat node_modules/some-package/package.json | grep -A 20 '"exports"'

如果依赖包没有 exports 字段,而你使用了 "node16""nodenext",可能会出现解析失败。解决方案是在 tsconfig.json 中添加 paths 映射:

// tsconfig.json — 为缺少 exports 的包添加路径映射
{
  "compilerOptions": {
    "paths": {
      "some-package": ["./node_modules/some-package/dist/index.d.ts"]
    }
  }
}

第二步:检查类型声明文件是否存在

# 检查包是否自带类型声明
ls node_modules/some-package/dist/*.d.ts 2>/dev/null

# 检查是否有 @types 包
ls node_modules/@types/some-package 2>/dev/null

如果既没有内置类型也没有 @types 包,创建一个声明文件:

// src/types/some-package.d.ts
declare module 'some-package' {
  export function doSomething(input: string): number
  export default class SomeClass {
    constructor(options: Record<string, unknown>)
    run(): Promise<void>
  }
}

第三步:检查 includeexclude 配置

// tsconfig.json — 确保目标文件在 include 范围内
{
  "include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*.d.ts"],
  "exclude": ["node_modules", "dist"]
}

📌 记住:paths 映射只影响 TypeScript 编译器的类型检查,不影响运行时的模块解析。如果你使用 paths 做了映射,运行时还需要配合打包工具的 alias 或 Node.js 的 --import 选项。

🚀 三、CJS 到 ESM 的渐进式迁移实战

3.1 评估迁移成本与制定策略

在开始迁移之前,你需要评估项目的迁移难度。以下是三个关键评估维度:

依赖兼容性检查——这是最重要的评估维度。使用以下命令检查你的依赖是否支持 ESM:

# 检查所有直接依赖是否支持 ESM
# 方法 1:查看 package.json 的 exports 或 type 字段
for pkg in $(cat package.json | grep -oP '"(?!=name|version|dev|peer)[^"]+":' | tr -d '":{'); do
  if [ -f "node_modules/$pkg/package.json" ]; then
    type=$(grep '"type"' "node_modules/$pkg/package.json" | head -1)
    exports=$(grep '"exports"' "node_modules/$pkg/package.json" | head -1)
    echo "$pkg: type=$type | exports=$exports"
  fi
done

迁移策略选择——根据项目规模,选择合适的迁移策略:

策略 适用场景 优点 缺点
一步到位 新项目、小型项目 彻底干净 风险高,可能破坏依赖
渐进式迁移(文件级) 中型项目 风险可控 需要维护两套模块系统
双包发布(Dual Package) 开源库 同时支持 CJS 和 ESM 消费者 构建复杂度高
Wrapper 模式 大型项目 对现有代码零侵入 有额外的维护成本

3.2 双包发布(Dual Package)方案

如果你是库作者,最推荐的方式是同时发布 CJS 和 ESM 格式。这样无论消费者使用哪种模块系统,都能正常工作。

// package.json — 双包发布配置
{
  "name": "my-library",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    }
  },
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "files": ["dist"]
}

使用 tsup 构建双包:

// tsup.config.ts — 构建 ESM + CJS 双格式
import { defineConfig } from 'tsup'

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],
  dts: true,           // 同时生成 .d.ts 和 .d.cts
  clean: true,         // 清理旧的 dist 目录
  splitting: true,     // 代码分割(ESM 专用)
  sourcemap: true,     // 生成 source map
  treeshake: true,     // 摇树优化
  outExtension({ format }) {
    return {
      js: format === 'esm' ? '.js' : '.cjs'
    }
  }
})

⚠️ **警告:**双包发布有一个著名的"Dual Package Hazard"问题——如果同一个进程同时通过 importrequire 加载了同一个包,会产生两个独立的实例,导致 instanceof 检查失败、单例模式失效等问题。解决方案是使用 package.jsonexports 字段(而不是 main + module),确保 Node.js 只解析到一个版本。

3.3 渐进式迁移 Checklist

对于中大型项目的渐进式迁移,以下是经过实战验证的步骤清单:

阶段一:准备工作(1-2 天)

  • ✅ 升级 Node.js 到 18+(原生支持 ESM)
  • ✅ 升级 TypeScript 到 5.x+(支持 moduleResolution: "node16"
  • ✅ 运行 npx are-the-types-wrong --pack 检查依赖的类型兼容性
  • ✅ 创建一个 ESM 分支,设置 "type": "module""module": "Node16"

阶段二:逐文件迁移(核心工作)

  • ✅ 将 require() 改为 import(使用 import { x } from './y.js'
  • ✅ 将 module.exports 改为 export
  • ✅ 将 __dirname / __filename 替换为 ESM 等效写法
  • ✅ 将 JSON require 替换为 fs.readFileSync + JSON.parseimport with assertion
  • ✅ 将动态 require() 替换为 import()

ESM 中替换 __dirname__filename 的标准写法:

// ❌ CJS 写法(在 ESM 中不可用)
const __dirname = __dirname
const __filename = __filename

// ✅ ESM 标准写法
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'

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

// 使用示例
const configPath = join(__dirname, '../config/default.json')

阶段三:验证与回归测试

  • ✅ 运行完整测试套件,确保所有测试通过
  • ✅ 测试动态导入 import() 是否正常工作
  • ✅ 测试第三方依赖的导入是否正常
  • ✅ 测试构建产物的格式是否正确(.js = ESM,.cjs = CJS)
  • ✅ 使用 npx publint 检查包的发布兼容性

💡 **提示:**迁移过程中最常遇到的问题是第三方库不支持 ESM。对于这类依赖,可以通过创建一个 CJS wrapper 文件来桥接:

// lib/third-party-wrapper.cjs — 桥接不支持 ESM 的第三方库
const legacyLib = require('legacy-cjs-package')
module.exports = legacyLib

// src/app.ts — 在 ESM 代码中通过 wrapper 导入
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const legacyLib = require('legacy-cjs-package')

💡 四、最佳实践与未来趋势

4.1 三种场景的推荐配置

根据项目类型,以下是经过社区验证的最佳配置方案:

场景一:Vite + React/Vue 前端项目

{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler",
    "target": "ES2022",
    "jsx": "react-jsx",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

场景二:Node.js 后端项目(CLI 工具 / API 服务)

{
  "compilerOptions": {
    "module": "Node16",
    "moduleResolution": "Node16",
    "target": "ES2022",
    "strict": true,
    "esModuleInterop": true,
    "declaration": true,
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

场景三:开源库(需要支持多种消费者)

{
  "compilerOptions": {
    "module": "Node16",
    "moduleResolution": "Node16",
    "target": "ES2020",
    "strict": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "./dist"
  }
}

4.2 2026 年的模块系统趋势

⚡ **关键结论:**ESM 已经是不可逆转的趋势。Node.js 22+ 的 --experimental-require-module 允许在 CJS 中 require() ESM 模块,极大地降低了迁移门槛。TypeScript Go (tsgo) 的原生编译器也默认采用 ESM 优先策略。

以下是几个值得关注的趋势:

  • Node.js require(esm) 稳定化——Node.js 22 已经实验性支持在 CJS 中 require ESM 模块,预计 2026 年下半年稳定。这将大幅降低 CJS 到 ESM 的迁移成本。
  • Bun 和 Deno 默认 ESM——Bun 和 Deno 2 都默认使用 ESM,CJS 支持只是兼容层。新项目应优先选择 ESM。
  • TypeScript tsgo 编译器——TypeScript 的 Go 重写版本(tsgo)在模块解析上做了重大优化,编译速度提升 10 倍以上,同时对 ESM 的支持更加完善。
  • 不要在新项目中使用 moduleResolution: "node"——这个选项模拟的是旧版 Node.js CJS 的解析逻辑,不支持 exports 字段映射,已不推荐使用。

4.3 常见错误速查表

错误信息 原因 解决方案
Cannot find module 'xxx' moduleResolution 不匹配或缺少类型声明 检查 tsconfig 配置和 @types
require is not defined in ES module scope 在 ESM 文件中使用了 require() 改用 importcreateRequire()
ERR_MODULE_NOT_FOUND Node.js ESM 解析失败,缺少文件扩展名 在 import 路径中添加 .js 扩展名
ERR_REQUIRE_ESM 尝试用 require() 加载 ESM-only 包 改用 import() 动态导入或降级依赖版本
SyntaxError: Cannot use import statement CJS 环境中加载了 ESM 代码 检查 package.jsontype 字段

📝 总结

TypeScript 的模块解析看似复杂,但核心逻辑只有两条:

  1. module 决定输出格式(编译后的代码是 CJS 还是 ESM)
  2. moduleResolution 决定查找策略(TypeScript 如何找到你 import 的模块)

对于新项目,直接选择 ESM + "moduleResolution": "bundler"(前端项目)或 "Node16"(Node.js 项目),避免使用已废弃的 "classic" 和即将被淘汰的 "node" 模式。

对于存量项目,采用渐进式迁移策略——先在 ESM 分支中验证依赖兼容性,逐文件替换 require / module.exports,最后通过双包发布(Dual Package)方案确保向后兼容。

📚 相关文章