说实话,刚上手 TypeScript 的时候,我也被那层薄薄的 .ts 文件欺骗过。觉得“我就是一个大 JS 项目改改后缀的事”。直到我的项目从 10 个文件长到 100 个文件,编译时间从 2 秒变成 20 秒,类型报错像雪片一样飞来——尤其是那种 Cannot find module 或者 Any 类型泛滥的绝望,我才真正意识到:模块化不是可选的洁癖,而是生存技能。
今天咱们不聊教科书式的定义,就聊聊我在实战里踩过的坑、用过的“骚操作”,以及怎么让你的 TypeScript 项目既整洁又高效。
一、 为什么你需要的不只是 export 和 import?
很多人觉得,TypeScript 模块化不就是 export class 和 import { } 吗?没错,这是语法基础。但组织是另一回事。
想象一下,你的项目结构是这样的:
src/
├── index.ts
├── utils.ts // 所有工具函数都扔这里
├── api.ts // 所有 API 调用都扔这里
├── types.ts // 所有类型都扔这里(最后变成 500 行的怪物)
└── components/
├── Button.tsx
└── Modal.tsx
看起来还行?但当 utils.ts 开始依赖 api.ts,types.ts 又被其他文件疯狂引用时,你就陷入了循环依赖和命名冲突的泥潭。
TypeScript 的模块化核心在于两点:
- 作用域隔离:每个文件是一个独立的模块,不污染全局。
- 依赖明确:通过
import显式声明,让编译器帮你检查。
这才是模块化的灵魂。
二、 项目结构:告别“扁平化”,拥抱“特性驱动”
1. 常见陷阱:按文件类型分类
你肯定见过这种结构:
src/
├── components/
├── services/
├── utils/
├── types/
└── hooks/
这看起来条理清晰,对吧?但实际开发中,你会发现一个 Button 组件需要一个 ButtonService,还有一个 useButton hook,以及一堆 ButtonTypes。你必须在三个文件夹之间跳来跳去,维护成本爆炸。
2. 更优方案:特性驱动(Feature-Sliced Design)
我的建议是:按业务特性组织,而不是按技术类型。
src/
├── app/
│ ├── app.tsx
│ └── router.ts
├── features/
│ ├── auth/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── services/
│ │ ├── types.ts
│ │ └── index.ts // 导出本特性所有公共接口
│ ├── dashboard/
│ │ └── ...
│ └── cart/
│ └── ...
├── shared/
│ ├── components/ // 通用 UI 组件(按钮、输入框等)
│ ├── utils/ // 纯工具函数
│ ├── hooks/ // 通用 hooks
│ └── api/ // 基础 API 客户端
└── entrypoints/
├── web/
└── node/ // 如果是全栈项目
为什么这样更好?
- 高内聚:一个特性相关的所有代码都在一个文件夹里,方便查找和删除。
- 低耦合:特性之间通过
index.ts导出明确边界,避免隐式依赖。 - 可扩展:新增一个特性,只需新建一个文件夹,不影响其他模块。
3. 关键:使用 index.ts 作为“入口点”
在每个模块(尤其是特性文件夹)里,放一个 index.ts,明确导出这个模块对外暴露的接口:
// src/features/auth/index.ts
export { default as useAuth } from './hooks/useAuth';
export { default as LoginForm } from './components/LoginForm';
export * from './types';
export { authApi } from './services/api';
这样,其他模块引入时只需要:
import { useAuth, LoginForm } from '@/features/auth';
而不是:
import { useAuth } from '@/features/auth/hooks/useAuth'; // 路径依赖,脆弱
原则:只导出公共接口,内部实现细节对模块外隐藏。
三、 tsconfig.json:模块系统的基石
TypeScript 的模块行为完全由 tsconfig.json 控制。别偷懒,好好配置它。
1. module 和 moduleResolution
{
"compilerOptions": {
"module": "ESNext", // 输出 ES 模块,支持现代打包工具
"moduleResolution": "bundler", // 推荐用 bundler,对现代工具链最友好
"target": "ES2020", // 目标 JS 版本
"strict": true, // 开启所有严格类型检查
"esModuleInterop": true, // 兼容 CommonJS 模块
"allowSyntheticDefaultImports": true // 允许 import Default from 'xxx'
}
}
moduleResolution: "bundler"是 2024+ 的首选,它模拟 Vite、Webpack、Rollup 等工具的解析行为,对相对路径、别名支持更好。moduleResolution: "node"是旧标准,适合纯 Node.js 项目,但对前端项目不够灵活。
2. 路径别名:告别 ../../../../
当你的项目结构变深,相对路径会变成噩梦:
import { useAuth } from '../../../../features/auth/hooks/useAuth'; // 丑陋
用 paths 配置别名,让导入清爽:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@features/*": ["src/features/*"],
"@shared/*": ["src/shared/*"]
}
}
}
然后在代码里:
import { useAuth } from '@features/auth/hooks/useAuth';
import { Button } from '@shared/components/Button';
注意:路径别名需要你的打包工具支持(Vite、Webpack、esbuild 等都支持)。如果是纯 TS 编译,需要额外配置 tsc 的 paths 映射。
3. typeRoots 和 types
避免 @types/ 包的全局污染:
{
"compilerOptions": {
"types": ["node", "jest"] // 只引入需要的类型包
}
}
这样,只有 node 和 jest 的类型会全局可用,其他类型必须显式 import。
四、 依赖管理:内部依赖 vs. 外部依赖
TypeScript 项目有两种依赖:
- 外部依赖:
package.json里的dependencies和devDependencies。 - 内部依赖:模块之间的导入关系。
1. 外部依赖:精确控制
使用 pnpm 或 yarn 而非 npm,它们对依赖树管理更严格。
# 安装开发依赖
pnpm add -D typescript @types/node ts-node
# 安装运行时依赖
pnpm add zod @tanstack/react-query
关键原则:
- 类型定义分离:把
@types/*包放在devDependencies,因为生产环境不需要。 - 避免“类型泄漏”:如果外部库提供了类型(如
zod),不要额外安装@types/zod,避免版本冲突。
2. 内部依赖:建立依赖图
手动检查你的模块依赖关系。一个健康的依赖图应该是有向无环图(DAG),没有循环依赖。
如何检测循环依赖?
- 工具推荐:madge
npx madge --circular src/index.ts - 手动检查:如果一个模块 A 导入了模块 B,模块 B 又导入了模块 A,这就是循环依赖,必须重构。
常见循环依赖场景:
// user.ts
import { getUser } from './api'; // api.ts 导入了 user.ts 的类型
// api.ts
import { User } from './user'; // 循环!
解法:把共享类型提取到 types.ts,或者用接口解耦。
// types.ts
export interface User { id: string; name: string; }
// user.ts
import { User } from './types';
import { getUser } from './api'; // 现在 api.ts 只导入 types,不导入 user.ts
// api.ts
import { User } from './types'; // 单向依赖
export const getUser = (id: string): Promise<User> => { ... };
3. 发布自己的模块:package.json 的 exports 字段
如果你要把一个模块发布给其他项目用,别只依赖 main 和 module 字段。用 exports 精确控制入口:
{
"name": "@myorg/auth",
"version": "1.0.0",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js",
"types": "./dist/types/index.d.ts"
},
"./hooks": {
"import": "./dist/esm/hooks/index.js",
"types": "./dist/types/hooks/index.d.ts"
}
},
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts"
}
这样,使用者可以:
import { useAuth } from '@myorg/auth/hooks'; // 精确导入,.tree-shaking 友好
而不是:
import { useAuth } from '@myorg/auth/dist/hooks'; // 糟糕,路径泄露
五、 实战示例:重构一个“混沌”模块
假设你有一个 UserService 文件,里面什么都有:
// src/UserService.ts (混沌版)
interface User { id: string; name: string; }
interface Post { id: string; title: string; authorId: string; }
const api = {
getUsers: () => fetch('/users').then(r => r.json()),
getPosts: () => fetch('/posts').then(r => r.json()),
};
export const getUser = async (id: string): Promise<User> => { ... };
export const getPostsByUser = async (userId: string): Promise<Post[]> => { ... };
export const transformUser = (user: any): User => { ... }; // 类型不安全!
重构步骤:
1. 按职责拆分文件
src/features/user/
├── types.ts
├── api.ts
├── repository.ts
├── service.ts
└── index.ts
2. types.ts:只放类型定义
// src/features/user/types.ts
export interface User {
id: string;
name: string;
email: string;
}
export interface Post {
id: string;
title: string;
content: string;
authorId: string;
}
3. api.ts:只做网络请求,不处理业务逻辑
// src/features/user/api.ts
import { User, Post } from './types';
const BASE_URL = '/api';
export const userApi = {
getUsers: async (): Promise<User[]> => {
const res = await fetch(`${BASE_URL}/users`);
if (!res.ok) throw new Error('Failed to fetch users');
return res.json() as Promise<User[]>;
},
getPostsByUserId: async (userId: string): Promise<Post[]> => {
const res = await fetch(`${BASE_URL}/posts?authorId=${userId}`);
if (!res.ok) throw new Error('Failed to fetch posts');
return res.json() as Promise<Post[]>;
},
};
4. repository.ts:数据访问层,封装 API 调用
// src/features/user/repository.ts
import { userApi } from './api';
import { User, Post } from './types';
export class UserRepository {
async findAll(): Promise<User[]> {
return userApi.getUsers();
}
async findPostsByUser(userId: string): Promise<Post[]> {
return userApi.getPostsByUserId(userId);
}
}
5. service.ts:业务逻辑层
// src/features/user/service.ts
import { UserRepository } from './repository';
import { User } from './types';
export class UserService {
private repository: UserRepository;
constructor() {
this.repository = new UserRepository();
}
async getActiveUsers(): Promise<User[]> {
const users = await this.repository.findAll();
return users.filter(u => u.email.endsWith('@example.com')); // 业务规则
}
async getUserWithPosts(userId: string) {
const [user, posts] = await Promise.all([
this.repository.findAll(),
this.repository.findPostsByUser(userId),
]);
const targetUser = users.find(u => u.id === userId);
return { user: targetUser, posts };
}
}
6. index.ts:导出公共接口
// src/features/user/index.ts
export { UserService } from './service';
export { UserRepository } from './repository';
export type { User, Post } from './types';
重构后的好处:
- 单一职责:每个文件只做一件事。
- 可测试性:
UserService可以 mockUserRepository进行测试。 - 类型安全:所有类型都在
types.ts中统一定义,避免重复和错误。 - 依赖清晰:
service→repository→api→types,单向依赖,无循环。
六、 常见陷阱与避坑指南
1. 滥用 any 类型
any 是 TypeScript 的“逃生舱”,但滥用它会让你失去所有类型保护。
错误做法:
const data: any = await fetch('/api').then(r => r.json());
console.log(data.users[0].name); // 编译通过,运行可能报错
正确做法:
interface ApiResponse {
users: Array<{ name: string; id: string }>;
}
const data = await fetch('/api').then(r => r.json() as ApiResponse);
console.log(data.users[0].name); // 类型安全
如果实在无法确定类型,用 unknown 代替 any,然后做类型守卫:
function handleInput(input: unknown) {
if (typeof input === 'string') {
console.log(input.toUpperCase()); // 安全
}
}
2. 过度使用泛型
泛型是强大工具,但不是万能药。过度泛型化会让代码难以阅读和维护。
反例:
function process<T, U, V>(data: T, config: U, callback: (result: V) => void): V { ... }
正例:
只在真正需要类型抽象时才用泛型。比如通用的 Repository<T> 或 ApiResult<T>。
interface ApiResult<T> {
data: T;
error?: string;
}
async function fetchData<T>(url: string): Promise<ApiResult<T>> { ... }
3. 忽略 strictNullChecks
开启 strict: true 后,null 和 undefined 不再是万能类型。这会让你更早发现潜在的空值错误。
function greet(name: string) { ... }
greet(null); // 类型错误,必须传 string
建议:新项目务必开启 strict: true。
4. 滥用 @ts-ignore 和 @ts-expect-error
这些注释是“妥协”,不应该成为常态。
// 糟糕的做法
// @ts-ignore
const result = someLib.untypedFunction();
正确做法:
- 如果是第三方库缺少类型,用
@types/包补充,或者写一个.d.ts声明文件。 - 如果库确实没有类型,用
// @ts-ignore作为临时方案,但加注释说明原因。
// TODO: 移除这个 ignore,等库更新类型定义
// @ts-ignore
const result = someLib.untypedFunction();
七、 工具推荐:让模块化更简单
1. 代码格式化工具
- Prettier:统一代码风格,减少团队争论。
- ESLint:检查代码质量问题,配合
@typescript-eslint插件。
// .eslintrc.json
{
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"]
}
2. 类型检查工具
- tsc:TypeScript 编译器,本地运行
tsc --noEmit检查类型错误。 - TypeScript Language Server:集成到 VS Code 中,实时报错。
3. 依赖分析工具
- madge:检测循环依赖。
- import-cost:VS Code 插件,显示每个导入包的大小。
