MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

如何在Typescript中使用泛型

2021-09-213.3k 阅读

泛型基础概念

在TypeScript中,泛型是一种强大的工具,它允许我们在定义函数、接口、类时不预先指定具体的类型,而是在使用时再确定类型。这提供了一种更灵活、可复用的方式来编写代码。

简单来说,泛型就像是一个类型的占位符。比如我们定义一个函数,它可以接受不同类型的参数并返回相同类型的值,这时就可以使用泛型。

function identity<T>(arg: T): T {
    return arg;
}

在上述代码中,<T> 就是泛型参数,T 在这里是一个类型变量,可以代表任何类型。arg 参数的类型是 T,返回值的类型也是 T。这意味着无论传入什么类型的值,函数都会返回相同类型的值。

我们可以这样使用这个函数:

let result1 = identity<number>(5); 
let result2 = identity<string>("hello"); 

这里分别指定 Tnumberstring 类型。也可以让TypeScript自动推断类型:

let result3 = identity(10); 
let result4 = identity("world"); 

在这种情况下,TypeScript会根据传入的参数自动推断 T 的类型。

泛型函数的类型参数约束

有时候,我们希望对泛型类型进行一些约束,以确保类型满足某些条件。例如,我们希望传入的类型必须有某个特定的属性。

假设我们有一个函数,它接收两个具有 length 属性的对象,并返回长度较大的那个。我们可以这样定义:

interface Lengthwise {
    length: number;
}

function compareLength<T extends Lengthwise>(a: T, b: T): T {
    return a.length > b.length? a : b;
}

let result5 = compareLength({ length: 5 }, { length: 3 }); 

在上述代码中,T extends Lengthwise 表示 T 必须是 Lengthwise 接口类型或其子类型,这样就确保了 ab 都有 length 属性。

多个类型参数

泛型函数可以有多个类型参数。例如,我们定义一个函数,它接收两个不同类型的参数,并返回一个包含这两个参数的数组:

function combine<T, U>(a: T, b: U): [T, U] {
    return [a, b];
}

let result6 = combine<number, string>(5, "hello"); 

这里 <T, U> 表示有两个类型参数 TUa 的类型是 Tb 的类型是 U,返回值是一个包含 TU 类型元素的数组。

泛型接口

我们不仅可以在函数中使用泛型,还可以在接口中使用。定义一个泛型接口,用于表示一个具有 id 属性和 value 属性的对象,value 的类型可以是任意类型:

interface GenericObject<T> {
    id: number;
    value: T;
}

let obj1: GenericObject<string> = { id: 1, value: "abc" }; 
let obj2: GenericObject<number> = { id: 2, value: 123 }; 

在上述代码中,通过 <T> 定义了泛型接口 GenericObject,在使用时分别指定 Tstringnumber 类型。

泛型接口与函数

泛型接口也可以用于描述函数类型。例如,我们定义一个泛型接口来描述一个函数,该函数接收一个类型为 T 的参数并返回一个类型为 U 的值:

interface GenericFunction<T, U> {
    (arg: T): U;
}

function transform<T, U>(arg: T, func: GenericFunction<T, U>): U {
    return func(arg);
}

function addOne(num: number): number {
    return num + 1;
}

let result7 = transform(5, addOne); 

在上述代码中,GenericFunction 是一个泛型接口描述的函数类型,transform 函数接收一个参数 arg 和一个符合 GenericFunction 接口的函数 func,并返回 func 作用于 arg 的结果。

泛型类

与泛型函数和泛型接口类似,我们也可以定义泛型类。泛型类在处理一些数据结构时非常有用,比如链表、栈、队列等。

下面定义一个简单的泛型栈类:

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

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

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

let stack1 = new Stack<number>();
stack1.push(1);
stack1.push(2);
let popped1 = stack1.pop(); 

在上述代码中,Stack 类是一个泛型类,<T> 表示栈中元素的类型。push 方法用于将元素压入栈,pop 方法用于从栈中弹出元素。

泛型类的类型参数约束

与泛型函数一样,泛型类也可以对类型参数进行约束。例如,我们定义一个泛型类,要求类型参数必须有 toString 方法:

interface Stringifyable {
    toString(): string;
}

class Printer<T extends Stringifyable> {
    print(item: T) {
        console.log(item.toString());
    }
}

class MyClass implements Stringifyable {
    value: number;
    constructor(value: number) {
        this.value = value;
    }
    toString(): string {
        return `MyClass value: ${this.value}`;
    }
}

let printer1 = new Printer<MyClass>();
let myObj = new MyClass(10);
printer1.print(myObj); 

在上述代码中,Printer 类的类型参数 T 必须是 Stringifyable 接口类型或其子类型,这样就确保了 print 方法中可以调用 item.toString()

泛型约束与类型参数的关系

当我们在泛型函数或类中对类型参数进行约束时,这个约束会影响到代码中对该类型参数的使用。

例如,我们有一个泛型函数,要求类型参数必须是数组类型,并且数组元素必须是 number 类型:

function sumArray<T extends number[]>(arr: T): number {
    return arr.reduce((acc, cur) => acc + cur, 0);
}

let numArray: number[] = [1, 2, 3];
let result8 = sumArray(numArray); 

这里 T extends number[] 约束了 T 必须是 number 类型元素的数组,所以在函数内部可以安全地对数组元素进行数字相加操作。

泛型类型别名

除了泛型接口和泛型类,我们还可以使用类型别名来定义泛型。类型别名提供了一种更简洁的方式来定义泛型类型。

例如,我们定义一个泛型类型别名表示一个函数,该函数接收两个相同类型的参数并返回该类型的值:

type BinaryOperation<T> = (a: T, b: T) => T;

function addNumbers: BinaryOperation<number> = (a, b) => a + b;
function concatenateStrings: BinaryOperation<string> = (a, b) => a + b;

在上述代码中,BinaryOperation 是一个泛型类型别名,通过指定 T 的具体类型,我们可以创建不同类型的函数。

泛型与函数重载

在TypeScript中,泛型可以与函数重载结合使用。函数重载允许我们为同一个函数定义多个不同的签名,根据传入参数的类型和数量来决定调用哪个实现。

例如,我们定义一个 printValue 函数,它可以打印字符串、数字或者数组:

function printValue(value: string): void;
function printValue(value: number): void;
function printValue<T>(value: T[]): void;
function printValue(value: any): void {
    if (Array.isArray(value)) {
        console.log(`Array: ${value.join(', ')}`);
    } else {
        console.log(`Value: ${value}`);
    }
}

printValue("hello"); 
printValue(10); 
printValue([1, 2, 3]); 

在上述代码中,前三个函数定义是函数重载的签名,最后一个函数是实际的实现。通过这种方式,我们可以根据不同的参数类型提供不同的行为,同时利用泛型来处理数组类型的参数。

泛型在模块中的使用

在TypeScript模块中,泛型同样可以发挥重要作用。我们可以在模块中定义泛型函数、接口、类,并在其他模块中使用。

例如,我们在一个 utils.ts 模块中定义一个泛型函数:

// utils.ts
export function getFirst<T>(arr: T[]): T | undefined {
    return arr.length > 0? arr[0] : undefined;
}

然后在另一个模块中导入并使用这个函数:

// main.ts
import { getFirst } from './utils';

let numArr = [1, 2, 3];
let firstNum = getFirst(numArr); 

let strArr = ["a", "b", "c"];
let firstStr = getFirst(strArr); 

这样通过泛型,getFirst 函数可以适用于不同类型的数组,在模块间实现了代码的复用。

泛型的高级特性:条件类型

条件类型是TypeScript 2.8引入的一个强大的泛型特性。它允许我们根据类型关系来选择不同的类型。

语法上,条件类型使用 T extends U? X : Y 的形式,意思是如果 TU 的子类型,则返回 X 类型,否则返回 Y 类型。

例如,我们定义一个类型别名 IsString,用于判断一个类型是否为 string 类型:

type IsString<T> = T extends string? true : false;

type Result1 = IsString<string>; 
type Result2 = IsString<number>; 

在上述代码中,Result1 的类型是 trueResult2 的类型是 false

条件类型还可以与其他泛型特性结合使用。比如,我们定义一个 ElementType 类型别名,用于获取数组元素的类型:

type ElementType<T> = T extends Array<infer U>? U : never;

type NumArrayElementType = ElementType<number[]>; 
type StrArrayElementType = ElementType<string[]>; 
type NotArrayElementType = ElementType<number>; 

这里 infer 关键字用于在条件类型中推断类型。ElementType 类型别名判断 T 是否为数组类型,如果是,则推断出数组元素的类型 U 并返回,否则返回 never 类型。

泛型的高级特性:映射类型

映射类型是另一个强大的泛型特性,它允许我们基于现有类型创建新类型。我们可以通过对现有类型的属性进行遍历和转换来生成新类型。

例如,我们有一个接口 User

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

现在我们想创建一个新类型,这个类型的所有属性都是可选的。我们可以使用映射类型来实现:

type OptionalUser = {
    [P in keyof User]?: User[P];
};

在上述代码中,keyof User 获取 User 接口的所有属性名,P in keyof User 遍历这些属性名,[P]?: User[P] 表示创建一个新属性,属性名是 P,属性类型是 User 接口中 P 属性的类型,并且这个属性是可选的。

我们还可以对属性类型进行转换。比如,将所有属性类型转换为 string 类型:

type StringifyUser = {
    [P in keyof User]: string;
};

这样 StringifyUser 类型的所有属性都是 string 类型。

泛型的高级特性:索引类型

索引类型也是TypeScript泛型的一个重要特性。它允许我们通过索引来访问对象的属性类型。

例如,我们有一个对象和一个函数,函数接收一个属性名,我们希望根据属性名获取对象中该属性的类型:

const user = {
    name: "John",
    age: 30,
    email: "john@example.com"
};

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

let name = getProperty(user, "name"); 
let age = getProperty(user, "age"); 

在上述代码中,keyof T 获取 T 类型对象的所有属性名,K extends keyof T 确保 KT 对象属性名的子集。T[K] 根据属性名 K 获取 T 对象中该属性的类型。

泛型的实际应用场景

  1. 数据结构实现:如前面提到的栈、队列、链表等数据结构的实现,泛型可以让这些数据结构适用于不同类型的数据。
  2. 函数库开发:在开发一些通用的函数库时,泛型可以提高函数的复用性。比如数组操作函数库,其中的函数可以处理不同类型的数组。
  3. 组件库开发:在前端开发中,使用React、Vue等框架开发组件库时,泛型可以让组件更加灵活,适应不同的数据类型和业务需求。例如,一个表格组件可以通过泛型来支持不同类型的数据展示。

通过深入理解和掌握TypeScript中的泛型,我们可以编写出更灵活、可复用、类型安全的代码,提高开发效率和代码质量。在实际开发中,要根据具体的需求合理运用泛型的各种特性,不断优化代码结构。