一文搞懂 TS 中的泛型 | 提升 TypeScript 项目的开发效率

595 阅读10分钟

image.png

在实际的前端开发中,随着项目的复杂性增加,我们经常会遇到需要封装通用函数的需求。而在使用 TypeScript 时,我们要确保代码的类型安全性。这就要求我们在编写通用函数时,能够处理各种类型的参数,而不是仅仅固定一种数据类型。

比如现在很多公司采用 TypeScript 进行开发时,肯定常常会遇到这样的问题:如何去封装一个能处理多种类型参数的函数?

一、什么是泛型

泛型是一种让我们在编写代码时可以不提前指定类型,而是在使用时根据实际情况动态传递类型的机制。

简而言之,泛型就是代码中的一种“占位符”,在编写时你不需要确定最终的类型,而是等到使用时再决定类型。

1.1 为什么需要泛型?

假设我们在项目中需要写一个处理数组的函数,通常我们会这样写:

function getFirstElement(arr: number[]): number {
    return arr[0];
}

这个函数只能处理 number[] 类型的数组。如果我们还需要处理 string[] 或其他类型的数组,就必须再写一遍类似的函数或者是使用联合类型:

function getFirstElement(arr: number[] | string[] | boolean[]): number | string | boolean {
    return arr[0];
}

这个函数可以处理 number[]string[]boolean[] 类型的数组。然而,这种方式仍然有几个问题:

  1. 可扩展性差:如果我们以后需要处理更多的类型,比如 Date[]Object[],那么我们就需要不断地修改函数签名,增加更多的类型到联合类型中,这使得代码的扩展性较差。

  2. 类型安全性不足:虽然这个函数可以处理不同类型的数组,但它不能精确地推断出返回值的类型。比如:

    const result = getFirstElement([1, 2, 3]);  // result 类型为 number | string | boolean
    

    由于返回类型是联合类型,result 的具体类型无法通过类型推断自动确定,导致类型安全性降低。

为了解决这种问题,TypeScript 提供了泛型,让我们能够编写一个通用的函数,处理任何类型的数组。

1.2 使用泛型优化代码

通过使用泛型,我们可以将上述重复的代码优化成一个函数,它能够处理任何类型的数组:

function getFirstElement<T>(arr: T[]): T {
    return arr[0];
}

这里的 <T> 就是我们定义的泛型参数,T 代表任意类型。当我们调用这个函数时,T 的类型会根据我们传递的数组类型自动推断出来。比如:

const num = getFirstElement([1, 2, 3]); // T 为 number,返回 1
const str = getFirstElement(["a", "b", "c"]); // T 为 string,返回 "a"
const bool = getFirstElement([true, false, true]); // T 为 boolean,返回 true

通过泛型,我们写一次函数,就能适应不同的类型,避免了重复的代码,还能确保类型安全。

1.3 泛型的优势

泛型的优势在于:

  1. 减少代码冗余:通过泛型,我们可以编写一次函数,就能够适应多种数据类型,避免了重复代码。
  2. 类型安全:泛型允许我们在编译时检查类型,确保数据传递和返回的一致性,减少了类型错误的风险。
  3. 提升代码复用性:泛型使得代码更加灵活,能够处理不同类型的数据,极大地提升了代码的复用性。

这种灵活的机制让我们在 TypeScript 开发中能够更加高效地编写通用函数,尤其是在处理复杂数据结构时,泛型显得尤为重要。


二、泛型在函数中的应用

在实际的前端开发中,常常需要处理多种不同类型的数据和对象属性。使用泛型不仅可以提高代码的复用性,还能保证类型安全。我们可以通过泛型封装通用函数,处理各种类型的对象和属性,减少重复代码。

2.1 泛型处理对象的属性

从对象中提取某个属性的值我们可以通过结合 泛型 和 keyof,可以确保我们访问的属性是对象中有效的键,并且返回的值有正确的类型。

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

在这个函数中,T 代表对象的类型,K 代表对象的键,K 必须是 T 的键之一(通过 keyof T 限定)。T[K] 表示对象中 K 键对应的值的类型。

const user = { name: "John", age: 30, isAdmin: true };

const userName = getProperty(user, "name"); // 推断返回类型为 string
const userAge = getProperty(user, "age"); // 推断返回类型为 number
const isAdmin = getProperty(user, "isAdmin"); // 推断返回类型为 boolean

如果尝试访问不存在的属性,编译器会报错:

// getProperty(user, "address"); // Error: 'address' 不存在于类型 'user' 中

这能极大程度上避免潜在的错误。因为在 JavaScript 中,访问不存在的属性通常不会直接导致错误,只会返回 undefined,这在开发中可能会引发一些意想不到的问题。

2.2 泛型和 keyof 的结合应用

我们可以结合泛型和 keyof 来创建一个更新对象属性值的函数,从而确保更新的属性和值是类型安全的。

/**
 * 更新对象的指定属性值
 *
 * @template T - 对象的类型
 * @template K - 对象中键的类型(键必须是对象中的某个属性)
 * @param {T} obj - 需要更新的对象
 * @param {K} key - 要更新的属性键,必须是对象的一个有效属性
 * @param {T[K]} value - 设置的新值,类型必须与属性键的值类型一致
 * 
 * @example
 * const product = { id: 1, name: "Laptop", price: 1200 };
 * setProperty(product, "price", 1300); // 更新 price 属性为 1300
 * 
 * // 错误示例:传入错误的类型
 * // setProperty(product, "price", "1300"); // Error: 类型 'string' 不能赋值给类型 'number'
 * 
 * // 错误示例:传入不存在的属性
 * // setProperty(product, "discount", 10); // Error: 'discount' 不存在于类型 'product' 中
 */
function setProperty<T, K extends keyof T>(obj: T, key: K, value: T[K]): void {
    obj[key] = value;
}

这个函数的主要作用是允许我们安全地更新对象的属性值,并确保传入的值类型和该属性的类型相匹配。

在没有泛型的情况下,我们可能会失去这种类型检查,容易出错。而通过泛型,我们不仅可以保证 key 必须是对象中存在的键,还可以保证传入的 value 的类型正确。

image.png

即使没有 JSDoc 注释,单凭 TypeScript 泛型和类型推断,编译器依然能够提供非常友好的提示。

当你将鼠标悬停在 setProperty 函数上时,会提示你这个函数接受两个泛型参数 TK。而 K 被约束为 keyof T,即它必须是 T 类型对象的属性。这帮助你快速了解这个函数的用途和使用场景。

2.3 泛型在复杂数据结构中的应用

当我们处理复杂的嵌套数据结构时,泛型可以帮助我们避免推断错误,并保持代码的灵活性。比如处理从 API 返回的 JSON 数据。

interface ApiResponse<T> {
    data: T;
    status: number;
    error?: string;
}

function handleApiResponse<T>(response: ApiResponse<T>): T | null {
    if (response.status === 200) {
        return response.data;
    } else {
        console.error(response.error);
        return null;
    }
}

// 使用泛型处理不同类型的 API 响应
const userResponse: ApiResponse<{ name: string; age: number }> = {
    data: { name: "Alice", age: 25 },
    status: 200,
};

const userData = handleApiResponse(userResponse); // userData 类型为 { name: string; age: number }

三、泛型在类与接口中的应用

在前端开发中,除了函数,泛型在类和接口中的应用也非常常见。

我们通常需要编写一些通用的组件、数据结构或工具类,而使用泛型可以让这些类和接口适用于各种不同的类型,避免重复代码并提高代码的复用性和灵活性。

3.1 泛型在接口中的应用

在项目中,我们经常需要处理键值对这样的结构。传统的做法可能是这样写:

interface KeyValuePair {
    key: string;
    value: number | string;
}

这个接口定义了 keystring 类型,而 value 可以是 numberstring。虽然能应对一些场景,但这有几个问题:

  • 灵活性不足:当 value 需要是其他类型(例如 booleanDate)时,这个接口就无法满足需求。
  • 类型安全:不同 key 可能有特定的 value 类型,但使用 number | string 的联合类型不能很好地约束这一点。

为了让接口更加灵活和类型安全,我们可以使用泛型:

interface KeyValuePair<K, V> {
    key: K;
    value: V;
}

这里的 KV 是泛型参数,表示 keyvalue 的类型由使用者在使用时指定。例如:

const stringPair: KeyValuePair<string, string> = { key: "name", value: "Alice" };
const numberPair: KeyValuePair<string, number> = { key: "age", value: 30 };

通过这种方式,KeyValuePair 可以适应任何类型的数据结构,增强了接口的复用性。

3.2 泛型在类中的应用

泛型在类中的应用类似于接口,能够让类处理不同类型的数据。在前端开发中,我们可能需要编写一些工具类来存储和处理不同类型的数据,例如一个简单的 Stack 类(栈):

class Stack<T> {
    private items: T[] = [];

    push(item: T): void {
        this.items.push(item);
    }

    pop(): T | undefined {
        return this.items.pop();
    }

    peek(): T | undefined {
        return this.items[this.items.length - 1];
    }
}

在这个 Stack 类中,T 是泛型参数,表示栈中元素的类型。通过使用泛型,我们可以让栈存储任何类型的元素:

const numberStack = new Stack<number>();
numberStack.push(10);
numberStack.push(20);
console.log(numberStack.pop()); // 输出 20

const stringStack = new Stack<string>();
stringStack.push("hello");
stringStack.push("world");
console.log(stringStack.pop()); // 输出 "world"

这样,无论是数字栈还是字符串栈,Stack 类都能很好地适应,并且保持了类型安全。

四、泛型在 TypeScript 中的高级应用

4.1 条件类型与泛型结合

条件类型可以与泛型结合使用,以进行类型推导。这种方式在处理复杂数据结构时非常实用。

例如,我们可以创建一个类型,用于判断传入的类型是否为数组,并根据这个类型定义函数的行为:

// 条件类型判断传入类型是否为数组
type IsArray<T> = T extends Array<any> ? true : false;

// 使用示例
const isArrayCheck1: IsArray<number[]> = true;   // 正确,类型为 true
const isArrayCheck2: IsArray<string> = false;     // 正确,类型为 false

// 实际应用:根据传入的参数类型不同执行不同的处理
function handleInput<T>(input: T): void {
    if (Array.isArray(input)) {
        console.log("处理数组:", input);
    } else {
        console.log("处理单个值:", input);
    }
}

// 使用示例
handleInput([1, 2, 3]); // 输出: 处理数组: [1, 2, 3]
handleInput("Hello");   // 输出: 处理单个值: Hello

4.2 映射类型

使用泛型创建映射类型可以方便地将一个类型的所有属性变为可选或只读,这在构建复杂数据结构时非常有用。

例如,我们可以创建一个 Partial 类型,使得传入的对象的属性都是可选的:

// 自定义 Partial 类型,使所有属性变为可选
type Partial<T> = {
    [P in keyof T]?: T[P];
};

interface User {
    name: string;
    age: number;
    email: string;
}

// 使用 Partial 进行可选属性的更新
function updateUser(id: number, updates: Partial<User>): void {
    // 假设在这里我们更新用户
    console.log(`更新用户 ID ${id} 的信息:`, updates);
}

// 实际应用:更新用户信息,只传入需要更新的属性
updateUser(1, { age: 30 }); // 只更新年龄
updateUser(2, { name: "Bob", email: "bob@example.com" }); // 更新姓名和邮箱

五、总结

TypeScript 泛型是一种强大的工具,通过定义类型参数,提升了代码的灵活性和可重用性,确保类型安全,适用于不同类型的数据结构。结合 keyof 等特性,泛型使得类型定义更加动态和灵活,帮助开发者编写高可读性和高可维护性的代码。