方向三:TypeScript 类、泛型的使用实践记录 | 豆包MarsCode AI刷题

3 阅读20分钟

TypeScript 类、泛型的使用实践记录

在现代前端开发以及众多基于 TypeScript 的应用开发场景中,类与泛型犹如两把利刃,能够助力开发者打造出高质量、易维护且极具灵活性与安全性的代码。接下来,我们将深入且细致地探讨 TypeScript 中泛型的使用方法、多样化的应用场景,以及类型约束在其中所扮演的关键角色,进一步挖掘它们为开发带来的巨大价值。

一、泛型的基本概念与使用方法

泛型作为 TypeScript 中一项极具特色的特性,赋予了代码一种通用性的魔力,使其能够在不同类型的数据处理场景中自如切换且保持正确的类型校验。从本质上讲,泛型允许我们在定义函数、类或者接口时,将其中涉及的类型参数化,而不是一开始就固定为某个具体的类型。 以定义一个简单的泛型函数为例来深入理解。下面这段代码展示了一个最基础的泛型函数 identity

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

在上述代码中,<T>就是泛型类型参数的定义形式,这里的 T 就像是一个占位符,它代表着一种未知的、可以被具体指定的类型。在函数的参数 arg 声明中使用 T,意味着这个参数的类型将由后续调用函数时具体传入的类型来决定,同时函数的返回值类型也同样是这个 T 所代表的类型。 当我们实际调用这个函数的时候,就需要明确地指定 T 所对应的具体类型,如下所示:

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

在第一行代码中,我们通过 <number> 将 T 指定为 number 类型,这就意味着此时函数 identity 的参数 arg 期望接收的是一个数字类型的值,并且返回值也会是数字类型。同样地,在第二行代码里,把 T 确定为 string 类型,函数就会按照处理字符串的逻辑来运作,接收并返回字符串类型的数据。

对于类而言,泛型同样能发挥强大的作用,它可以让类在实例化时根据不同的需求来适配不同类型的数据。比如下面这个简单的用于存储数据的 GenericClass

class GenericClass<T> {
    private data: T;
    constructor(arg: T) {
        this.data = arg;
    }
    getData(): T {
        return this.data;
    }
}

在这个类的定义中,<T> 同样是泛型类型参数。类中有一个私有属性 data,其类型被定义为 T,意味着它的具体类型取决于实例化该类时所传入的类型。构造函数接收一个类型为 T 的参数 arg,并将其赋值给 data 属性,而 getData 方法则用于返回这个存储的 data,其返回值类型也是 T。像这样实例化该类:

let instance1 = new GenericClass<number>(10);
let instance2 = new GenericClass<string>("world");

通过指定不同的类型参数,instance1 中的 data 就存储了数字 10,并且 getData 方法返回的也是数字类型的值;instance2 则相应地存储和返回字符串类型的数据,充分体现了泛型在类定义中带来的灵活性。

二、泛型的应用场景

1. 数据结构操作

在数据结构相关的操作中,泛型的优势展露无遗。无论是数组、链表、栈还是队列等常见的数据结构,泛型都能帮助我们编写更加通用且安全的代码。

例如,在处理数组时,我们常常会遇到需要获取数组中最后一个元素的情况。如果不使用泛型,我们可能需要针对不同类型的数组分别编写函数,这显然会导致代码冗余且不易维护。而借助泛型,我们可以定义一个通用的函数来实现这个功能,如下代码所示:

function getLastElement<T>(arr: T[]): T {
    return arr[arr.length - 1];
}

在这个函数中,参数 arr 的类型被定义为 T[],表示这是一个由 T 类型元素组成的数组。函数的返回值类型同样是 T,意味着无论这个数组中存储的是何种类型的元素(比如数字、字符串或者更复杂的对象类型等),它都能准确地返回最后一个元素,并且返回值的类型与数组元素类型保持一致。我们可以这样使用这个函数:

let numArray = [1, 2, 3];
let lastNum = getLastElement<number>(numArray);

let strArray = ["a", "b", "c"];
let lastStr = getLastElement<string>(strArray);

对于 numArray,通过 <number> 指定 T 为 number 类型,函数就能正确地从数字数组中获取并返回最后一个数字元素。同理,对于 strArray,指定 T 为 string 类型后,函数也能精准地返回最后一个字符串元素,大大提高了代码的复用性和通用性。

2. API 接口开发

在当今的软件开发中,API 接口的开发至关重要,它需要能够兼容各种各样的数据类型,满足不同客户端的使用需求。泛型在这种场景下就成为了开发者的得力助手。

想象一下,我们正在开发一个对外提供服务的 API 接口,它可能会根据不同的查询条件返回不同类型的实体数据,比如查询用户信息时返回用户对象,查询商品信息时返回商品对象等。如果不采用泛型,要准确地定义接口的返回类型会变得十分复杂且缺乏灵活性。

下面是一个简单的示例,展示了如何利用泛型来定义一个 API 接口的响应结构以及查询接口函数:

interface APIResponse<T> {
    code: number;
    message: string;
    data: T;
}

function queryData<T>(url: string): Promise<APIResponse<T>> {
    // 这里模拟异步请求并返回相应类型的数据
    return Promise.resolve({ code: 200, message: "success", data: {} as T });
}

在 APIResponse 接口中,使用泛型 T 来定义 data 属性的类型,这样这个接口就可以根据实际情况灵活地表示包含不同类型数据的响应结果。而 queryData 函数同样使用泛型 T,它返回一个 Promise,这个 Promise 最终解析得到的结果是符合 APIResponse<T> 结构的对象,也就是可以根据不同的查询场景返回相应类型数据的响应结构。通过这种方式,API 接口在面对各种类型的数据交互时,都能保持良好的适应性和可扩展性。

三、类型约束在泛型中的作用

虽然泛型赋予了代码极大的灵活性,但在实际开发中,如果对泛型所代表的类型没有任何限制,很可能会导致一些难以预料的错误,影响代码的安全性和逻辑性。此时,类型约束就闪亮登场了,它像是给泛型戴上了一副 “合适的枷锁”,既能保证其灵活性,又能确保其在合理的范围内发挥作用。

例如,我们要定义一个函数,它的功能是比较两个参数的大小关系。很显然,并不是所有类型的数据都能够进行大小比较操作,像对象、函数等类型就无法直接比较大小。所以,我们需要对这个函数的参数类型进行限制,让它只能接收那些可以进行比较操作的类型,比如数字或者字符串等。在 TypeScript 中,我们可以通过类型约束来实现这一需求,如下代码所示:

function compare<T extends string | number>(a: T, b: T): number {
    if (a > b) {
        return 1;
    } else if (a < b) {
        return -1;
    }
    return 0;
}

在这个 compare 函数的定义中,通过 extends 关键字对泛型 T 进行了类型约束,明确规定 T 必须是 string 或者 number 类型。这样一来,当我们调用这个函数时,传入的参数就必须符合这个类型要求,如果传入其他不恰当的类型(比如对象或者布尔值等),TypeScript 的类型检查机制就会报错,提前避免了可能出现的运行时错误,保证了代码在比较操作时的逻辑正确性。

在类的设计中,类型约束同样有着重要的应用场景。比如我们要创建一个动物类相关的泛型抽象类,并且期望其子类在实例化时必须使用具体的动物类型,就可以通过类型约束来达成目的,且可以避免不符合预期的类型传入而引发的潜在错误。

四、总结

总之,TypeScript 中的泛型以及与之配合的类型约束,为我们编写高质量、灵活且安全的代码提供了强大的工具。在实际的开发过程中,合理运用它们,能够显著提升开发效率和代码的可维护性,让我们可以更加从容地应对各种复杂多变的业务需求。

- 补充:在 TypeScript 中使用泛型时,有一些需要注意的细节:

在 TypeScript 中使用泛型时,有诸多需要仔细留意的细节,它们关乎代码的可读性、灵活性、安全性以及整体的可维护性。以下是一些注意事项:

一、泛型类型参数的命名规范

泛型类型参数的命名虽然看似只是一个代码风格层面的小问题,但实际上对代码的理解和后续维护有着重要影响。通常,我们习惯使用单个大写字母来命名泛型类型参数,其中最为常见的就是 T(代表 Type),它简洁明了地传达出这是一个用于表示某种类型的占位符概念。除此之外,像 UV 等字母也常被使用,它们在存在多个泛型类型参数的复杂场景中,能够依次代表不同的未知类型,方便我们在函数、类或者接口等定义中进行区分。

不过,有时候为了让代码的表意更加清晰准确,尤其是在处理一些特定业务领域相关的复杂类型情况时,也可以选用更具描述性的名称。例如,使用 ElementType 来表示元素的类型,或者 ResponseType 用于指代接口响应的数据类型等。但无论使用哪种命名方式,核心原则都是要在简洁性和表意清晰性之间找到平衡,使得阅读代码的人能够迅速领会这个泛型类型参数所代表的大致含义,避免造成理解上的混淆。

以下是一个简单的示例代码,展示了泛型类型参数命名的常规做法:

function processArray<T>(arr: T[]): T[] {
    // 具体处理逻辑,比如可以对数组元素进行某种转换等操作
    return arr.map((item) => item);
}

在这个 processArray 函数的定义中,使用 T 作为泛型类型参数,简洁地表明它是一种可被替换的类型占位符。对于阅读这段代码的开发者来说,一眼就能明白 T 所代表的类型将决定函数参数 arr 以及返回值的具体类型,而且后续如果需要使用该函数处理不同类型的数组,也能很容易地根据传入的实际数组类型来理解整个函数的运作机制。

二、泛型函数调用时的类型推断与显式指定

  1. 类型推断

TypeScript 编译器具备强大的类型推断能力,在泛型函数调用的场景中,它常常能够依据我们传入的实际参数的类型,自动推断出泛型参数对应的类型。这种类型推断机制在很多简单且直观的情况下,极大地简化了我们编写代码的过程,让代码看起来更加简洁明了。例如,考虑下面这个简单的 identity 函数:

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

let result = identity("hello"); // 编译器能推断出T为string类型

在上述代码中,当我们调用 identity 函数并传入字符串 "hello" 作为实参时,TypeScript 编译器会自动分析这个实参的类型,进而推断出泛型 T 的类型就是 string。在这种情况下,我们无需显式地写成 identity<string>("hello") 这样的形式来指定泛型类型,编译器已经能够准确知晓 T 的类型并确保函数内部的类型处理是正确的。这使得代码更加简洁,符合简洁高效的编程习惯,尤其在一些小型项目或者简单函数调用的场景中,能够提升编码的效率。

然而,需要注意的是,类型推断并非在所有复杂场景下都能完美地准确判断泛型类型,它是基于一定的规则和对代码上下文的分析来进行的。比如,当函数的参数类型存在多种可能性或者代码逻辑较为复杂时,可能就需要我们进一步明确地指定泛型类型了。

  1. 显式指定

在一些复杂或者不明确的情况下,显式指定泛型类型就变得十分必要了。例如,当一个函数有多个不同的泛型类型参数,并且这些参数之间的类型关系不能通过简单的传入实参来清晰推断时,就需要我们手动通过特定的语法来明确告知编译器每个泛型类型参数的具体类型。以下是一个展示显式指定泛型类型的示例:

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

let combined = combine<number, string>(1, "one"); // 显式指定T为number,U为string

在这个 combine 函数中,它接受两个不同的参数 a 和 b,分别对应两个泛型类型参数 T 和 U。由于这两个参数的类型可以是任意不同的类型,仅仅通过传入 1 和 "one" 这两个实参,编译器很难准确判断出 T 和 U 具体应该对应什么类型。所以,我们需要显式地使用 <number, string> 这样的语法,明确地告诉编译器 T 的类型是 numberU 的类型是 string,这样函数才能按照我们期望的类型规则进行后续的操作,比如正确地构建并返回包含对应类型元素的数组 [T, U]

这种显式指定泛型类型的方式,虽然在代码书写上相对更繁琐一些,但它提供了精确控制类型的手段,确保了在复杂的函数调用场景下,代码依然能够遵循严格的类型规范,避免因类型推断不准确而可能引发的潜在错误。

三、泛型约束的合理使用

  1. 避免过度约束

在为泛型添加类型约束时,要特别谨慎,防止出现过度约束的情况。泛型的一大优势就在于它能够让代码具备处理多种不同类型数据的灵活性,而过度的类型约束可能会严重削弱这一优势,使得代码变得僵化,难以适应后续业务需求的变化。

例如,想象我们有一个函数,当前的主要业务场景是处理数字和字符串的相加操作,于是我们可能会这样定义带有泛型约束的函数:

function add<T extends number | string>(a: T, b: T): T {
    // 代码假设T只能是number或string进行相加操作
    return (a as any + b as any) as T;
}

在这个函数中,通过 extends 关键字将泛型 T 约束为只能是 number 或者 string 类型,在当前只涉及这两种类型相加的业务场景下,似乎是合理的。然而,如果后续业务进行了扩展,比如需要处理数组元素的拼接(数组的相加操作本质上是拼接元素)或者其他可相加的自定义类型数据时,这个函数就会因为过于严格的类型约束而无法直接复用,需要对代码进行较大幅度的修改甚至重写。

所以,在添加类型约束时,我们应该基于对业务的合理预期范围来进行权衡,既要考虑当前的主要业务需求,也要预留一定的灵活性以应对可能出现的拓展情况,避免因为一时的便利而过度限制了泛型的通用性。

  1. 确保必要约束

与避免过度约束相反,我们也要时刻留意确保代码中存在必要的类型约束,不然很可能会导致代码出现类型错误或者逻辑问题,尤其是在涉及一些具有特定操作要求的函数或类中。

例如,当我们定义一个函数,其功能是比较两个值的大小,如果不对泛型做能比较大小的类型约束,像允许传入任何类型的参数,那么一旦传入对象、函数等不可比较大小的类型作为参数,在函数执行比较操作时就必然会引发运行时错误,破坏整个程序的正常逻辑。下面这个示例就展示了如何通过合理的类型约束来保障函数逻辑的正确性:

function compare<T extends number | string>(a: T, b: T): number {
    if (a > b) {
        return 1;
    } else if (a < b) {
        return -1;
    }
    return 0;
}

在 compare 函数的定义中,通过 extends 关键字对泛型 T 进行了类型约束,明确规定 T 必须是 number 或者 string 类型。这样一来,当我们调用这个函数时,TypeScript 的类型检查机制会确保传入的参数符合这个类型要求,只有数字或者字符串类型的数据才能被传入,从而提前避免了因传入不恰当类型而可能导致的运行时错误,使得函数在执行比较操作时能够按照预期的逻辑顺利进行,保障了代码的稳定性和正确性。

四、泛型在类中的应用细节

  1. 构造函数与泛型类型参数的关联

在类的构造函数中使用泛型时,要格外关注传入的参数类型与定义的泛型类型参数之间的一致性。泛型类型参数在类中定义了一种通用的类型规则,而构造函数作为创建类实例的关键环节,其接收的参数类型必须严格遵循这个规则。例如,下面这个简单的 GenericClass

class GenericClass<T> {
    private data: T;
    constructor(arg: T) {
        this.data = arg;
    }
    getData(): T {
        return this.data;
    }
}

let instance = new GenericClass<number>(10); // 构造函数传入的参数类型需匹配泛型指定的number类型

在这个类的定义中,泛型类型参数 T 用于确定类中 data 属性的类型,构造函数接收一个类型为 T 的参数 arg,并将其赋值给 data 属性。当我们实例化这个类时,通过 <number> 指定了 T 为 number 类型,那么此时传入构造函数的参数就必须是数字类型的值,如代码中的 10。如果传入其他不符合 number 类型的参数,TypeScript 的类型检查机制就会报错,提示参数类型不匹配。

此外,在类的其他方法中,对于基于泛型类型的属性进行操作时,同样也要严格符合相应的类型规则。比如 getData 方法返回的类型为 T,它必须与构造函数传入参数所确定的 T 的类型保持一致,确保整个类在处理不同类型数据时,其内部的类型逻辑是严谨且正确的。

  1. 继承中的泛型处理

当涉及类的继承并且父类使用泛型时,子类在继承过程中需要妥善处理泛型相关的问题,以保证类型的正确使用和整个继承体系的逻辑完整性。

一方面,子类要么明确指定泛型类型(如果父类的泛型未被具体化)。例如:

abstract class Animal<T extends { name: string }> {
    constructor(protected animal: T) {}
    abstract makeSound(): void;
}

class Dog {
    name = "Dog";
    bark() {
        console.log("Woof!");
    }
}

class DogAnimal extends Animal<Dog> {
    makeSound() {
        this.animal.bark();
    }
}

在这个例子中,抽象类 Animal 使用了泛型 T,并对 T 进行了类型约束,要求 T 必须是包含 name 属性(类型为 string)的对象类型。子类 DogAnimal 在继承 Animal 时,按照父类泛型的要求传入了符合约束的 Dog 类型,这样就明确了父类中泛型 T 在子类中的具体类型,使得子类能够基于这个具体的动物类型(Dog 类型)来实现相应的行为,比如在 makeSound 方法中调用 Dog 类的 bark 方法来发出声音。

另一方面,如果子类没有显式指定泛型类型,那么就需要遵循父类泛型的约束规则等,确保在子类的实现中,涉及泛型相关的属性、方法等的类型处理都符合父类所设定的规范,避免出现类型不一致或者违反约束条件的情况,从而保证整个类继承结构在类型层面的稳定性和正确性。

五、泛型与接口的配合使用

  1. 接口中泛型的灵活性体现

接口作为 TypeScript 中定义对象结构和行为规范的重要工具,与泛型配合使用时能够展现出极大的灵活性。通过在接口中使用泛型,我们可以定义出通用的接口结构,使其能够方便地适配多种不同类型的具体实现,满足各种复杂多变的业务需求。

  1. 接口实现中的泛型匹配

当类去实现带有泛型的接口时,需要精准地处理好泛型类型的对应关系,确保类中定义的方法等的类型与接口要求完全一致,这是保证代码符合接口规范以及类型安全的关键所在。以下是一个展示接口实现中泛型匹配的示例:

interface GenericStorage<T> {
    add(item: T): void;
    get(): T[];
}

class ArrayStorage<T> implements GenericStorage<T> {
    private storage: T[] = [];
    add(item: T): void {
        this.storage.push(item);
    }
    get(): T[] {
        return this.storage;
    }
}

在这个例子中,GenericStorage 接口定义了两个方法 add 和 get,它们都涉及泛型类型 T,分别规定了添加元素的类型以及获取元素列表的返回类型。类 ArrayStorage 在实现这个接口时,同样使用了泛型 T,并且在实现 add 方法和 get 方法时,严格按照接口中对 T 的类型要求进行处理。例如,add 方法接收一个类型为 T 的参数 item,并将其添加到类型为 T[] 的 storage 数组中,get 方法则返回这个 T[] 类型的 storage 数组,从而保证了类 ArrayStorage 完全符合 GenericStorage 接口所定义的类型规范,实现了接口中规定的功能,同时也确保了代码在类型层面的正确性和一致性。

总之,在使用 TypeScript 泛型时,充分考虑上述这些细节,能够帮助我们编写出更加健壮、灵活且符合类型安全要求的代码,让我们在面对复杂多变的业务需求以及不断演进的项目时,能够更加从容地进行代码的开发、维护和扩展。