TypeScript 模块系统 — ESM/CJS 互作的坑,一次讲透
今天解决一个"新手闻风丧胆、老手也常翻车"的领域:模块互操作。
Cannot use import statement outside a module、ERR_UNKNOWN_FILE_EXTENSION、xxx only refers to a type but is being used as a value——这些报错十有八九源于一件事:TypeScript 里有两套模块系统在同时运转——值的模块(ESM/CJS 的运行时机制)和类型的模块(编译期就被擦除的 import type)。今天把两套系统的规则、翻译官(esModuleInterop)和隔离器(isolatedModules)讲透,从此模块报错 30 秒定位。
目录
- 一、先建立全景:两套模块系统
- 二、值模块:ESM 与 CJS 的根本差异
- 三、esModuleInterop:default 的翻译官
- 四、类型模块:import type 与 isolatedModules
- 五、package.json 的 exports 字段:现代包的门面
- 六、ts-node / Node ESM 的实战配置
- 七、工业场景:CJS 项目渐进迁移 ESM
- 八、类比记忆:海关与签证
- 九、常见坑点与最佳实践
- 十、自测挑战
- 十一、总结与知识图谱
一、先建立全景:两套模块系统
// 你以为你在用一套模块系统,其实是两套叠在一起:
import { Device } from "./device.js"; // ← ① 类型的导入:编译期检查后【整体擦除】
const d: Device = getDevice();
import { getDevice } from "./api.js"; // ← ② 值的导入:编译后【真实存在】于产物
// 编译后的产物里:
// "import { Device } from './device.js'" —— 消失了(类型擦除)
// "import { getDevice } from './api.js'" —— 保留(或转成 require)
一切的坑都源于一个问题:编译器擦除 import 时,怎么知道哪些该擦、哪些该留?——答案是它猜。而今天讲的配置,本质都是在控制"怎么猜"和"猜错了怎么办"。
二、值模块:ESM 与 CJS 的根本差异
2.1 两种模块的语法对照
// ===== ESM(ES2015+,浏览器与现代 Node 的标准) =====
import fs from "node:fs"; // default 导入
import { readFile } from "node:fs/promises"; // 具名导入
import * as path from "node:path"; // 命名空间导入
export const VERSION = "1.0"; // 具名导出
export default class Device {} // default 导出
// ===== CJS(Node 传统格式) =====
const fs = require("fs"); // require 拿到整个 module.exports
const { readFile } = require("fs/promises");
module.exports = { VERSION: "1.0" }; // 导出挂在 module.exports 上
2.2 语义的根本差异(互操作坑的根源)
| 维度 | ESM | CJS |
|---|---|---|
| 加载时机 | 编译期确定依赖(静态、可分析) | 运行时才 require(动态) |
| 导出绑定 | 活绑定(导出的是引用,值变了跟着变) | 值的快照拷贝 |
| 能否条件加载 | 不能(import 必须顶层静态) | 能(if 里 require) |
| this(模块顶层) | undefined | module.exports |
| 循环依赖 | 有明确定义(拿到未初始化的绑定会报错) | 拿到部分填充的 exports |
// "活绑定"演示(ESM 特性,CJS 没有的语义):
// counter.js
export let count = 0;
export function inc() { count++; }
// main.js
import { count, inc } from "./counter.js";
console.log(count); // 0
inc();
console.log(count); // 1 ← count 是活引用,跟着变了!
// CJS 里 import 到的是 require 时刻的快照,永远是 0
2.3 为什么这些差异对你重要
- Tree-shaking 依赖静态分析 → 只有 ESM 能被有效摇树
- 循环依赖的行为差异 → 重构老代码时 CJS 循环"碰巧能跑",迁 ESM 就炸
- 动态加载 → CJS 的 require(cond ? a : b) 必须改写成 ESM 的动态 import()
三、esModuleInterop:default 的翻译官
3.1 问题现场:CJS 包的 default 到底是什么?
// 一个 CJS 模块:
// cjs-lib.js
module.exports = function doSomething() {};
// 你在 TS 里想这样用(很自然的想法):
import doSomething from "cjs-lib";
// ❌ 报错:Module can only be default-imported using the 'esModuleInterop' flag
// 为什么?CJS 里根本没有 "default 导出" 这个概念!
// 只有 module.exports 这个"一整坨"
// "doSomething 应该映射到 default" —— 这只是你的【愿望】,不是规则
3.2 两个翻译开关
{
"compilerOptions": {
"esModuleInterop": true
// 作用:编译时给 CJS 模块合成一个 default 导出
// import doSomething from "cjs-lib"
// → 编译为 require("cjs-lib").default?不 ——
// → 编译为 __importDefault(require("cjs-lib")) 辅助包装
}
}
// ===== esModuleInterop 开启后的三种姿势对比 =====
// CJS 模块:module.exports = { a: 1, b: 2 }
// 姿势 1:default 导入(拿到整个 module.exports)
import lib from "cjs-lib";
lib.a; // ✅ 1
// 姿势 2:具名导入(编译器从类型里挑属性)
import { a } from "cjs-lib";
// // ✅ 1(interop 合成了具名导出的视图)
// 姿势 3:命名空间(等价于直接拿 module.exports)
import * as libNs from "cjs-lib";
libNs.a; // ✅ 1
3.3 allowSyntheticDefaultImports:只骗类型,不改产物
{
"compilerOptions": {
"allowSyntheticDefaultImports": true
// 只在【类型层面】允许 default 导入 CJS(类型检查放行)
// 但编译产物不做任何包装 —— 运行时该炸还是炸
// 适用于:运行时有打包器(webpack/vite)自己做 interop,TS 只需要不报错
}
}
// 记忆:esModuleInterop = 类型放行 + 运行时翻译(完整方案)
// allowSyntheticDefaultImports = 只放行类型(配合打包器的场景)
四、类型模块:import type 与 isolatedModules
4.1 问题现场:类型和值同名混用
// device.ts
export interface Device { id: string } // 类型
export function getDevice(): Device { ... } // 值
// main.ts
import { Device, getDevice } from "./device";
// 编译器擦除 import 时的两难:
// - Device 是类型 → 必须擦掉(产物里不存在)
// - getDevice 是值 → 必须保留
// 单文件编译(vite/esbuild 逐文件翻译)时,编译器看不到 device.ts 的内容
// 它【不知道】Device 是不是类型 —— 猜错就把运行时导入删没了 💥
4.2 import type:显式告诉编译器"这是纯类型导入"
// ✅ 方案 1:整条语句标记为纯类型(编译后整行消失)
import type { Device } from "./device";
import { getDevice } from "./device";
// ✅ 方案 2:单个成员内联标记
import { getDevice, type Device } from "./device";
// 附加好处:
// 1. 单文件编译器(esbuild/swc/babel)不再需要猜测 —— 明确指令
// 2. 防止手滑把类型当值用:
const d = new Device();
// ❌ 编译错误:'Device' cannot be used as a value (it was imported using 'import type')
// —— 没有这行标记,这个错误可能静默漏到运行时
4.3 isolatedModules:强制所有文件"可被单文件编译"
{
"compilerOptions": {
"isolatedModules": true
// 宣言:本项目的每个文件都能被 esbuild/swc 独立翻译
// 为此,某些"跨文件才能成立"的写法被禁止 ↓
}
}
// ===== 被 isolatedModules 禁止的写法 =====
// ❌ 1. 重导出类型不写 type:
export { Device } from "./device"; // 编译器不知道 Device 是类型
export type { Device } from "./device"; // ✅
// ❌ 2. 空的导入声明(老技巧,用于加载副作用模块的旧写法):
import "some-module";
import {} from "some-module"; // ❌(部分配置下)
// ✅ 改用显式副作用导入语法或带成员的导入
// ❌ 3. const enum(跨文件的内联枚举需要看别的文件):
const enum Color { Red } // ❌ isolatedModules 下报错
enum Color { Red } // ✅ 普通 enum(产物保留对象)
// 结论:现代项目(vite/next/esbuild 构建)一律开启 isolatedModules
// 它是"兼容高速编译器"的法律,也是代码习惯的护栏
4.4 verbatimModuleSyntax:TS 5.0 的新国王
{
"compilerOptions": {
"verbatimModuleSyntax": true
// 比 isolatedModules 更彻底:import 的擦除规则完全由语法决定
// - import type ... → 一定擦除
// - import ... → 一定保留(哪怕全是类型!)
// 产物所见即源码 —— 打包器最喜欢的确定性
}
}
// verbatimModuleSyntax 开启后:
import { SomeType } from "./types"; // 保留在产物里(可能是故意的副作用导入)
// 如果 SomeType 纯是类型且你不想保留 → 必须显式写 type:
import type { SomeType } from "./types"; // 一定擦除
// 它取代了 importsNotUsedAsValues / preserveValueImports 两个老开关
// 新项目(TS 5+)推荐直接用它 + isolatedModules 的语义自动包含
五、package.json 的 exports 字段:现代包的门面
5.1 老字段与新字段
// ===== 老写法(node10 时代) =====
{
"name": "typed-utils",
"main": "./dist/index.js", // 唯一入口
"types": "./dist/index.d.ts" // 类型入口
}
// ===== 新写法(exports,Node 12.7+ / 现代打包器) =====
{
"name": "typed-utils",
"type": "module", // .js 文件默认按 ESM 解析
"exports": {
".": { // 主入口
"types": "./dist/index.d.ts", // ⚠ types 必须排在第一个条件!
"import": "./dist/index.mjs", // ESM 入口
"require": "./dist/index.cjs" // CJS 入口
}
},
"files": ["dist"] // npm 包只含 dist(Day 28 详讲)
}
5.2 exports 的三条铁律
1. types 条件必须是第一个 —— TS 按顺序匹配条件,错过就找不到类型
2. 一旦声明 exports,【未列出】的路径全部 404:
import "typed-utils/dist/helpers" → ❌ 无法导入(子路径要显式导出)
3. exports 与 main 并存时,现代解析器优先 exports
// 这解释了一类经典报错:
// "Package subpath './x' is not defined by 'exports'"
// → 不是文件不存在,是 exports 白名单没放行
六、ts-node / Node ESM 的实战配置
6.1 Node 原生跑 TS ESM(Node 22+ / 20.6+ --experimental-strip-types)
# Node 22+ 可以直接跑 TS(类型擦除,不做类型检查):
node --experimental-strip-types src/index.ts
# 注意:这只是"擦类型",类型错误【不会】被拦截
# 类型检查仍要 tsc --noEmit(放到 CI / 提交钩子里,Day 27 串起来)
6.2 tsx:开发期跑 TS 的现代选择
npm i -D tsx
# package.json
{
"scripts": {
"dev": "tsx watch src/index.ts" # 开发:监听重启,ESM/CJS 通吃
}
}
# tsx 内置 esbuild,不用纠结 ts-node 的 ESM 那堆配置
# 原则:开发用 tsx(快),类型检查交给独立的 tsc --noEmit
6.3 经典报错速查表
| 报错 | 根因 | 修复 |
|---|---|---|
Cannot use import statement outside a module |
CJS 上下文里出现 ESM 语法 | package.json 加 "type": "module" 或改用 .mjs |
ERR_UNKNOWN_FILE_EXTENSION .ts |
Node 不认识 .ts | 用 tsx / --experimental-strip-types |
xxx only refers to a type but is being used as a value |
擦除后类型被当值用 | 拆开 import type |
The current file is a CommonJS module |
双模块系统判断错乱 | 检查 type 字段与文件扩展名 |
Package subpath './x' is not defined by 'exports' |
子路径没在 exports 白名单 | exports 里加 "./x": {...} |
七、工业场景:CJS 项目渐进迁移 ESM
背景:一个 5 年的老 CJS 项目(require 满天飞),要迁 ESM(为了 tree-shaking)
推荐的渐进路线(不要大爆炸重写):
第 1 步:工具链先行
- 开启 esModuleInterop(require 的 default 导入先能过)
- 开启 isolatedModules(强制新代码符合单文件编译)
第 2 步:新代码全部 ESM
- 新文件一律 import/export
- 禁止新代码出现 require(ESLint 规则拦截,Day 27)
第 3 步:分目录切换
- 每个子模块(如 src/utils/)整体转换 + 单独验证
- 利用 Node 的扩展名策略隔离:.cjs 保留旧文件,.mjs 放新文件
第 4 步:最后切 type: "module"
- 全量 require 清零后,package.json 加 "type": "module"
- 删掉 .mjs/.cjs 扩展名后缀
⭐ 保留的例外:真正需要动态 require 的地方(配置加载器),
用 ESM 的 await import() 替代 —— 它是动态的、返回 Promise
八、类比记忆:海关与签证
模块系统 = 国际物流
- ESM / CJS = 两种规格的集装箱(新式 vs 老式)
- esModuleInterop = 海关翻译官:老式集装箱也能按新式报关单提取(合成 default)
- import type = 货物清单上标注"仅文件资料,不占货舱"(编译期擦除)
- isolatedModules = 法规:每只集装箱必须能独立查验(不许"看完别的箱才懂这只")
- exports 字段 = 仓库的白名单门牌:没挂牌的房间一律不许提货
- verbatimModuleSyntax = 新法规:报关单写什么就验什么(所见即所得)
90% 的"物流事故"(模块报错)都出在:
1. 集装箱规格混装没请翻译官(缺 esModuleInterop)
2. 货物清单标注不清被错误销毁(缺 import type)
3. 去了白名单外的房间提货(exports 没放行)
九、常见坑点与最佳实践
坑点 1:相对导入不写扩展名
// ESM 规范要求相对导入【必须带扩展名】:
import { x } from "./utils"; // node16 模式下 ❌
import { x } from "./utils.js"; // ✅(注意 .js —— 即使源文件是 .ts!)
// 这是新人最懵的点:TS 源码里写 .js?
// 因为编译后 utils.ts 变成 utils.js,导入路径指向的是【产物】
// bundler 模式下可以不写(打包器自己补全)—— 这就是 moduleResolution 选型的影响
坑点 2:default 导出混用团队规范
// 一个团队里混用两种风格是灾难的开始:
// a.ts
export default function parse() {}
// b.ts
export function parse() {}
// 导入方永远要记"这个文件是哪种导出"
// 建议:工具库 / 多人协作项目统一用具名导出(tree-shaking 也更友好)
// default 导出留给"单入口"场景(页面的默认组件等)
坑点 3:循环依赖的静默炸弹
// a.ts
import { bInit } from "./b";
export const aFlag = true;
// b.ts
import { aFlag } from "./a";
export function bInit() { console.log(aFlag); } // ESM 下此时 aFlag 可能还是 undefined
// CJS 里循环"碰巧能跑"(拿到半成品 exports),ESM 下会拿到未初始化绑定
// 重构迁移时高发 —— 解法:抽公共依赖到第三个文件,打断环
坑点 4:types 字段放错位置
// ❌ exports 里 types 不在第一位:
{
"exports": {
".": {
"import": "./dist/index.mjs",
"types": "./dist/index.d.ts" // ❌ 永远匹配不到 import 条件之后
}
}
}
// ✅ types 永远第一个:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
十、自测挑战
挑战 1(基础):报错对号入座
以下报错分别对应今天讲的哪个知识点?
a. 'Device' cannot be used as a value because it was imported using 'import type'
b. Module 'cjs-lib' can only be default-imported using the 'esModuleInterop' flag
c. Package subpath './utils' is not defined by 'exports'
d. ERR_UNKNOWN_FILE_EXTENSION: .ts
挑战 2(进阶):修复一段双系统混用代码
// 模块 config.ts(CJS 风格写的)
module.exports = { env: "prod", retry: 3 };
// 你的 ESM 代码 main.ts —— 让它在开启 esModuleInterop 的前提下正确工作:
import config from "./config";
console.log(config.env);
// 问题:config.ts 是 .ts 文件,能被 import 吗?relative 导入要写什么扩展名?
// 动手实验验证你的答案。
挑战 3(实验):亲测活绑定
// 写 counter.mjs(export let count + inc 函数)和 main.mjs
// 用 node 直接跑,验证 ESM 活绑定
// 再改写成 CJS(counter.cjs),对比 count 是否还会"跟着变"
// 把两张输出对照写进博客
挑战 4(论文级):讲清 isolatedModules 禁止 const enum 的原因
向橡皮鸭解释:
1. const enum 的"内联"优化需要什么信息?
2. 为什么单文件编译器拿不到这个信息?
3. 为什么普通 enum 不受影响?
十一、总结与知识图谱
模块系统(Day 23)
│
├── 全景:两套模块系统
│ ├── 值模块 —— 运行时真实存在(ESM / CJS)
│ └── 类型模块 —— 编译期擦除(import type)
│
├── ESM vs CJS 根本差异
│ ├── 静态 vs 动态加载(tree-shaking 的根基)
│ ├── 活绑定 vs 值快照
│ └── 循环依赖行为差异
│
├── 翻译与隔离配置
│ ├── esModuleInterop —— CJS 合成 default(类型+运行时双翻译)
│ ├── allowSyntheticDefaultImports —— 只放行类型
│ ├── import type —— 显式纯类型导入
│ ├── isolatedModules —— 强制单文件可编译
│ └── verbatimModuleSyntax —— TS 5 的确定性擦除规则
│
├── package.json 现代门面
│ ├── exports 多条件入口(types 永远第一)
│ └── 子路径白名单
│
├── 运行环境
│ ├── tsx —— 开发期首选
│ └── tsc --noEmit —— 独立类型检查
│
└── 工业迁移路线
└── 工具链先行 → 新代码 ESM → 分目录切换 → 最后切 type
一句话总结:模块互操作的坑,本质是**"编译器猜你要擦什么"与"运行时需要什么"之间的信息差**——import type 消除猜测、esModuleInterop 负责翻译、exports 白名单守住门面,三个机制各管一段,管住 90% 的报错。
明日预告:Day 24 声明文件——当依赖库没有类型时,.d.ts 是你替它写的"类型说明书";declare module、三斜线指令、types 发布配置,补齐"与外部 JS 世界接壤"的最后一环。