《TypeScript编程入门》11.1 ES 模块与模块解析

本节从模块化的历史讲起,说明为什么全局脚本会互相污染、ES 模块如何用显式的 export 与 import 把依赖写进代码。随后逐一拆解命名导出、默认导出、重命名与副作用导入的取舍,讲清相对说明符与裸说明符的区别,并用表格对照五种 moduleResolution 的查找规则与适用场景,最后给出扩展名、目录索引、循环依赖三类高频坑与对应报错。

本节目标:理解「模块」到底解决了什么问题,掌握 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 决定用哪套规则。它是最容易被忽略、又最容易出错的选项:

取值规则来源什么时候用
classicTypeScript 早期方案,不查 node_modules只在 2015 年前的老代码里出现
node10(旧名 node)CommonJS 的 require 规则存量 Node 项目
node16Node 的 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 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes