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

巧用TypeScript泛型实现代码复用

2021-02-036.3k 阅读

一、泛型基础概念

在深入探讨如何巧用 TypeScript 泛型实现代码复用之前,我们先来了解一下泛型的基本概念。泛型是一种参数化类型的机制,它允许我们在定义函数、类或接口时,不指定具体的类型,而是在使用时再确定类型。这种灵活性使得我们能够编写可复用的代码,同时保持类型安全。

简单来说,泛型就像是一个类型的占位符。以一个简单的函数为例,假设我们想要编写一个函数,它可以接受任何类型的参数并返回该参数。在普通的 JavaScript 中,我们可能会这样写:

function identity(arg) {
    return arg;
}

这个函数可以接受任何类型的参数并返回它,但这样做没有类型检查,很容易在后续使用中出现类型错误。而在 TypeScript 中,我们可以使用泛型来解决这个问题:

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

这里的 <T> 就是泛型参数,T 可以被看作是一个类型变量,它代表了将来会被指定的具体类型。当我们调用这个函数时,可以显式地指定类型参数:

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

或者让 TypeScript 进行类型推断:

let result2 = identity(10);

在上述代码中,result1 的类型被推断为 stringresult2 的类型被推断为 number。通过使用泛型,我们在保持代码灵活性的同时,也保证了类型安全。

二、泛型函数

2.1 泛型函数的定义与使用

泛型函数是使用泛型最常见的场景之一。除了前面提到的简单的 identity 函数,我们来看一些更复杂的例子。比如,我们想要编写一个函数,它可以接受一个数组,并返回数组中的第一个元素。

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

这个函数接受一个泛型类型 T 的数组,并返回 T 类型的元素或者 undefined。我们可以这样使用它:

let numbers = [1, 2, 3];
let firstNumber = getFirst(numbers);

let strings = ["a", "b", "c"];
let firstString = getFirst(strings);

在这个例子中,getFirst 函数可以适用于任何类型的数组,而不需要为每种类型的数组都编写一个单独的函数。这大大提高了代码的复用性。

2.2 泛型函数的多个类型参数

有时候,一个函数可能需要多个类型参数。例如,我们想要编写一个函数,它接受两个不同类型的参数,并返回一个包含这两个参数的元组。

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

这里我们定义了两个泛型参数 TU,分别代表两个参数的类型。使用时可以这样:

let combined1 = combine(1, "hello");
let combined2 = combine(true, { name: "John" });

combined1 中,T 被推断为 numberU 被推断为 string;在 combined2 中,T 被推断为 booleanU 被推断为 { name: string }

2.3 泛型函数的约束

在某些情况下,我们可能需要对泛型参数进行约束,以确保它们具有某些特定的属性或方法。比如,我们想要编写一个函数,它接受一个对象和一个属性名,返回该对象中指定属性的值。

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

这里使用了 K extends keyof T 来约束 K 必须是 T 对象的键。这样可以保证我们在获取属性值时不会出现类型错误。使用示例如下:

let person = { name: "Alice", age: 30 };
let name = getProperty(person, "name");
let age = getProperty(person, "age");
// 以下代码会报错,因为 "gender" 不是 person 对象的键
// let gender = getProperty(person, "gender");

三、泛型接口

3.1 定义泛型接口

泛型接口与普通接口类似,只不过它可以包含泛型参数。例如,我们定义一个简单的泛型接口来表示一个带有数据和状态的对象。

interface DataWithStatus<T> {
    data: T;
    status: string;
}

这里的 <T> 是泛型参数,代表数据的类型。我们可以使用这个接口来定义不同类型的对象:

let numberData: DataWithStatus<number> = { data: 42, status: "success" };
let stringData: DataWithStatus<string> = { data: "hello", status: "loading" };

3.2 泛型接口的函数类型

泛型接口也可以用来定义函数类型。比如,我们定义一个泛型接口来表示一个可以比较两个值的函数。

interface Comparator<T> {
    (a: T, b: T): boolean;
}

这个接口定义了一个函数类型,它接受两个相同类型 T 的参数,并返回一个 boolean 值。我们可以实现这个接口来创建具体的比较函数:

let numberComparator: Comparator<number> = (a, b) => a === b;
let stringComparator: Comparator<string> = (a, b) => a === b;

3.3 泛型接口的继承

泛型接口之间也可以继承。例如,我们有一个基本的泛型接口 BaseData<T>,然后定义一个继承自它的 EnhancedData<T> 接口,增加一些额外的属性。

interface BaseData<T> {
    value: T;
}

interface EnhancedData<T> extends BaseData<T> {
    description: string;
}

我们可以这样使用:

let baseNumberData: BaseData<number> = { value: 10 };
let enhancedNumberData: EnhancedData<number> = { value: 20, description: "A number" };

四、泛型类

4.1 定义泛型类

泛型类允许我们在类的定义中使用泛型参数,使得类可以处理不同类型的数据。例如,我们定义一个简单的泛型类 Box,它可以存储任意类型的值。

class Box<T> {
    private value: T;
    constructor(value: T) {
        this.value = value;
    }
    getValue(): T {
        return this.value;
    }
}

我们可以创建不同类型的 Box 实例:

let numberBox = new Box(10);
let stringBox = new Box("hello");

4.2 泛型类的静态成员

需要注意的是,泛型类的静态成员不能使用类的泛型参数。因为静态成员是属于类本身,而不是类的实例,在类被定义时,泛型参数还没有被确定。例如:

class StaticBox<T> {
    static defaultValue: T; // 这会报错,静态成员不能使用类的泛型参数
    value: T;
    constructor(value: T) {
        this.value = value;
    }
    static getDefaultValue(): T { // 这也会报错
        return this.defaultValue;
    }
}

如果我们需要在静态成员中使用泛型,我们可以将泛型参数定义在静态方法或属性本身:

class StaticBox<T> {
    value: T;
    constructor(value: T) {
        this.value = value;
    }
    static getDefaultValue<U>(): U {
        return null as unknown as U; // 这里只是示例,实际应用中应返回合适的默认值
    }
}

4.3 泛型类的继承

泛型类也可以继承其他类,无论是泛型类还是非泛型类。例如,我们有一个非泛型的基类 BaseObject,然后定义一个泛型类 DerivedObject<T> 继承自它。

class BaseObject {
    id: number;
    constructor(id: number) {
        this.id = id;
    }
}

class DerivedObject<T> extends BaseObject {
    data: T;
    constructor(id: number, data: T) {
        super(id);
        this.data = data;
    }
}

我们可以这样使用:

let derivedNumberObject = new DerivedObject(1, 10);
let derivedStringObject = new DerivedObject(2, "hello");

五、泛型在实际项目中的应用场景

5.1 数据存储与操作

在实际项目中,我们经常需要处理不同类型的数据存储和操作。例如,我们可能有一个通用的 Cache 类,它可以缓存不同类型的数据。

class Cache<T> {
    private data: Map<string, T> = new Map();
    set(key: string, value: T) {
        this.data.set(key, value);
    }
    get(key: string): T | undefined {
        return this.data.get(key);
    }
}

我们可以使用这个 Cache 类来缓存不同类型的数据:

let numberCache = new Cache<number>();
numberCache.set("num1", 10);
let num = numberCache.get("num1");

let stringCache = new Cache<string>();
stringCache.set("str1", "hello");
let str = stringCache.get("str1");

5.2 网络请求与响应处理

在处理网络请求时,不同的 API 可能返回不同类型的数据。我们可以使用泛型来处理这种情况。例如,我们有一个 HttpClient 类,它可以发送不同类型的请求并处理响应。

class HttpClient {
    async get<T>(url: string): Promise<T> {
        const response = await fetch(url);
        return response.json() as Promise<T>;
    }
}

假设我们有一个 API 返回用户数据,类型为 User

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

let httpClient = new HttpClient();
httpClient.get<User>("/api/user").then(user => {
    console.log(user.name);
    console.log(user.age);
});

5.3 组件库开发

在开发组件库时,泛型非常有用。例如,我们有一个 List 组件,它可以展示不同类型的数据列表。

interface ListProps<T> {
    items: T[];
    renderItem: (item: T) => JSX.Element;
}

const List = <T>(props: ListProps<T>) => {
    return (
        <ul>
            {props.items.map(item => (
                <li key={Math.random().toString()}>{props.renderItem(item)}</li>
            ))}
        </ul>
    );
};

我们可以这样使用这个 List 组件:

interface Product {
    name: string;
    price: number;
}

const products: Product[] = [
    { name: "Product 1", price: 100 },
    { name: "Product 2", price: 200 }
];

const ProductList = () => {
    return (
        <List<Product>
            items={products}
            renderItem={product => (
                <div>
                    {product.name} - ${product.price}
                </div>
            )}
        />
    );
};

六、深入理解泛型的类型推断

6.1 自动类型推断

TypeScript 具有强大的类型推断能力,在使用泛型时,它可以根据上下文自动推断出泛型参数的类型。例如,在前面的 identity 函数中:

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

let result = identity(10);

这里 TypeScript 能够根据传入的参数 10 自动推断出泛型参数 T 的类型为 number。同样,在泛型函数调用时,如果多个参数的类型可以帮助推断,TypeScript 也能准确推断出泛型参数。

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

let combined = combine(1, "hello");

在这个例子中,TypeScript 根据第一个参数 1 推断出 Tnumber,根据第二个参数 "hello" 推断出 Ustring

6.2 类型推断的局限性

尽管 TypeScript 的类型推断很强大,但它也有一些局限性。例如,当泛型参数只在函数内部使用,而没有在返回值或其他参数中体现时,TypeScript 可能无法正确推断类型。

function printLength<T>(arr: T[]) {
    console.log(arr.length);
}

// 以下代码会报错,因为 TypeScript 无法推断出泛型参数 T 的类型
printLength([1, 2, 3]);

在这种情况下,我们需要显式地指定泛型参数的类型:

printLength<number>([1, 2, 3]);

另外,当泛型函数的参数是可选的,并且没有足够的上下文信息时,类型推断也可能不准确。

function addOptional<T>(a: T, b?: T): T | undefined {
    if (b!== undefined) {
        return a;
    }
    return undefined;
}

// 以下代码 TypeScript 可能无法准确推断类型
let result1 = addOptional(1);
let result2 = addOptional(1, 2);

在这种情况下,我们可以通过显式指定泛型参数或者提供更多的上下文信息来帮助 TypeScript 进行类型推断。

七、泛型与类型兼容性

7.1 泛型类型之间的兼容性

在 TypeScript 中,泛型类型之间的兼容性比较复杂。一般来说,两个泛型类型只有在它们的类型参数完全相同时才是兼容的。例如:

interface GenericInterface<T> {
    value: T;
}

let intf1: GenericInterface<number>;
let intf2: GenericInterface<string>;

// 以下赋值会报错,因为类型参数不同
intf1 = intf2;

但是,当泛型类型的类型参数是 any 时,情况有所不同。例如:

let anyIntf: GenericInterface<any>;
let numberIntf: GenericInterface<number>;

// 可以赋值,因为 any 类型兼容所有类型
anyIntf = numberIntf;

7.2 泛型函数的兼容性

对于泛型函数,兼容性规则与普通函数类似,但也考虑泛型参数。例如:

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

function genericFunc2<U>(arg: U): U {
    return arg;
}

let func1: typeof genericFunc1;
let func2: typeof genericFunc2;

// 以下赋值会报错,因为泛型参数名称不同(尽管函数功能相同)
func1 = func2;

如果我们想要让它们兼容,可以使用类型别名来统一泛型参数的名称:

type GenericFunc<T> = (arg: T) => T;

let func3: GenericFunc<number>;
let func4: GenericFunc<string>;

// 以下赋值仍然会报错,因为类型参数不同
func3 = func4;

只有当类型参数相同时,泛型函数才是兼容的:

let func5: GenericFunc<number>;
let func6: GenericFunc<number>;

func5 = func6;

八、优化泛型代码的性能

8.1 避免不必要的泛型

虽然泛型提供了强大的代码复用能力,但过度使用泛型可能会导致性能问题。例如,在一些简单的场景下,如果一个函数只处理特定类型的数据,就没有必要使用泛型。

// 不必要的泛型
function add<T extends number>(a: T, b: T): T {
    return a + b;
}

// 直接指定类型更高效
function addNumbers(a: number, b: number): number {
    return a + b;
}

addNumbers 函数中,直接指定类型可以避免泛型带来的额外类型检查开销。

8.2 类型擦除与运行时性能

在编译时,TypeScript 的泛型会进行类型擦除,也就是说,泛型类型在运行时并不存在。这意味着泛型本身不会增加运行时的开销。但是,如果我们在泛型代码中进行了复杂的类型操作,这些操作可能会影响编译时间。

例如,在一个泛型函数中使用了复杂的类型判断和转换:

function complexGeneric<T>(arg: T): T {
    if (typeof arg === "string") {
        return arg.toUpperCase() as unknown as T;
    }
    return arg;
}

这种复杂的类型操作可能会使编译过程变慢,我们应该尽量简化泛型代码中的类型操作,以提高编译性能。

8.3 利用类型推断减少显式类型声明

在使用泛型时,充分利用 TypeScript 的类型推断能力可以减少显式的类型声明,使代码更简洁,同时也有助于提高编译性能。例如:

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

// 利用类型推断
let result = identity(10);

// 显式指定类型(不必要,会增加编译负担)
let result2 = identity<number>(10);

通过让 TypeScript 自动推断类型,我们不仅减少了代码量,还避免了因手动指定类型可能出现的错误,同时提高了编译效率。

九、泛型的常见错误与解决方法

9.1 类型参数未定义错误

有时候,我们可能会在使用泛型时忘记定义类型参数。例如:

function getProperty(obj, key) { // 这里忘记定义泛型参数
    return obj[key];
}

let person = { name: "Alice", age: 30 };
let name = getProperty(person, "name"); // 这里会报错,因为类型不明确

解决方法是正确定义泛型参数:

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

let person = { name: "Alice", age: 30 };
let name = getProperty(person, "name");

9.2 泛型约束不满足错误

当我们对泛型参数进行约束时,如果使用的类型不满足约束条件,就会报错。例如:

function printLength<T extends { length: number }>(arr: T) {
    console.log(arr.length);
}

let num = 10;
// 以下代码会报错,因为 number 类型不满足 { length: number } 的约束
printLength(num);

解决方法是确保传入的参数类型满足泛型约束。如果我们想要处理不同类型,可以考虑使用联合类型或重载:

function printLength<T extends { length: number } | string>(arr: T) {
    if (typeof arr === "string") {
        console.log(arr.length);
    } else {
        console.log(arr.length);
    }
}

let num = 10;
let str = "hello";
printLength(str);

9.3 泛型与继承冲突错误

在泛型类或接口继承时,可能会出现冲突。例如:

class Base<T> {
    value: T;
}

class Derived extends Base<string> {
    // 这里会报错,因为Derived没有泛型参数,但继承自泛型类Base
}

解决方法是在 Derived 类中也定义泛型参数,或者直接使用具体类型:

class Base<T> {
    value: T;
}

class Derived<T> extends Base<T> {
    // 正确,Derived也定义了泛型参数
}

class DerivedString extends Base<string> {
    // 正确,直接使用具体类型
}

通过避免这些常见错误,我们可以更好地使用泛型,实现高效的代码复用。