嘿,朋友。写代码写到深夜,盯着屏幕上那一堆 import 发呆的时候,你是不是也有种想把键盘吃了的冲动?
“这破模块怎么引入了八百个依赖?” “这个函数明明写在 A 文件里,为什么 B 文件能调用它?” “改了这边,那边报错;改了那边,这边崩盘。”
别急,这锅咱们不背。这是模块化没玩明白的锅。今天咱们不整那些虚头巴脑的概念,我就当是个刚入行的老大哥,坐在你对面,抽根烟,跟你聊聊 TypeScript 模块化那个事儿。咱们从最基础的 export/import 聊到让人头秃的 namespace,最后告诉你怎么把这些烂摊子收拾得干干净净。
一、 为什么我们会陷入“依赖地狱”?
先说说我之前的坑。
那时候我觉得 import 嘛,不就是把别人写的东西拿过来用吗?于是,我的 main.ts 变成了这样:
import { UserService } from './services/UserService';
import { AuthService } from './services/AuthService';
import { Logger } from './utils/Logger';
import { Config } from './config/Config';
import { API } from './api/API';
import { DateUtils } from './utils/DateUtils';
import { EventBus } from './events/EventBus';
// ... 还有五十个 import
我觉得挺安全,挺模块化的。结果呢?
- 启动慢得像蜗牛:浏览器加载这些文件的时候,感觉像在等一部电影放完。
- 改不动:我想改一下
Config的一个配置,结果UserService崩了,AuthService也崩了,因为它们都间接依赖了它。 - 重复代码满天飞:我在
A文件里写了一个工具函数formatDate,在B文件里又写了一个一模一样的。为啥?因为我想复用A里的,结果发现A没export,或者导出了但我不知道怎么优雅地用。
这就是依赖地狱。你以为你在写代码,其实代码在写你。
二、 ES Modules (import/export):现代前端的基石
好,让我们回到正轨。TypeScript 官方强烈推荐的是 ES Modules,也就是 import 和 export。这是目前业界的标准,Node.js 用它,浏览器用它,打包工具(Webpack, Vite, Rollup)也用它。
2.1 基础用法:默认导出 vs 命名导出
很多新手分不清这两个的区别,导致代码混乱。
场景:你要写一个按钮组件。
错误写法:
// Button.ts
export class Button { ... }
export function createButton() { ... }
export const DEFAULT_COLOR = 'blue';
正确写法(根据语义选择):
- 默认导出:一个文件只有一个主角。比如组件、类、主配置。
- 命名导出:一个文件有多个工具函数、常量。
// Button.ts - 默认导出(这个文件的核心就是这个 Button 类)
export default class Button {
constructor(public color: string) {}
render() { return `<button style="color:${this.color}">Click me</button>`; }
}
// helpers.ts - 命名导出(这里有多个平级的工具函数)
export function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export function truncate(text: string, length: number): string {
return text.length > length ? text.slice(0, length) + '...' : text;
}
export const MAX_LENGTH = 100;
使用的时候:
// App.ts
// 默认导出,你可以随便命名
import Button from './Button';
// 命名导出,必须用大括号,名字要对
import { isValidEmail, truncate, MAX_LENGTH } from './helpers';
// 或者批量重命名导入(很实用,避免名字冲突)
import { isValidEmail as checkEmail, truncate as shortText } from './helpers';
关键点:
- 默认导出:
import AnyName from './Module' - 命名导出:
import { RealName } from './Module'
这看起来很简单,对吧?但问题来了,当项目变大,这些文件之间互相引用,依然会乱。
2.2 循环依赖:死锁的陷阱
这是依赖地狱的巅峰。
// A.ts
import { B } from './B';
export class A {
doSomething() { B.run(); }
}
// B.ts
import { A } from './A';
export class B {
run() { console.log(A.value); }
}
TypeScript 编译器可能会报错,或者更糟糕——运行时报错 undefined。因为 A 在导入 B 的时候,B 还没定义完;反之亦然。
解决方案:
- 提取公共依赖:把
A和B都用到的东西(比如value)提取到一个新的C.ts里。 - 按需导入:不要在文件顶部
import,而是在函数内部import。
// A.ts - 动态导入,打破循环
export class A {
async doSomething() {
const { B } = await import('./B'); // 延迟加载,只在需要时才加载 B
B.run();
}
}
但这只是治标。治本的方法是重新设计你的模块边界。
三、 Namespace:老旧的“上帝包”
在 ES Modules 普及之前,TypeScript 甚至 JavaScript 早期,人们用 namespace 来组织代码。
// MathUtils.ts
namespace MathUtils {
export function add(a: number, b: number): number {
return a + b;
}
export function subtract(a: number, b: number): number {
return a - b;
}
}
// 使用
let result = MathUtils.add(1, 2);
听起来不错?别急。
Namespace 的问题在于:
- 命名空间污染:你定义了一个
MathUtils,别人也可能定义一个MathUtils。它们会合并!这会导致意想不到的 bug。 - 打包工具不友好:Webpack、Vite 这些现代打包工具是专门为 ES Modules 设计的。Namespace 会让它们难以分析依赖图,导致代码分割(Code Splitting)失效,你打包出来的 JS 文件巨大无比。
- 不是标准:它是 TypeScript 特有的语法,JavaScript 里没有。这意味着如果你以后迁移到纯 JS,或者给其他前端框架用,代码得重写。
我的建议:除非你在维护一个非常古老的 TypeScript 项目,或者你需要做内联类型声明(.d.ts 文件),否则永远不要用 namespace 来组织业务代码。
四、 彻底解决:分层架构与 Barrel Files
好了,理论讲完了。现在教你怎么落地。
4.1 分层架构:别把所有东西都扔在一起
把你的项目分成三层:
- Domain(领域层):纯粹的类、接口、业务逻辑。不依赖 UI,不依赖网络。
- Application(应用层):用例、服务。调用 Domain,调用 Infrastructure。
- Infrastructure(基础设施层):API 调用、数据库、UI 组件。
示例结构:
src/
├── domain/
│ ├── user/
│ │ ├── User.ts // class User { id, name, email }
│ │ ├── UserRepo.ts // interface UserRepo { findAll(): Promise<User[]> }
│ │ └── UserService.ts // class UserService { constructor(private repo: UserRepo) {} ... }
│ └── auth/
│ └── AuthService.ts
├── application/
│ ├── api/
│ │ └── UserApi.ts // 调用 fetch 获取用户
│ └── services/
│ └── RemoteUserService.ts // 继承 UserRepo,用 UserApi 实现
├── ui/
│ └── UserList.tsx
└── index.ts // 入口文件,只负责组装
好处:
domain层没有任何import,除了自己的文件。它很干净,可测试。application层依赖domain,但不依赖ui。ui层依赖application,但不依赖domain(通常通过接口)。
这样,改 UserApi 的网络请求方式,不会影响到 UserService 的业务逻辑。
4.2 Barrel Files:简化导入,隐藏内部实现
当你有很多小文件时,导入会变得很丑:
import { User } from './domain/user/User';
import { UserService } from './domain/user/UserService';
import { UserRepo } from './domain/user/UserRepo';
Barrel File 做法:
// src/domain/user/index.ts
export { User } from './User';
export { UserService } from './UserService';
export { UserRepo } from './UserRepo';
然后,外部这样用:
import { User, UserService } from './domain/user'; // 清爽多了!
关键优势:
- 隐藏内部:外部模块只能访问你
export的东西。如果UserValidator只是内部工具,就不放在index.ts里导出,它就成了私有实现。 - 重构轻松:以后你把
User.ts拆成两个文件,只要更新user/index.ts,所有调用方代码都不用改。
4.3 依赖注入(DI):打破硬编码耦合
回到之前的循环依赖问题。如果 UserService 直接 new 一个 UserApi,它们就耦合死了。
用接口解耦:
// domain/user/UserRepo.ts
export interface UserRepo {
findAll(): Promise<User[]>;
}
// application/services/InMemoryUserRepo.ts
import { UserRepo, User } from '../../domain/user';
export class InMemoryUserRepo implements UserRepo {
async findAll(): Promise<User[]> {
return [{ id: 1, name: 'Alice' }];
}
}
// application/services/RemoteUserRepo.ts
import { UserRepo, User } from '../../domain/user';
import { fetchUsers } from '../api/userApi'; // 假设这个函数存在
export class RemoteUserRepo implements UserRepo {
async findAll(): Promise<User[]> {
return fetchUsers();
}
}
// domain/user/UserService.ts
import { UserRepo, User } from './';
export class UserService {
constructor(private repo: UserRepo) {} // 依赖接口,不依赖具体实现
async getActiveUsers(): Promise<User[]> {
const all = await this.repo.findAll();
return all.filter(u => u.id !== 0);
}
}
在入口文件组装:
// src/index.ts
import { UserService } from './domain/user';
import { RemoteUserRepo } from './application/services/RemoteUserRepo';
const userRepo = new RemoteUserRepo();
const userService = new UserService(userRepo);
userService.getActiveUsers().then(console.log);
这就是终极解决方案:
- Domain 层:只关心业务规则,不关心数据从哪来。
- Application 层:关心数据如何获取,但不关心 UI。
- Infrastructure 层:关心具体实现(API、DB)。
- 入口文件:负责把具体实现注入到业务逻辑中。
这样,你要换数据库?只改 RemoteUserRepo。你要换 UI?只改 UI 组件。UserService 根本不需要动。
五、 实战:一个完整的例子
让我们用一个稍微复杂点的例子——一个图书管理系统。
步骤 1:定义领域模型(Domain)
// src/domain/book/Book.ts
export interface Book {
id: string;
title: string;
author: string;
isAvailable: boolean;
}
// src/domain/book/BookRepo.ts
export interface BookRepo {
findById(id: string): Promise<Book | null>;
findAll(): Promise<Book[]>;
save(book: Book): Promise<void>;
}
// src/domain/book/BookService.ts
import { Book, BookRepo } from './';
export class BookService {
constructor(private repo: BookRepo) {}
async borrowBook(id: string): Promise<{ success: boolean; message: string }> {
const book = await this.repo.findById(id);
if (!book) {
return { success: false, message: 'Book not found' };
}
if (!book.isAvailable) {
return { success: false, message: 'Book is already borrowed' };
}
// 模拟更新
book.isAvailable = false;
await this.repo.save(book);
return { success: true, message: 'Book borrowed successfully' };
}
}
步骤 2:实现基础设施(Infrastructure)
// src/infrastructure/book/InMemoryBookRepo.ts
import { BookRepo, Book } from '../../domain/book';
// 假装这是数据库
const books: Book[] = [
{ id: '1', title: 'TypeScript Deep Dive', author: 'Zhongchu Li', isAvailable: true },
{ id: '2', title: 'Clean Code', author: 'Robert Martin', isAvailable: false },
];
export class InMemoryBookRepo implements BookRepo {
async findById(id: string): Promise<Book | null> {
return books.find(b => b.id === id) || null;
}
async findAll(): Promise<Book[]> {
return [...books]; // 返回副本,防止外部修改
}
async save(book: Book): Promise<void> {
const index = books.findIndex(b => b.id === book.id);
if (index !== -1) {
books[index] = book;
}
}
}
步骤 3:创建 Barrel Files
// src/domain/book/index.ts
export { Book } from './Book';
export { BookRepo } from './BookRepo';
export { BookService } from './BookService';
// src/infrastructure/book/index.ts
export { InMemoryBookRepo } from './InMemoryBookRepo';
步骤 4:组装应用
// src/app.ts
import { BookService } from './domain/book';
import { InMemoryBookRepo } from './infrastructure/book';
// 创建仓库实例
const repo = new InMemoryBookRepo();
// 创建服务实例,注入仓库
const bookService = new BookService(repo);
// 导出服务,供 UI 层使用
export { bookService };
步骤 5:UI 层调用
// src/ui/Library.tsx
import { bookService } from '../app';
export function Library() {
async function handleBorrow(id: string) {
const result = await bookService.borrowBook(id);
alert(result.message);
}
return (
<div>
<h1>Library</h1>
<button onClick={() => handleBorrow('1')}>Borrow TypeScript Book</button>
</div>
);
}
看看这个结构:
domain里没有import任何东西(除了自己的文件)。infrastructure只依赖domain。app只依赖domain和infrastructure。ui只依赖app。- 没有循环依赖,没有重复代码,清晰易懂。
六、 避坑指南:新手常犯的错误
1. 滥用 any 类型
// 错误
export function process(data: any) { ... }
// 正确
export function process(data: Record<string, unknown>) { ... }
用 any 会破坏类型安全,让模块化变得毫无意义。
2. 导出内部实现
// 错误:暴露了内部状态
export const config = { secret: '123' };
// 正确:只暴露必要的方法
export function getConfig(): string { ... }
Barrel File 帮你控制哪些东西该暴露,哪些不该。
3. 在业务逻辑里直接 fetch
// 错误:业务逻辑和网络耦合
export class BookService {
async getBooks() {
const res = await fetch('/api/books');
return res.json();
}
}
// 正确:通过接口解耦
export interface BookApi {
getBooks(): Promise<Book[]>;
}
export class HttpBookApi implements BookApi {
async getBooks(): Promise<Book[]> {
const res = await fetch('/api/books');
return res.json();
}
}
4. 忘记 export 导致私有变量被意外访问
TypeScript 的 export 是模块级别的。如果你在一个文件里写了 let count = 0; 但没有 export,它就是私有的。这是好事,保持封装。
七、 总结:模块化是一种思维方式
模块化不只是语法糖,它是一种分治策略。
- 高内聚:一个模块只做一件事。
- 低耦合:模块之间通过接口通信,不关心内部实现。
- 可测试:你可以单独测试
BookService,Mock 掉BookRepo。 - 可复用:
BookService可以在 Web、Node.js、甚至移动应用里复用,只要提供不同的BookRepo实现。
别再写那种几千行的 `main.ts
