本节目标:理解「模块」到底解决了什么问题,掌握 ES 模块的完整语法,并说清楚 TypeScript 是如何把
import "./math"这样的字符串变成磁盘上的具体文件的。读完你能自己诊断Cannot find module一类错误,并知道该改配置还是改代码。
11.1 ES 模块与模块解析
在第 2 章里我们已经用过 import 和 export,但那时是「照着写」——没人告诉你为什么非得这么写,也没人告诉你编译器是怎么把 import { add } from "./math" 变成对某个具体文件的读取的。
这一节要补上这块地基。它分成两半:前半讲模块语法(怎么写),后半讲模块解析(编译器怎么找)。两者必须一起理解,因为绝大多数模块相关的报错,根因都是「你以为的路径」和「编译器理解的路径」不一致。
11.1.1 没有模块的时代
在 ES 模块(ES Modules,简称 ESM)出现之前,浏览器里的 JavaScript 只能靠 <script> 标签拼接:
<script src="utils.js"></script>
<script src="main.js"></script>
这段代码有两个致命问题。第一,utils.js 和 main.js 共享同一个全局作用域,谁定义了一个 const name,另一个就不能再用同名变量——命名冲突。第二,main.js 能用上 utils.js 的函数,仅仅因为它排在后面——依赖关系是隐式的、靠加载顺序维持的,删掉一行 <script> 就可能全线崩溃。
社区试过几种补救:IIFE 包裹(把变量藏进函数作用域)、CommonJS(Node 的 require)、AMD(浏览器异步加载)。它们各有各的语法,互不兼容。ESM 是 TC39 给出的官方答案,也是本节的主题。
11.1.2 ES 模块的语法全貌
ESM 用两个关键字描述模块的边界:export 决定这个文件对外提供什么,import 决定这个文件依赖别人什么。
// src/math.ts
export const PI = 3.14159; // 命名导出
export function add(a: number, b: number): number {
return a + b;
}
export class Vector { /* ... */ }
function helper() { /* 未导出:模块私有 */ }
// src/main.ts
import { PI, add } from "./math"; // 命名导入
console.log(add(PI, 1)); // 4.14159
helper 没有 export,所以在 main.ts 里无论如何都拿不到它——ESM 的私有性由语法强制,而不是靠命名约定(_private 那种前缀写法只是君子协定)。
11.1.3 导出与导入的组合
实际项目里会遇到下面这些变体,逐一对照记忆:
| 写法 | 含义 | 适用场景 |
|---|---|---|
export const x = 1 | 声明时直接导出 | 最常见的命名导出 |
export { x, y } | 集中导出已声明的名字 | 文件末尾统一出口 |
export { x as y } | 导出时重命名 | 对外 API 改名 |
export default expr | 默认导出,一个模块最多一个 | 一个模块一个主对象 |
import { x } from "./m" | 命名导入 | 对应命名导出 |
import def from "./m" | 默认导入,名字随便起 | 对应默认导出 |
import * as ns from "./m" | 命名空间导入 | 导出很多、需要分组 |
import "./m" | 副作用导入,只要执行 | 注册、polyfill |
关于 export default 有一个新手最容易踩的坑:默认导出可以随意改名。
// src/logger.ts
export default function log(msg: string) { console.log(msg); }
// 三种写法都能跑,因为默认导出没有固定名字
import log from "./logger";
import anything from "./logger";
import whatever from "./logger";
命名导出则相反,名字必须对上(或用 as 显式改名):
import { add as plus } from "./math"; // 合法
// import { sum } from "./math"; // 报错:模块没有导出的成员"sum"
这个差异直接决定了编辑器的自动补全质量:默认导出的名字是使用者猜的,重命名重构时工具常常漏掉;命名导出能被静态分析精确追踪。所以团队风格指南通常建议优先命名导出,慎用默认导出。
11.1.4 模块是静态的
ESM 与 CommonJS 最根本的区别,是模块结构在编译期就完全确定。由此推出三件事。
第一,import / export 只能写在模块顶层,不能塞进 if 或函数里:
// 报错:An import declaration can only be used at the top level of a module.
if (process.env.NODE_ENV === "development") {
import { debug } from "./debug";
}
第二,导入的绑定是「活的」。模块 A 导出一个变量,模块 B 修改它,A 看到的是新值——导入的是引用而不是快照。
第三,编译期就能画出依赖图。打包器因此能做 tree-shaking(摇树优化):没被用到的导出直接从产物里删掉。这也是为什么「能不能被摇掉」跟导出形式强相关——直接写在 export 上的声明最容易被分析。
需要条件加载时用动态 import(),它返回一个 Promise:
const mod = await import("./heavy");
mod.run(); // 类型来自模块本身,无需手动断言
动态导入是表达式而非声明,所以可以放在任何位置。它返回的模块对象形状与命名空间导入一致。
11.1.5 说明符:相对路径与裸说明符
import 后面那串字符串叫说明符(specifier),分两类:
import { add } from "./math"; // 相对说明符:以 ./ 或 ../ 开头
import { add } from "../utils/math"; // 相对说明符
import { z } from "zod"; // 裸说明符:不以 ./ 开头
import { readFile } from "node:fs/promises"; // 内置模块(带 node: 前缀)
规则很硬:
- 以
./、../或/开头的,按文件路径解析,相对于当前文件所在目录。 - 不以这些开头的,一律当作包名,去
node_modules里找。
第二点是初学者的高频事故来源。假设你在 src/utils/strings.ts 里写了:
import { format } from "strings"; // 想导入同目录的 strings.ts
TypeScript 不会去找 ./strings.ts,而是会去 node_modules/strings 里找,找不到就报 Cannot find module 'strings'。想导入本地文件,./ 不能省。
11.1.6 模块解析:编译器怎么找文件
拿到一个说明符后,编译器要把它变成一个真实文件路径。这个过程叫模块解析(module resolution)。它是本书里少数「配置决定行为」的环节——同一份代码,换个 moduleResolution 就可能从能编译变成报错。
解析分两条完全不同的路径。
相对说明符:从当前文件目录出发,按顺序尝试。以 import { add } from "./math" 为例(node16 模式):
1. ./math.ts
2. ./math.tsx
3. ./math.d.ts
4. ./math/package.json 的 exports / types 字段
5. ./math/index.ts
裸说明符:从当前目录开始,逐级向上查找 node_modules,每一级里再按包内的入口字段(exports、types、main)决定具体文件。向上查找这一点很关键——它让 node_modules 可以放在项目的任意祖先目录,这正是 npm 嵌套安装模型的基础。
11.1.7 五种 moduleResolution 策略
tsconfig.json 的 moduleResolution 决定用哪套规则。它是最容易被忽略、又最容易出错的选项:
| 取值 | 规则来源 | 什么时候用 |
|---|---|---|
classic | TypeScript 早期方案,不查 node_modules | 只在 2015 年前的老代码里出现 |
node10(旧名 node) | CommonJS 的 require 规则 | 存量 Node 项目 |
node16 | Node 的 ESM + CJS 双规则 | 产物真正被 Node 执行 |
nodenext | 同 node16,随 Node 版本演进 | 新项目首选 |
bundler | 打包器语义,不要求写扩展名 | Vite、esbuild、webpack 项目 |
classic 是历史包袱,任何新项目都不该用。node10 仍然常见于存量项目,它允许省略扩展名、允许 index.ts 自动补全,但它的规则与 Node 真实运行时并不一致。
真正需要理解的是 node16/nodenext 与 bundler 的分歧:前者严格模拟 Node,要求显式写扩展名(import "./math.js",注意是 .js 不是 .ts);后者模拟打包器,允许省略扩展名。选错了就会出现两种截然相反的现象:写 .js 在 bundler 下显得多余,不写扩展名在 nodenext 下直接报错。
// 给 Node 直接运行的项目
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "ES2022"
}
}
// 给 Vite / esbuild 打包的项目
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"target": "ES2020"
}
}
module 与 moduleResolution 存在默认联动:不显式写 moduleResolution 时,module: "commonjs" 会推出 node10,module: "nodenext" 会推出 nodenext。建议两个都显式写出来,避免升级 TypeScript 时默认值变化带来的连锁反应。两者的完整对照会在下一节展开,打包器产物的差异则由第 16 章承担。
11.1.8 扩展名、目录索引与 index 文件
解析规则里最容易出事的细节是扩展名。
相对导入的扩展名,在 bundler 下可以省,在 node16/nodenext 下必须写。 而且写的是输出后的扩展名:
// nodenext 模式下,math.ts 编译成 math.js
import { add } from "./math.js"; // 正确
// import { add } from "./math"; // 报错:Relative import paths need explicit file extensions
// import { add } from "./math.ts"; // 报错:An import path can only end with a '.ts' extension...
这条规则对新手极其反直觉:源码里写的是 .js,磁盘上却是 .ts。原因是 TypeScript 遵循「导入路径描述运行时产物」的原则——运行时加载的确实是 math.js。
目录索引:import "./utils" 会尝试 ./utils/index.ts。这个便利特性在 node16/nodenext 下对 ESM 被禁用(Node 的 ESM 不做目录索引),只在 bundler 与 CJS 场景保留。所以「本地能跑、CI 报错」的一种常见成因,就是代码靠 index.ts 兜底,而 CI 用了更严格的解析模式。
11.1.9 循环依赖
模块 A 导入 B,B 又导入 A,就形成循环依赖。ESM 能处理它——靠提升(hoisting):导入的绑定在模块执行前就已建立,只是值是 undefined,直到对方完成求值。
// a.ts
import { b } from "./b";
export const a = "A";
console.log("a 看到 b =", b); // undefined(若 a 先被加载)
// b.ts
import { a } from "./a";
export const b = "B";
结果取决于谁先被加载。如果入口是 a.ts:先进入 a,遇到 import "./b" 转而执行 b,b 又导入 a,但 a 还没执行完,所以 b 里拿到的 a 是 undefined。
循环依赖不会直接报错,只会让某些变量在某一刻是 undefined,这类 bug 极难排查。工程上的对策是把共享的部分抽到第三个模块,让依赖图变成有向无环图。函数声明(function)因为整体提升,通常比 const 更能容忍循环,但依赖这个特性属于运气而非设计。
11.1.10 三类常见报错
Cannot find module './math' or its corresponding type declarations.
先看说明符有没有漏 ./,再看文件是否真的存在、扩展名是否符合当前 moduleResolution。若是第三方包,检查 npm install 是否已执行、包是否自带类型声明。
Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'.
这是 node16/nodenext 下最经典的报错。改法是把 "./math" 写成 "./math.js"。不要写成 .ts。
An import declaration can only be used at the top level of a module.
import 被写进了 if 或函数体。改成顶层导入,或者用动态 import()。
11.1.11 与后续章节的衔接
模块语法与解析是后面两节的公共地基:11.2 ESM/CJS 互操作与 moduleResolution
讲 ESM 与 CommonJS 混用时的互操作开关,11.3 npm 包、类型声明与 exports
讲第三方包如何通过 exports 与类型声明暴露给使用者,第 12 章则专门讲 .d.ts 与 @types 机制。想先建立「Node 到底怎么加载模块」的整体印象,可以延伸阅读 Node.js 模块系统与 ESM
;想直接看 TypeScript 侧的完整对照,见 TypeScript 模块解析与 ESM/CJS
。
小结
本节先回答了「为什么需要模块」:全局作用域会互相污染,脚本拼接的依赖关系隐式且脆弱。ES 模块用 export / import 把依赖变成代码的一部分,并保证模块结构在编译期完全静态——这既是 tree-shaking 的前提,也是「导入必须是顶层声明」这条限制的来源。
随后我们区分了两类说明符:./ 开头按路径解析,否则一律当包名去 node_modules 找;并梳理了五种 moduleResolution 策略的适用场景,重点提醒 node16/nodenext 要求显式写 .js 扩展名、而 bundler 不要求这一分歧。最后点出循环依赖的成因、表现与规避思路。
到这里我们讲的都是「纯 ESM」的世界。但现实项目里大量依赖仍然是 CommonJS 写的,require、module.exports、__dirname 这些东西不会消失。下一节 11.2 ESM/CJS 互操作与 moduleResolution
就把镜头转向另一侧:当你的代码是 ESM、而依赖的包是 CJS 时,默认导出、esModuleInterop 与 package.json 的 type 字段究竟怎么互相配合。
阅读导航:上一节:10.3 递归类型与类型性能治理 · 下一节:11.2 ESM/CJS 互操作与 moduleResolution 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。