TypeScript 模块就是至少带有一个顶层 import 或 export 的文件。其中声明的一切都是这个文件私有的,除非你 export 它,其他文件再 import 自己需要的内容。语法就是 JavaScript 的 ES 模块语法,再加上几种只用于类型的形式。
命名导出与默认导出
一个项目有很多文件,所以导入的一方是这样写的。默认导出不用花括号导入,可以取任意名字;命名导出写在花括号里,保持原来的名字,除非你用 as 重命名。
// main.ts
import describe, { distance, ORIGIN, type Point } from "./math.js";
import { distance as dist } from "./math.js"; // renamed on import
import * as math from "./math.js"; // everything, as one object
const p: Point = { x: 6, y: 8 };
console.log(describe(p), distance(ORIGIN, p), dist(p, p), math.ORIGIN);
| 写法 | 导入的内容 |
|---|---|
import { a, b } from "./m.js" | 命名导出 a 和 b |
import x from "./m.js" | 默认导出,名为 x |
import * as m from "./m.js" | 包含所有导出的命名空间对象 |
import { a as b } from "./m.js" | a,在本文件中重命名为 b |
import type { T } from "./m.js" | 只有类型,会从输出中删除 |
import "./setup.js" | 运行该文件,只为了它的副作用 |
export { a } from "./m.js" | 不导入而直接重新导出 a |
export * from "./m.js" | 重新导出所有命名导出 |
重新导出可以让一个文件(通常是 index.ts)汇集某个文件夹的公开 API。纯 JavaScript 的模块行为,比如实时绑定和模块缓存,见 ES 模块。
import type 与 export type
类型在运行时并不存在,所以一个只用作类型的导入没有任何东西需要加载。import type 明确表达了这一点,这条语句会从 JavaScript 输出中消失。type 修饰符也可以用在普通导入中的单个名称上。
import type { User } from "./models.js"; // whole statement erased
import { saveUser, type Settings } from "./api.js"; // only saveUser survives
export type { User }; // re-export a type only
export type UserId = User["id"];
不写这个关键字时,TypeScript 仍然会去掉它能看出只是类型的名称。有两种设置会让这个关键字成为必需:
verbatimModuleSyntax: true会保留每个没有标记type的导入,所以未标记的类型导入会报错 TS1484:'Point' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.- 在 Node 中直接运行
.ts文件(类型剥离)会去掉类型标注,但不会查看其他文件。未标记的import { Point }留在代码里,Node 在运行时就会失败,报SyntaxError: The requested module './math.ts' does not provide an export named 'Point'。
给每个只导入类型的语句都写上 type,在两种情况下都没问题,所以这是值得养成的习惯。
Cannot Find Module
当导入路径既不指向某个文件,也不指向带类型的包时,编译器会以错误 TS2307 停止。运行下面这段代码看看:
输出是 index.ts(2,29): error TS2307: Cannot find module './utils.js' or its corresponding type declarations.。常见原因:
- 相对路径拼写错误,或者漏写了
./(没有它,名称会到node_modules中查找)。 - 一个没有附带类型的 JavaScript 包:如果有
@types/{package}就安装它,否则为它写一个声明文件。 - 包的
package.json中exports字段没有列出的子路径。在node16、nodenext和bundler解析方式下,只有列出的入口才能被导入。
输出 ES 模块还是 CommonJS
你始终写的是 import 和 export,由 tsconfig.json 中的 module 选项决定输出什么样的 JavaScript。
// Source, the same in both cases
import { add } from "./math.js";
console.log(add(1, 2));
// module: nodenext, in a package with "type": "module"
import { add } from "./math.js";
console.log(add(1, 2));
// module: nodenext, in a package without "type": "module"
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const math_js_1 = require("./math.js");
console.log((0, math_js_1.add)(1, 2));
使用 node16、node18、node20 或 nodenext 时,TypeScript 对每个文件都遵循 Node 自己的规则:
| 文件 | 输出格式 |
|---|---|
在带 "type": "module" 的包中的 .ts | ES 模块 |
在不带它的包中的 .ts | CommonJS |
.mts | 始终是 ES 模块,输出为 .mjs |
.cts | 始终是 CommonJS,输出为 .cjs |
使用 module: "esnext" 或 "preserve" 时,输出会保留 import/export,这正是 Vite 和 esbuild 等打包工具所期望的。两种模块系统在运行时的区别见 CommonJS 与 ESM。
导入路径中的文件扩展名
在 module: node16 或 nodenext 下,ES 模块文件必须写出 Node 实际会加载的文件名,也就是编译后的 .js 文件。类型检查时,TypeScript 会把 ./math.js 映射回 math.ts。
import { add } from "./math"; // error TS2835 in an ES module file
import { add } from "./math.js"; // correct: the path as it exists after compiling
错误信息是 Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './math.js'?。同样设置下的 CommonJS 文件可以省略扩展名,因为 require 会自己尝试各种扩展名。
另外两种配置会改变这条规则:
moduleResolution: "bundler"(module设为esnext、preserve或commonjs)接受不带扩展名的./math,因为由打包工具来解析它。rewriteRelativeImportExtensions: true允许你写./math.ts,也就是 Node 直接运行.ts文件时可用的同一个路径,并在输出中把它改写为./math.js。
模块解析与 paths
对于 "zod" 这样的裸名称,TypeScript 会到 node_modules 中查找,读取包的 package.json(其中的 exports 和 types 字段),找不到时再退回到 node_modules/@types/zod。相对名称(./、../)从发起导入的文件开始解析。.json 文件也可以被导入,所需的设置见 JSON 页面。
tsconfig.json 中的 paths 可以为你自己的文件夹添加别名:
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"paths": {
"@lib/*": ["./src/lib/*"]
}
}
}
paths 只影响类型检查。生成的文件中仍然写着 import { v } from "@lib/util",所以运行时必须由别的东西来解析它:配置了同样别名的打包工具,或者 package.json 中 Node 的 imports 字段(以 # 开头的别名,比如 "#lib/*": "./dist/lib/*"),后者不需要任何额外工具。TypeScript 7 已经移除了 baseUrl(错误 TS5102);请相对于 tsconfig.json 编写 paths 条目,并以 ./ 开头。
常见问题
TypeScript 中 import 和 import type 有什么区别?
import type { User } from "./user.js" 只能导入类型,整条语句会从 JavaScript 输出中删除。普通的 import 可以导入值和类型;TypeScript 会去掉只当作类型使用的名称,但开启 verbatimModuleSyntax 后,它要求你用 type 标记这些名称,让输出和你写的完全一致。
为什么 TypeScript 要求导入路径带 .js?
在 module: node16 或 nodenext 下,ES 模块文件必须用 Node 运行时真正加载的文件名来导入,而那个文件是编译后的 .js。类型检查时,TypeScript 会把 ./math.js 解析到 math.ts。在 ES 模块文件中省略扩展名会报错 TS2835。
TypeScript 中应该用 export default 还是命名导出?
两种都可以。很多团队更喜欢命名导出:每个导入它的文件里名字都一样,编辑器能可靠地自动导入,重命名也是一次重构而不是全局搜索。默认导出则允许每个导入方自己取名。
tsconfig 中的 paths 选项会改变输出里的 import 吗?
不会。paths 只告诉类型检查器去哪里找模块。生成的 JavaScript 仍然按原样保留 "@lib/util",所以运行时必须由打包工具或 package.json 中 Node 的 imports 字段来解析它。
如何修复 TypeScript 中的“Cannot find module”?
错误 TS2307 表示路径没有指向 TypeScript 能找到的文件,或者某个包没有附带类型。检查相对路径和扩展名;如果包没有内置类型,就安装它的 @types/... 包,或者为它写一个简短的声明文件。