【TS】day23-modules

作者:mario 发布时间: 2026-09-01 阅读量:5 评论数:0

TypeScript 模块系统 — ESM/CJS 互作的坑,一次讲透

今天解决一个"新手闻风丧胆、老手也常翻车"的领域:模块互操作。Cannot use import statement outside a moduleERR_UNKNOWN_FILE_EXTENSIONxxx only refers to a type but is being used as a value——这些报错十有八九源于一件事:TypeScript 里有两套模块系统在同时运转——值的模块(ESM/CJS 的运行时机制)和类型的模块(编译期就被擦除的 import type)。今天把两套系统的规则、翻译官(esModuleInterop)和隔离器(isolatedModules)讲透,从此模块报错 30 秒定位。


目录


一、先建立全景:两套模块系统

// 你以为你在用一套模块系统,其实是两套叠在一起:

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 世界接壤"的最后一环。

评论