第四章:打造随需应变的“通用模具” - 泛型

第四章:打造随需应变的“通用模具” - 泛型

摘要: 欢迎来到第四章。在本章,我们将完成一次从“具体实现”到“抽象建模”的思维升级。我们将直面当前 Todo 应用中因处理不同数据类型而导致的逻辑重复问题,并引入 TypeScript 的核心利器——泛型——来解决它。您将不再编写一次性的函数,而是学会创建如同“通用模具”般的、可适应多种数据类型的、高度复用且类型安全的函数与接口。我们将立即在 Todo 应用中实践,重构出一个通用的 API 数据结构,让您切身感受泛型在真实工程中的威力。


4.1. 痛点呈现:重复的逻辑与失控的 any

随着应用变得复杂,我们不可避免地需要处理多种不同类型的数据。让我们在 Todo 应用中模拟这个场景,并看看它如何立刻导致代码冗余。

4.1.1. 场景:为应用添加“用户”数据

假设我们的 Todo 应用现在需要显示当前的用户信息。首先,我们需要在项目中定义用户的“契约”。

第一步:创建 types.ts 并定义 User 接口

为了更好地组织代码,我们创建一个专门存放类型定义的文件。

文件路径: src/types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 我们之前定义的 Todo 和 Filter 类型也移到这里
export interface Todo {
readonly id: number;
text: string;
completed: boolean;
dueDate?: Date;
}

export type Filter = "all" | "active" | "completed";

// 新增 User 接口
export interface User {
id: number;
name: string;
email: string;
}

export type AppState = {
todos: Todo[];
currentUser: User[] | null; // 用户可能存在,也可能未登录
currentFilter: Filter;
}

第二步:更新 main.ts 以使用新类型

文件路径: src/main.ts

1
2
3
4
5
6
7
8
9
10
11
12
import './style.css';
// 从 types.ts 导入类型
import type { Todo, User, AppState, Filter } from './types';

const state: AppState = {
todos: [
{ id: 1, text: "学习 TypeScript 核心类型", completed: true },
{ id: 2, text: "编写一个 Todo List 应用", completed: false },
],
currentUser: { id: 1, name: "Prorise", email: "contact@prorise.com" },
currentFilter: "all"
};

4.1.2. 痛点:重复的查找逻辑

现在,我们需要一个函数根据 ID 查找 Todo,也需要一个几乎完全一样的函数来根据 ID 查找用户。

文件路径: src/main.ts (在 state 定义下方添加)

1
2
3
4
5
6
7
8
9
function findTodoById(id: number): Todo | undefined {
return state.todos.find(todo => todo.id === id);
}

function findUserById(id: number): User | undefined {
// 假设我们有一个用户列表
const users: User[] = [state.currentUser!];
return users.find(user => user.id === id);
}

问题显而易见:这两个函数的 逻辑完全相同,唯一的区别就是处理的数据类型(TodoUser)不同。在工程中,这种重复是“万恶之源”,它增加了维护成本,违反了 DRY (Don’t Repeat Yourself) 原则。


4.1.3. 失败的抽象:使用 any 导致的类型安全丧失

一个自然的冲动是使用 any 来创建一个通用函数。让我们看看为什么这是一个陷阱。

1
2
3
4
5
6
7
8
9
10
11
function findItemById_any(items: any[], id: number): any {
return items.find(item => item.id === id);
}

const foundTodo = findItemById_any(state.todos, 1);

// 灾难发生:
// 1. 类型丢失:foundTodo 的类型是 any,我们失去了所有代码提示。
// 2. 运行时风险:我们可以调用任何不存在的方法,编译器不会警告我们。
// 这行代码可以通过编译,但在运行时会因为 toUpperCase 不存在而崩溃。
console.log(foundTodo.text.toUpperCase());

any 以牺牲类型安全为代价换取了灵活性,这对于追求健壮性的工程师来说是不可接受的。它让我们回到了序章中的“混沌”状态。


4.2. 泛型 <T> 的引入与泛型约束

现在,让我们引入真正的“利器”——泛型。泛型允许我们编写一个函数,并为其中的类型创建一个“占位符”(通常表示为 <T>),在使用该函数时再填入具体的类型。

4.2.1. 重构:创建一个通用的 findItemById<T> 函数

文件路径: src/utils.ts (我们创建一个新的工具文件)

1
2
3
4
5
6
7
// T 是一个类型变量,一个“占位符”。
// 这个函数接收一个 T 类型的数组和一个 number 类型的 id,
// 并返回一个 T 类型的值或 undefined。
export function findItemById<T>(items: T[], id: number): T | undefined {
// 这里会出现一个编译错误,这是我们下一步要解决的
return items.find(item => item.id === id);
}

痛点分析: TypeScript 报错是因为,它只知道 T 是“某种类型”,但它不能保证“某种类型”一定拥有一个 id 属性。我们需要给泛型添加约束。

4.2.2. 泛型约束 (extends):让泛型更“聪明”

解决方案: 我们使用 extends 关键字来告诉 TypeScript,传入的类型 T 必须满足 某个“形状”。

文件路径: src/utils.ts (修改 findItemById 函数)

1
2
3
4
5
// 我们约束 T 必须是这样一个对象:它至少要有一个 number 类型的 id 属性。
export function findItemById<T extends { id: number }>(items: T[], id: number): T | undefined {
// 现在 TypeScript 知道 item 上一定有 id 属性,错误消失了。
return items.find(item => item.id === id);
}

第三步:在 main.ts 中使用我们的通用工具

文件路径: src/main.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import './style.css';
// 从 types.ts 导入类型
import type { Todo, User, AppState, Filter } from './types';
import { findItemById } from './utils'; // 导入我们的泛型函数

const state: AppState = {
todos: [
{ id: 1, text: "学习 TypeScript 核心类型", completed: true },
{ id: 2, text: "编写一个 Todo List 应用", completed: false },
],
currentUser: [
{ id: 1, name: "Prorise", email: "contact@prorise.com" },
{ id: 2, name: "John", email: "john@example.com" },
],
currentFilter: "all"
};

// 调用泛型函数
// 1. 查找 Todo
const todo = findItemById(state.todos, 1);
// todo 的类型被正确推断为 Todo | undefined,拥有 text, completed 等属性

console.log(todo?.text);

// 2. 查找 User
const user = findItemById(state.currentUser!, 1);

console.log(user?.name);



我们成功地用一个 完全类型安全 的泛型函数替换了两个重复的函数。这就是泛型在工程化中的核心价值:在不牺牲类型安全的前提下,实现最高程度的代码复用。


4.3. 泛型接口:为 API 响应建立通用契约

痛点: 我们的应用将来会从 API 获取各种数据:Todo 列表、用户列表、配置项等。这些 API 响应通常有统一的结构,例如:

1
{ status: 200, message: "OK", data: [...] }

为每一种数据都写一个 TodoApiResponse, UserApiResponse 接口,又会陷入重复的泥潭。

解决方案: 使用泛型接口,创建一个可复用的 ApiResponse<T> 模板。

第四步:在 types.ts 中定义泛型接口

文件路径: src/types.ts

1
2
3
4
5
6
7
8
9
// ... 其他类型定义 ...

// 定义一个通用的 API 响应“包装器”
// T 代表了 `data` 属性中具体的数据类型
export interface ApiResponse<T> {
status: number;
message: string;
data: T;
}

第五步:在 api.ts 中应用泛型接口

文件路径: src/api.ts (新建文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { Todo, User, ApiResponse } from './types';

// 模拟 API 地址
const API_BASE = "https://api.example.com";

// 获取 Todo 列表的函数
// 它的返回值是一个 Promise,Promise 的解析值符合 ApiResponse <Todo[]> 契约
export async function fetchTodos(): Promise<ApiResponse<Todo[]>> {
// 实际项目中这里是真实的 fetch 调用
// return fetch(`${API_BASE}/todos`).then(res => res.json());

// 此处为模拟数据
return Promise.resolve({
status: 200,
message: "Success",
data: [
{ id: 1, text: "从 API 获取 Todo", completed: false }
]
});
}

// 获取单个用户的函数
export async function fetchUser(id: number): Promise<ApiResponse<User>> {
return Promise.resolve({
status: 200,
message: "Success",
data: { id, name: "API User", email: "api@example.com" }
});
}

现在,我们有了一个统一的、可复用的 ApiResponse<T> 接口来描述所有来自后端的数据结构,极大地提升了代码的一致性和可维护性。


4.4 本章小结

类型工具核心价值在我们 Todo 应用中的实践
泛型函数 <T>消除代码重复,同时保持类型安全。创建了一个通用的 findItemById 工具函数,可用于查找任何带 id 的对象。
泛型约束 extends为泛型添加规则,使其更智能、更安全。约束了 findItemById 的参数类型必须包含 id 属性。
泛型接口创建可复用的数据结构“模板”。定义了标准的 ApiResponse<T> 接口,统一了所有 API 响应的形状。

在本章,我们引入了泛型这一强大的抽象工具,将 Todo 应用的可复用性提升到了一个新的层次。我们学会了不再为每一个具体类型编写重复逻辑,而是去思考和构建通用的解决方案。在下一章,我们将面对一个新的挑战:随着应用逻辑增多,main.ts 文件将变得越来越臃肿。