模块与声明文件
让类型在文件之间、项目之间自由传递
🎯 学习目标
学完本章,你将能够:
- 能够使用 ESM 语法(export/import)组织模块代码
- 能够编写 .d.ts 声明文件为 JS 库提供类型支持
- 能够理解模块解析策略(Node、Bundler)与路径别名配置
- 能够处理 CJS 与 ESM 互操作及默认导入陷阱
- 能够运用 Barrel 模式与包导出配置优化模块结构
先看一个问题
你的 User 接口在 a.ts 里定义了,b.ts 也要用——怎么办?用了第三方库没有类型——又怎么办?
// a.ts —— 定义了 User 接口
interface User {
id: string;
name: string;
}
// b.ts —— 想用 User,但 TS 说"找不到 User"
function greet(user: User) { // 报错!
console.log(user.name);
}
// 用了 lodash,但 TS 提示 any
import _ from "lodash";
_.chunk([1, 2, 3, 4], 2); // 没有类型提示
第一个问题的答案是模块导入/导出,第二个问题的答案是声明文件。这两个机制共同构成了 TypeScript 的"类型流通系统"。
Part 1: 类型导入
用 import/export 在文件之间传递类型
开篇的问题直击痛点——User 接口在 a.ts 里定义了,b.ts 想用怎么办?答案就是 import 和 export。但 TypeScript 还多了一个 type 修饰符,让你精确标记"这个导入是纯类型,编译后擦掉"。三种导入方式各有适用场景。
导出类型
从模块中暴露类型定义
// utils.ts
export interface User {
id: string;
name: string;
email: string;
}
export type Status = "active" | "inactive";
export function getUser(id: string): User {
return { id, name: "张三", email: "a@b.c" };
}
// 值和类型可以同名导出
export const DEFAULT_STATUS: Status = "active";
// 纯类型导出(type 修饰符)
export type { User, Status };
// 编译后会被擦除,不产生 JS 代码
导入类型
三种导入方式,推荐内联 type 标记
// 方式1:混合导入
import { User, Status, getUser } from "./utils";
// 方式2:仅导入类型
import type { User, Status } from "./utils";
// 方式3:内联 type 标记(推荐)
import { type User, type Status, getUser } from "./utils";
// ^^^^ ^^^^
// 标记这些是纯类型导入,编译后被擦除
// 重命名导入/导出
import { User as UserType } from "./utils";
export { getUser as fetchUser } from "./utils";
// 默认导出
export default class UserService {
constructor(private users: User[] = []) {}
}
import UserService from "./utils";
推荐使用 import { type X } 内联标记,混合导入值和类型时最清晰。
Part 2: 全局声明
declare 告诉 TS:"这个东西在运行时存在,请相信我"
有些变量是运行时注入的——比如 __DEV__、process.env、CDN 加载的全局函数。TypeScript 不认识它们,会报"找不到"的错误。declare 关键字就是来救场的:它告诉编译器"别检查了,运行时一定有"。还可以给没有类型的第三方库补充声明。
declare 关键字
为运行时已存在的全局变量声明类型
// 声明全局变量
declare const API_URL: string;
declare const __DEV__: boolean;
declare const process: { env: Record<string, string> };
// 使用
if (__DEV__) {
console.log("开发模式");
}
// 声明全局函数
declare function ga(command: string, ...args: unknown[]): void;
ga("send", "pageview");
// 声明全局模块——为无类型的库补充类型
declare module "some-untyped-lib" {
export function doSomething(input: string): number;
export const version: string;
}
// 使用——就像有类型的模块一样
import { doSomething } from "some-untyped-lib";
doSomething("hello"); // 有完整的类型提示
Part 3: .d.ts 文件
纯类型声明文件,不包含实现,编译后不输出 JS
declare 可以写在任意 .ts 文件里,但更好的做法是统一放到 .d.ts 文件中。这种文件只放类型声明,不放实现,编译后完全消失。扩展 Express 的 Request、声明环境变量类型、声明图片和 CSS 模块——都是 .d.ts 的典型用途。
编写 .d.ts
扩展第三方模块、声明环境类型
// types/custom.d.ts
// 扩展 express 的 Request 类型
declare module "express" {
interface Request {
userId?: string;
sessionId?: string;
user?: {
id: string;
name: string;
role: "admin" | "user";
};
}
}
// 声明 .env 文件的类型
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: "development" | "production" | "test";
PORT?: string;
DATABASE_URL: string;
JWT_SECRET: string;
}
}
// 使用时有完整的类型提示
process.env.DATABASE_URL; // string
process.env.JWT_SECRET; // string
// process.env.UNKNOWN; // 报错!不在声明中
静态资源声明
让 TS 识别图片、CSS 等非 JS 导入
// types/assets.d.ts
// 声明图片模块
declare module "*.png" {
const value: string;
export default value;
}
declare module "*.svg" {
const value: string;
export default value;
}
// 声明 CSS 模块
declare module "*.css" {
const classes: Record<string, string>;
export default classes;
}
// 使用
import logo from "./logo.png"; // string(图片 URL)
import styles from "./button.css"; // Record<string, string>
Part 4: 补充类型
三种方式为第三方库获取类型,优先级从高到低
装了一个 npm 包,导入后全是 any——这种体验太糟糕了。TypeScript 生态有三种方式解决:库自带类型、安装 @types 包、手写声明文件。优先用前两种,手写是最后手段。我们来看看每种方式怎么做。
三种方式
从最简单到最复杂
| 优先级 | 方式 | 说明 |
|---|---|---|
| 1(最推荐) | 自带类型的库 | package.json 有 "types" 字段 |
| 2 | 安装 @types/xxx | DefinitelyTyped 社区维护 |
| 3(最后手段) | 手写 .d.ts | 自己维护,可能与库不同步 |
# 方式2:安装 DefinitelyTyped 类型包
$ npm install -D @types/express
$ npm install -D @types/node
$ npm install -D @types/lodash
# 安装后自动生效
手写声明示例
为无类型库补充完整的接口
// src/types/tiny-cache.d.ts
declare module "tiny-cache" {
interface Options {
ttl?: number; // 过期时间(毫秒)
maxSize?: number; // 最大缓存条目数
}
class Cache {
constructor(options?: Options);
get<T>(key: string): T | undefined;
set<T>(key: string, value: T, ttl?: number): void;
delete(key: string): boolean;
clear(): void;
has(key: string): boolean;
get size(): number;
}
export = Cache;
}
// 使用
import Cache from "tiny-cache";
const cache = new Cache({ ttl: 60000 });
cache.set("user:1", { name: "张三" });
const user = cache.get<{ name: string }>("user:1");
Part 5: 全局扩展
扩展现有的全局接口,如 Window、Array 等
有时候你需要给 Window 加自定义属性,或者给 Array 加个 unique 方法。利用声明合并和 declare global,就能安全地扩展内置接口。但有一个关键陷阱:你的 .d.ts 文件到底是"模块"还是"脚本"?这决定了扩展全局类型的方式。
扩展全局接口
给 Window、String 等内置接口添加自定义方法
// 扩展 Window 接口
declare global {
interface Window {
__APP_VERSION__: string;
analytics: {
track(event: string, data?: Record<string, unknown>): void;
identify(userId: string): void;
};
}
}
// 使用
window.__APP_VERSION__; // string
window.analytics.track("page_view", { page: "/home" });
// 扩展 Array 接口
declare global {
interface Array<T> {
/** 返回去重后的新数组 */
unique(): T[];
}
}
模块 vs 脚本
一个关键区别决定作用域
| 特征 | 模块模式 | 脚本模式 |
|---|---|---|
| 判断标准 | 有 import 或 export | 没有任何导入导出 |
| 作用域 | 文件级别(独立作用域) | 全局作用域 |
| 扩展全局类型 | 需要 declare global {} | 直接声明即可 |
// 如果需要扩展全局类型,文件至少有一个 export {} 让它成为模块
export {}; // 这一行让文件变成模块
declare global {
interface Window {
myCustomProp: string;
}
}
三斜线指令
现代项目中已很少使用
// 三斜线指令——在 .d.ts 中引用其他声明
/// <reference types="node" /> // 引入 Node.js 类型
/// <reference path="./types.d.ts" /> // 引入相对路径的类型文件
现代 TS 项目中三斜线指令已很少使用,大多数场景通过 tsconfig.json 的 types 和 include 配置即可。
路径别名
告别 "../../../" 的地狱嵌套
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@utils/*": ["src/utils/*"]
}
}
}
// 没有别名——痛苦
import { User } from "../../../types/user";
import { formatDate } from "../../utils/date";
// 使用别名——清爽
import { User } from "@/types/user";
import { formatDate } from "@/utils/date";
# 运行时也需要处理(Node.js 不认识 @/)
# 方案1:tsconfig-paths
$ npx ts-node -r tsconfig-paths/register src/index.ts
# 方案2:构建时替换(esbuild、tsc-alias)
# 方案3:package.json 的 imports(Node 12.20+)
路径别名在开发时很爽,但运行时需要额外处理。建议只定义 1-2 个顶级别名(如 @/*),别搞太多层级。
解析流程
import 一行代码背后发生了什么?

图:模块解析流程
【AI 生图提示词】请生成一张中文技术流程图,风格简洁、配色柔和、适合教学: - 画面主体:从上到下的决策流程 - 步骤 1:import 语句开始 - 步骤 2:检查 tsconfig paths(路径别名映射) - 步骤 3:检查 node_modules/@types 下的类型声明 - 步骤 4:检查包自带的 types 字段 - 步骤 5:文件系统查找(.ts → .tsx → .d.ts → index.ts) - 结果:找到则成功(绿色勾号),找不到则报错"Cannot find module"(红色叉号) - 每步之间用箭头连接,标注关键路径 - 配色建议:浅蓝背景,步骤白色圆角矩形,成功绿色,失败红色,箭头橙色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
理解这个流程,遇到"找不到模块"时就能按图索骥:先查 paths,再查 @types,最后查文件是否存在。
Barrel 模式
index.ts 统一导出——方便但要注意 tree-shaking
// src/components/Button.ts
export class Button { /* ... */ }
// src/components/Input.ts
export class Input { /* ... */ }
// src/components/Modal.ts
export class Modal { /* ... */ }
// src/components/index.ts(barrel 文件)
export { Button } from "./Button";
export { Input } from "./Input";
export { Modal } from "./Modal";
// 使用方——一行搞定
import { Button, Input } from "@/components";
// 不用写:
// import { Button } from "@/components/Button";
// import { Input } from "@/components/Input";
Part 6: ESM 与 CJS
两大模块体系的差异与互操作,是 Node.js 项目中的高频痛点
新项目用 ESM,老代码用 CJS,npm 生态两套体系并存。import 和 require 能不能混用?为什么有时候默认导入会报错?esModuleInterop 到底该不该开?这一节把这些问题一次性说清楚,再补充模块解析策略和循环依赖的处理方案。
CJS 与 ESM 对比
两种模块系统的核心差异
| 特征 | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| 语法 | require() / module.exports | import / export |
| 加载时机 | 运行时同步加载 | 编译时静态分析 |
| 值的性质 | 值的拷贝(可修改) | 值的绑定(只读引用) |
| Tree-shaking | 不支持 | 支持 |
| 顶层 await | 不支持 | 支持 |
| 文件扩展名 | .cjs | .mjs |
// CJS 写法
const fs = require("fs");
module.exports = { read: fs.readFileSync };
// ESM 写法
import fs from "fs";
export const read = fs.readFileSync;
新项目优先使用 ESM。CJS 仍然广泛存在于 npm 生态中,所以"互操作"是日常必修课。

图:ESM 与 CJS 的值绑定差异
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 上行CJS:moduleA导出counter=0,moduleB require得到快照副本(0),moduleA中counter++→1,moduleB仍为0,虚线箭头"值拷贝不同步" - 下行ESM:moduleA export counter=0,moduleB import实时引用,moduleA中counter++→1,moduleB也读到1,实线箭头"实时绑定同步" - 右侧:"CJS=拷贝快照 | ESM=实时引用" - 配色:CJS橙色(不同步),ESM绿色(同步),模块圆角矩形,16:9
默认导入陷阱
CJS 模块的 default 导入与 esModuleInterop
// CJS 库的实际导出:
// module.exports = function() { ... }
// 没有 esModuleInterop 时——必须用 * as
import * as express from "express"; // OK
import express from "express"; // 报错!
// 开启 esModuleInterop 后——可以直接默认导入
import express from "express"; // OK!TS 自动合成 default
express(); // 正常调用
// tsconfig.json
{
"compilerOptions": {
"esModuleInterop": true, // 推荐开启
"allowSyntheticDefaultImports": true // 通常随 esModuleInterop 自动开启
}
}
esModuleInterop 同时影响输入和输出。如果你是库作者,开启后生成的代码会使用 __importDefault 辅助函数,可能与某些运行环境不兼容。库项目建议谨慎评估。
扩展名区分
.mts / .mjs / .cts / .cjs 显式声明模块类型
| 扩展名 | TS 源码 | 编译输出 | 声明文件 |
|---|---|---|---|
| ESM | .mts | .mjs | .d.mts |
| CJS | .cts | .cjs | .d.cts |
| 由 package.json 决定 | .ts | .js | .d.ts |
// math.mts —— 明确为 ES Module
export function add(a: number, b: number): number {
return a + b;
}
// 编译输出: math.mjs,声明文件: math.d.mts
// legacy.cts —— 明确为 CommonJS
export function legacyHelper(): void { }
// 编译输出: legacy.cjs,声明文件: math.d.cts
在混合 CJS/ESM 的项目中,使用显式扩展名可以避免歧义。.ts 的模块类型由最近的 package.json 中的 "type" 字段决定。
模块解析策略
moduleResolution 的三种模式对比
| 特性 | node (classic) | node16 / nodenext | bundler |
|---|---|---|---|
| 适用场景 | 旧项目兼容 | Node.js 原生 ESM | Vite/webpack 等打包工具 |
| 相对导入写扩展名 | 不需要 | 必须写 .js | 不需要 |
| package.json exports | 不识别 | 完整支持 | 完整支持 |
| 条件导入 | 不支持 | 支持 | 支持 |
| 推荐程度 | 不推荐新项目使用 | Node.js 项目首选 | 前端项目首选 |
// node16 模式下——必须写 .js 扩展名(即使源文件是 .ts!)
import { User } from "./user.js"; // 正确
import { User } from "./user"; // 报错!
import { User } from "./user.ts"; // 报错!不能写 .ts
// bundler 模式下——省略扩展名没问题
import { User } from "./user"; // OK
.js 而不是 .ts。这是因为 TS 编译后输出的是 .js 文件,导入路径不会重写。新手几乎都会踩这个坑。
包导出配置
package.json exports 与条件导出
// package.json
{
"name": "my-lib",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./utils": {
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
}
}
}
// 消费方使用——自动匹配正确的格式
import { hello } from "my-lib"; // ESM → index.mjs
import { helper } from "my-lib/utils"; // ESM → utils.mjs
const { hello } = require("my-lib"); // CJS → index.cjs
exports 字段让同一个包同时为 ESM 和 CJS 消费者提供正确的入口。类型声明的匹配顺序:types 条件 → 文件扩展名(.d.mts / .d.cts)→ .d.ts。
多版本类型
typesVersions 为不同 TS 版本提供类型
// package.json
{
"types": "./dist/index.d.ts",
"typesVersions": {
">=5.0": {
"*": ["./dist/index.d.ts"]
},
">=4.7": {
"*": ["./dist/ts4.7/index.d.ts"]
},
"*": {
"*": ["./dist/legacy/index.d.ts"]
}
}
}
利用 typesVersions,库作者可以为新版本 TS 提供更精确的类型(如 const 泛型参数),同时保持旧版本的兼容性。大多数项目不需要配置它,了解即可。
循环依赖
什么是循环依赖?为什么它会引发运行时崩溃?
// a.ts
import { b } from "./b";
export const a = 1;
console.log(b); // 可能是 undefined!
// b.ts
import { a } from "./a";
export const b = 2;
console.log(a); // 可能是 undefined!

图:循环依赖的加载流程
【AI 生图提示词】请生成一张中文技术流程图,风格简洁、配色柔和、适合教学: - 画面主体:从左到右的时序流程,展示循环依赖的完整加载过程 - 步骤 1:Node.js 先加载 a.ts - 步骤 2:遇到 import { b } from './b' → 暂停 a.ts,开始加载 b.ts - 步骤 3:b.ts 遇到 import { a } from './a' → 检测到循环依赖 - 步骤 4:返回 a.ts 已导出的部分(此时为空)→ 红色警告"undefined 风险" - 步骤 5:b.ts 完成 → 回到 a.ts 继续完成 - 关键标注:"部分导出"、"循环检测"、"undefined 风险" - 配色建议:浅灰背景,正常步骤蓝色,循环检测橙色,风险步骤红色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
undefined,TypeScript 编译不报错但行为不可预测。这是 JS 模块系统中最隐蔽的 bug 来源之一。
打破循环
三种常用模式消除循环依赖
// 模式1:提取共享类型到第三个文件
// types.ts(无依赖,最推荐)
export interface User { id: string; name: string; }
export interface Order { id: string; userId: string; }
// 模式2:延迟导入(lazy import)
// 只在函数内部导入,而非文件顶层
export function processOrder() {
const { getUser } = require("./user-service"); // 运行时才导入
const user = getUser(order.userId);
}
// 模式3:依赖注入——通过参数传入,而非导入
export function createOrder(userService: UserService) {
// 不 import UserService,由调用方传入
const user = userService.getUser(userId);
}
模式1(提取共享类型)是最彻底的解决方案。模式2和3适合重构成本高的存量代码。可以用 madge 工具可视化检测循环依赖:npx madge --circular src/。
ESM 声明文件
.d.mts — TS 4.7+ 为 ES Module 提供的声明格式
// math.mts(源文件)
export function add(a: number, b: number): number {
return a + b;
}
export const PI = 3.14;
// math.d.mts(自动生成的声明文件)
export declare function add(a: number, b: number): number;
export declare const PI: 3.14;
| 源文件 | 编译输出 | 声明文件 | 模块类型 |
|---|---|---|---|
.mts | .mjs | .d.mts | ES Module |
.cts | .cjs | .d.cts | CommonJS |
.ts | .js | .d.ts | 由 type 字段决定 |
TS 4.7 引入了 .d.mts 和 .d.cts,让声明文件的模块类型与源码严格对应。发布 npm 包时,确保声明文件的扩展名与编译输出匹配。
模块最佳实践
项目中的模块使用规范
// 1. 使用 import type 让 tree-shaking 更彻底
import { type User, getUser } from "./user";
// ^^^^^^^^^^ 编译后擦除,打包工具可以安全移除 User 相关代码
// 2. 开启 isolatedModules(几乎所有项目都应开启)
// tsconfig.json: "isolatedModules": true
// 确保每个文件可以独立编译,兼容 esbuild / Babel / SWC
// 3. 善用 re-export 简化公开 API
// src/index.ts
export { UserService } from "./services/user";
export type { User, UserRole } from "./types/user";
export { formatDate, parseDate } from "./utils/date";
// 4. 避免深层嵌套的 barrel 文件
// 好的做法:只在顶层做一次 re-export
export { Button } from "./components/Button";
export { Input } from "./components/Input";
// 不好的做法:每层都有 index.ts 做 barrel
// components/ui/forms/inputs/index.ts
// components/ui/forms/index.ts
// components/ui/index.ts
// components/index.ts
// 四层 barrel = 四次模块解析 = 打包变慢
const enum 或 namespace 合并,这些特性要求跨文件类型信息。开启 isolatedModules 后 TS 会禁止这些用法,确保项目兼容 esbuild 等单文件编译器。Vite 项目默认就依赖此模式。
Express 练习
练习
任务:为一个 Express 项目设计完整的类型结构,创建多个 .d.ts 文件并配置 tsconfig paths。
// 项目结构:
// src/
// types/
// env.d.ts —— 声明 process.env
// express.d.ts —— 扩展 Express Request
// models.d.ts —— 数据模型类型
// routes/
// users.ts
// index.ts
// 1. types/env.d.ts —— 声明环境变量
// 2. types/express.d.ts —— 给 Request 添加 userId
// 3. types/models.d.ts —— 导出 User、Post 类型
// 4. tsconfig.json —— 配置 @/* 路径别名
参考答案
// types/env.d.ts
export {};
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: "development" | "production";
PORT?: string;
DATABASE_URL: string;
JWT_SECRET: string;
}
}
// types/express.d.ts
export {};
declare module "express" {
interface Request {
userId?: string;
userRole?: "admin" | "user";
}
}
// types/models.d.ts
export interface User {
id: string;
name: string;
email: string;
role: "admin" | "user";
}
export interface Post {
id: string;
title: string;
content: string;
authorId: string;
}
// tsconfig.json(关键部分)
// "baseUrl": ".",
// "paths": { "@/*": ["src/*"] }
常见问题
问题一:找不到模块声明
// 导入一个没有类型的库时报错
import lodash from "lodash"; // 报错!找不到声明文件
解决:安装 @types/lodash 或创建自定义 .d.ts。
问题二:declare global 不生效
// .d.ts 文件中 declare global 没有效果
// 原因:文件缺少 export {} 导致它不是模块
// 解决:添加 export {};
export {};
declare global {
interface Window { myProp: string; }
}
问题三:import type 和 import 混淆
// 错误:用 import type 导入值
import type { getUser } from "./utils"; // 报错!getUser 是值不是类型
// 正确:用 type 标记类型,不标记值
import { type User, getUser } from "./utils";
试一试
练习一
任务:创建一个 types/env.d.ts 文件,声明 process.env 的类型(包含 PORT、NODE_ENV、DATABASE_URL)。
// types/env.d.ts
// 你的代码:
参考答案
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: "development" | "production" | "test";
PORT?: string;
DATABASE_URL: string;
}
}
练习二
任务:为一个名为 my-lib 的无类型库手写声明文件,它导出 format(date: Date): string 和 VERSION: string。
// types/my-lib.d.ts
// 你的代码:
参考答案
declare module "my-lib" {
export function format(date: Date): string;
export const VERSION: string;
}
本章要点
模块与声明文件知识体系回顾
| 主题 | 核心概念 | 一句话总结 |
|---|---|---|
| 类型导入导出 | import type / export type | 用内联 type 标记最清晰 |
| declare | 全局声明 / 模块扩展 | 告诉 TS 运行时存在什么 |
| .d.ts | 声明文件编写 | 纯类型,不输出 JS |
| 补充类型 | @types / 手写声明 | 优先库自带类型 |
| ESM 与 CJS | 互操作 / 扩展名 | 新项目优先 ESM |
| 模块解析 | node16 / bundler | Node 用 node16,前端用 bundler |
| 循环依赖 | 提取 / 延迟导入 | 用 madge 检测,提取共享类型解决 |
| 最佳实践 | isolatedModules / re-export | 开启 isolatedModules 是底线 |
📝 要点总结
本章核心知识点
模块语法
ESM export/import、默认导出与命名导出、type 关键字
声明文件
.d.ts 编写、declare 关键字、全局类型扩展
解析策略
Node 经典解析、Bundler 现代解析、paths 路径别名
CJS 与 ESM
互操作规则、默认导入陷阱、esModuleInterop
最佳实践
Barrel 模式、循环依赖处理、包 exports 配置
下一章
模块和声明文件让类型在项目中流通,ESM/CJS 互操作与循环依赖处理是进阶必备。下一章我们学习前端工程化基础——让开发流程更专业。