TypeScript 泛型类

在前面的章节中,我们已经见识到了泛型在 “函数” 和 “接口” 中强大的解耦能力。现在,我们要把泛型引入到面向对象编程的基石——类(Class) 之中。

在真实的业务场景下,我们往往需要设计一些通用的数据结构(比如栈、队列、字典、缓存管理器)。像这种基础架构代码,不应该被某一种具体的数据类型死死绑定。

让一个 “图纸(类)” 能够根据实例化时的要求,动态生产出适应不同 “类型” 的对象,这就是泛型类要做的事情。

TypeScript 泛型类的定义

和 “定义泛型函数”、“定义泛型接口” 一样,我们只需要在类名的后面加上 “<T>”,那么这个类就会变成一个 “泛型类”。

定义成泛型类之后,那么在这个类的内部(包括属性、构造函数、普通方法)就可以随心所欲地使用这个泛型 “T” 了。

假设我们需要一个缓存管理类,它不仅能缓存字符串,未来可能还要缓存数字、甚至复杂的对象。

示例 1:封装一个万能的 StorageBox(收纳盒)

// 定义泛型类
class StorageBox<T> {
    // 声明一个私有属性(类型为 T 组成的数组)
    private _items: T[] = [];

    // 存入物品:参数必须是 T 类型
    public add(item: T): void {
        this._items.push(item);
        console.log("成功收纳:", item);
    }

    // 拿取物品:取出来的必然也是 T 类型
    public get(index: number): T {
        return this._items[index];
    }
}

// 场景 1:实例化一个专门装 “字符串” 的收纳盒(比如装书名)
const bookBox = new StorageBox<string>();
bookBox.add("《HTML 教程》");
bookBox.add("《CSS 教程》");
console.log(bookBox.get(0));

// 场景 2:实例化一个专门装 “数字” 的收纳盒(比如装年份)
const yearBox = new StorageBox<number>();
yearBox.add(2026);
yearBox.add(2027);
console.log(yearBox.get(1));

运行结果如下。

成功收纳:《HTML 教程》
成功收纳:《CSS 教程》
《HTML 教程》
成功收纳:2026
成功收纳:2027
2027

分析:

由于我们在类名后面定义了 <T>,因此当我们写下 new StorageBox<string>() 的那一刻,这个对象内部所有的 “T” 瞬间都会被替换成 string。

这样下来,我们只需要使用一套代码,就可以实现无限多种类型的收纳。

TypeScript 泛型类的 “多泛型参数 (<K, V>)”

在 TypeScript 泛型类中,我们同样也可以传入多个泛型参数,最典型的应用场景就是:封装底层的 “字典” 或 “本地存储引擎”。

示例 2:实现一个强类型的键值对存储类

// 定义泛型类(包含 2 个泛型参数)
class Dictionary<K, V> {
    private _keys: K[] = [];
    private _values: V[] = [];

    public set(key: K, value: V): void {
        this._keys.push(key);
        this._values.push(value);
    }

    public getValueByKey(key: K): V | undefined {
        const index = this._keys.indexOf(key);
        if (index !== -1) {
            return this._values[index];
        }
        return undefined;
    }
}

// 实例化对象:Key 必须是字符串,Value 必须是布尔值
const switchConfig = new Dictionary<string, boolean>();

switchConfig.set("isDarkMode", true);
switchConfig.set("enableAnimation", false);

console.log("是否开启暗黑模式?", switchConfig.getValueByKey("isDarkMode"));

运行结果如下。

是否开启暗黑模式?true

TypeScript 类方法拥有独立的泛型

在上面的例子中,我们定义在类名后面的泛型 <T>,可以被类的所有普通属性和方法共享。

但在某些特殊场景下,类的某个方法可能需要处理一个 “与类本身泛型无关的新类型”。此时,我们可以为这个特定的方法声明一个 “专属” 的独立泛型。

示例 3:为方法定义独立的泛型

class StorageBox<T> {
    // content 使用的是类级别的泛型 T
    constructor(public content: T) {}

    // printWithExtra 方法定义了一个独立的泛型 U
    public printWithExtra<U>(extraInfo: U): void {
        console.log("盒子里装的是:", this.content);
        console.log("额外附加信息:", extraInfo);
    }
}

// 1. 实例化时,类的泛型 T 被确定为 string
const box = new StorageBox<string>("绿叶网");

// 2. 调用方法时,传入数字,方法的独立泛型 U 被确定为 number
box.printWithExtra<number>(2026);

// 3. 调用方法时,传入布尔值,方法的独立泛型 U 再次被确定为 boolean
box.printWithExtra<boolean>(true);

运行结果如下。

盒子里装的是:绿叶网
额外附加信息:2026
盒子里装的是:绿叶网
额外附加信息:true

分析:

在这个例子中,StorageBox 类的泛型是 <T>。当我们把它实例化为 box 并且存入 "绿叶网",此时 T 就被死死地绑定成了 string。

但是,我们的 printWithExtra() 方法拥有自己独立的泛型 <U>。这意味着,无论 T 是什么,在调用 printWithExtra() 时,都可以灵活地传入数字、布尔值或任何其他类型,而不会受到 T 的任何限制。

TypeScript 静态成员与泛型

有这么一道经典的架构面试题:在 TypeScript 中,类的静态成员(包括 “static 属性” 和 “static 方法”),可以使用类上定义的泛型 “T” 吗?”

答案是:绝对不可以!

如果我们尝试在静态成员中使用泛型 “T”,那么TypeScript 就会直接拦截报错。

示例 4:静态成员引用泛型报错

class StorageBox<T> {
    public content: T;

    constructor(val: T) {
        this.content = val;
    }

    // 错误写法:试图在静态属性上使用类的泛型 T
    public static defaultContent: T;
    // 报错:静态成员不能引用类类型参数。
}

分析:

为什么类的静态属性和静态方法不能使用类的泛型 “T” 呢?这个设置其实是非常合理的。我们来回忆一下上一章介绍的 “TypeScript 静态成员” 的核心特点:静态成员是挂载在 “类” 本身的,而不是挂载在 “实例化对象” 上的。其中,静态成员在类加载的时候就已经存在了

而我们类上的泛型 “T”,是在什么时候才确定具体类型的呢?其实是在实例化对象的时候(比如 new GenericClass<string>())。

如果我们直接使用 GenericClass.defaultValue 去访问静态属性,此时根本没有任何对象被 new 出来,TypeScript 编译器也就无从得知这个 “T” 到底是个什么类型了。因此,TypeScript 严格禁止静态成员染指类级别的泛型参数。

虽然静态成员不能染指 “类级别的泛型 T”,但静态方法完全可以像普通函数一样,声明自己 “独立的泛型”。

示例 5:静态方法的独立泛型

class StorageBox<T> {
    // 报错:静态成员不能使用类的泛型 T
    // public static defaultBox: T;

    // 正确:静态方法定义了属于自己的独立泛型 U(与类的 T 无关)
    public static createEmptyBox<U>(label: U): void {
        console.log("创建了一个空盒子,专属标签是:", label);
    }
}

// 调用静态方法时传入字符串,此时 U 被自动推断为 string
StorageBox.createEmptyBox("私密收纳盒");

TypeScript 泛型类的类型推断

和泛型函数一样,我们在实例化泛型类时,如果构造函数 constructor() 中已经接收了泛型参数,此时我们是可以省略尖括号的,因为 TypeScript 会自动进行类型推断。

示例 6:泛型类的自动推断

class StorageBox<T> {
    constructor(public content: T) {}
}

// 写法 1(严格写法)
const box1 = new StorageBox<string>("礼物");

// 写法 2(优雅写法)
const box2 = new StorageBox("礼物");
// 自动推断 T 为 string

对于写法 2 来说,由于我们传入的是一个字符串,因此 TypeScript 会自动推断 T 就是 string。

TypeScript 泛型类结合接口(企业级高频架构)

在真实的企业级架构中,我们很少会直接孤立地写一个泛型类。为了保证代码的规范性和可扩展性,架构师通常会先定义一个 “泛型接口” 作为标准规范(图纸),然后再让 “泛型类” 去实现这个接口。

举个例子:我们要开发一个缓存管理器(Cache),我们需要强制规定它必须拥有 set 和 get 方法。

示例 7:泛型类实现泛型接口

// 1. 定义泛型接口(制定标准规范)
interface ICache<T> {
    set(key: string, value: T): void;
    get(key: string): T | undefined;
}

// 2. 定义泛型类,并实现该接口
class MemoryCache<T> implements ICache<T> {
    // 使用对象来充当缓存字典
    private _cache: { [key: string]: T } = {};

    // 必须实现接口规定的 set 方法
    public set(key: string, value: T): void {
        this._cache[key] = value;
        console.log("缓存成功:", key);
    }

    // 必须实现接口规定的 get 方法
    public get(key: string): T | undefined {
        return this._cache[key];
    }
}

// 3. 实例化缓存器:用于缓存 “用户信息对象”
interface User {
    name: string;
    age: number;
}

const userCache = new MemoryCache<User>();

userCache.set("user_1001", { name: "Jack", age: 20 });
const userInfo = userCache.get("user_1001");

console.log(userInfo?.name); 

运行结果如下。

缓存成功:user_1001
Jack

分析:

我们首先定义了 ICache<T> 接口,规定了缓存操作必须具备 set 和 get,并将具体缓存的数据类型交由泛型 “T” 决定。

class MemoryCache<T> implements ICache<T>

在定义类时,上面这句代码的意思是:MemoryCache 接收了一个泛型 “T”,然后原封不动地把这个 “T” 传递给了 ICache 接口。

通过这种模式,我们不仅实现了代码的高度复用(MemoryCache 可以存任何类型),还保证了类的结构严格符合接口制定的标准。这是阅读各种主流开源框架(如 Vue、React、NestJS)底层源码时的必备前置知识。

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号