TypeScript 泛型的一点见解
泛型的理论理解
泛型,我的理解就是一种“占位符”或者“模板”。就像去餐厅点菜,菜单上写的“主食 + 汤”,这里的“主食”和“汤”就是占位符,可以根据自己的口味选择米饭、面条或者包子作为主食,选择番茄汤、海带汤或者玉米汤作为汤。泛型也是一样 ,在定义函数、类或者接口的时候,先不明确具体的类型,而是用一个占位符(比如T)来代替,等真正使用的时候再指定具体是什么类型。
理解为一种让函数、类或者接口在使用时可以动态指定数据类型的机制。这就意味着不需要在写代码时固定某一个数据类型,而是可以让它在实际使用时灵活决定。简单来说,就是让代码更具通用性和可重用性,在前端的开发项目中是必要掌握的知识点。
举个简单的例子,一个普通的函数:
function add(a: number, b: number): number {
return a + b;
}
这个 add
函数是固定了数据类型的,只能接受 number
类型的参数。如果想写一个可以接受不同数据类型的函数,比如 string
或者 boolean
,这时泛型就派上用场了。
泛型的基本用法
泛型在定义函数、类或接口时不指定具体的类型,而是使用一个占位符(通常是字母 T、U 等),在实际调用时再确定类型。
function identity<T>(arg: T): T {
return arg;
}
在这个例子中,T
就是一个类型占位符。可以传入任何类型的参数,T
就代表了这个类型,并且函数返回的类型也会是这个类型。
调用时指定类型:
const num = identity(42); // T 被推断为 number
const str = identity("hello"); // T 被推断为 string
identity
函数的返回值类型是根据传入的参数类型来自动推断的,灵活程度很高
泛型的约束
有时候我们需要限制泛型的类型,而不是完全不做限制。这时候可以使用泛型约束。约束是通过 extends
继承 关键字来实现的。
就像是给泛型加了一个“规则”,告诉 TypeScript 这个泛型不能是任意类型,必须满足某些条件。就好比去餐厅点菜,菜单上说“主食必须是米饭或面条”,这就是一个约束,不能点包子之类的其他主食。
举个例子,假如有一个需要传入对象的函数,而这个对象必须有 length
属性,那么可以这么做:
function logLength<T extends { length: number }>(item: T): void {
console.log(item.length);
}
在这里,T
必须是拥有 length
属性的类型,这样就能保证调用 logLength
时,传入的类型一定有 length
属性。
logLength([1, 2, 3]); // 数组,长度是 3
logLength("Hello"); // 字符串,长度是 5
logLength({ length: 10 }); // 自定义对象,有 length 属性
logLength(123); // 错误!number 类型没有 length 属性
复杂一点的例子
假设有一个函数,要根据对象的某个属性名获取对应的值。我们希望这个属性名必须是对象上存在的属性名,这就需要用到泛型约束:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
这里K extends keyof T
的意思是:K
必须是T
的键(属性名)之一。keyof T
会生成一个联合类型,包含T
的所有键名。
const obj = { a: 1, b: "hello" };
const value1 = getProperty(obj, "a"); // 返回值类型是 number
const value2 = getProperty(obj, "b"); // 返回值类型是 string
const value3 = getProperty(obj, "c"); // 报错,"c" 不是 obj 的属性名
如果没有泛型约束,key
可以是任意字符串,那么obj[key]
的类型就无法确定,可能会导致错误。
泛型约束的高级用法
如果有一个函数,它接收一个函数作为参数,并调用这个函数。函数参数必须符合某个特定的签名(比如接收一个number
参数,返回一个string
)。
function callFunction<T extends (arg: number) => string>(fn: T, arg: number): string {
return fn(arg);
}
这里T extends (arg: number) => string
的意思是:泛型T
必须是一个函数类型,接收一个number
参数,返回一个string
。
function aFunction(num: number): string {
return `${num}`;
}
let result = callFunction(aFunction, 123); // 正常,返回值是 string
如果不是函数,而是接收一个类作为参数,并创建这个类的实例,并且这个类必须有一个特定的构造函数签名(比如接收一个string
参数)
function createInstance<T extends new (arg: string) => any>(cls: T, arg: string): T {
return new cls(arg);
}
这里T extends new (arg: string) => any
的意思是:泛型T
必须是一个类类型,它的构造函数接收一个string
参数。
class aClass {
constructor(name: string) {}
}
let instance = createInstance(aClass, "TypeScript"); // 正常,instance 是 MyClass 的实例
如果不符合则会报错
class aClass {
constructor() {}
}
createInstance(aClass, "TypeScript"); // 报错,aClass 的构造函数签名不符合要求
复杂一点还可以约束泛型参数必须是一个对象类型,并且具有特定的索引签名。比如
function getObjectValue<T extends { [key: string]: any }, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const obj = { a: 1, b: "hello" };
getObjectValue(obj, "a"); // 返回值类型是 number
getObjectValue(obj, "b"); // 返回值类型是 string
getObjectValue(obj, "c"); // 报错,"c" 不是 obj 的键
这里T extends { [key: string]: any }
的意思是:T
必须是一个对象类型,且可以通过字符串键访问其属性。
泛型与接口
泛型接口就是把泛型和接口结合起来,让接口在定义的时候也用占位符来代替具体的类型。这样可以让接口更加通用和灵活。
比如说,有一个存储数据的接口,这个数据可能是字符串、数字、或者任何类型。那就可以用泛型来设计这个接口
interface Box<T> {
value: T;
}
const box1: Box<number> = { value: 123 };
const box2: Box<string> = { value: "hello" };
这里,Box<T>
接口是一个泛型接口,它接受一个类型参数 T
,可以在实例化时决定 T
的类型。这样,不管要存储什么类型的数据,都可以通过这个接口来实现
泛型接口的约束
还可以给泛型接口加一些约束。比如,我们希望value
属性必须是一个对象,并且这个对象有一个id
属性:
interface Identifiable<T> {
id: number;
value: T;
}
interface Container<T> {
value: T;
getData(): T;
}
// 现在我们定义一个泛型接口,要求 data 是 Identifiable 类型
interface IdentifiableContainer<T> extends Container<T> {
identifiableData: Identifiable<T>;
}
// 实现这个泛型接口
class MyContainer<T> implements IdentifiableContainer<T> {
value: T;
identifiableData: Identifiable<T>;
constructor(value: T, identifiableData: Identifiable<T>) {
this.value = value;
this.identifiableData = identifiableData;
}
getData(): T {
return this.value;
}
}
// 使用这个类
const myContainer = new MyContainer<string>("Hello", { id: 1, value: "World" });
console.log(myContainer.getData()); // 输出: Hello
console.log(myContainer.identifiableData); // 输出: { id: 1, value: "World" }
泛型的类型推断
当调用一个泛型函数时,TypeScript 会根据传入的参数来推断出泛型的具体类型
function getFirstElement<T>(arr: T[]): T {
return arr[0];
}
const first = getFirstElement([1, 2, 3]); // T 被推断为 number
const firstStr = getFirstElement(["a", "b", "c"]); // T 被推断为 string
即使没有明确传入泛型类型参数,TypeScript 会根据实际传入的参数自动推断类型,这也是泛型的强大之处。
泛型伤脑筋的地方
类型推断 vs 显式指定泛型
TypeScript 的泛型就像一个“聪明的盒子”,它能自动“猜”出你放进去的东西是什么类型。
大多数时候,这个“盒子”都很厉害,能准确猜出类型,啥都不用做,它就搞定啦。比如,传了个字符串进去,它就能自动知道这是字符串类型,传个数字进去,它就知道是数字类型。
但有时候,这个“盒子”也会犯迷糊。特别是当传进去的东西类型不太明确,或者有点复杂的时候,它就可能“猜”错了。这时候,就得主动告诉它
这就像是你去餐厅,菜单上写着“主食 + 汤”,服务员本来能猜出你想吃啥,但你点的菜太奇怪了,比如“一半米饭一半面条 + 番茄汤和紫菜汤混合”,服务员就懵了,你就得明确说:“我要米饭 + 玉米汤。”(玉米汤真的好喝)
就像这个:
function wrap<T>(value: T): T {
return value;
}
const result1 = wrap(42); // 推断出 T 是 number,结果没问题
const result2 = wrap("hello"); // 推断出 T 是 string,结果也没问题
const result3 = wrap({}); // 错误,推断出 T 是 {} 类型,通常你期望是其他类型
// 需要确保传入的类型足够明确,或者直接手动指定泛型类型
const result3 = wrap<{ name: string }>({ name: "Simin" });
泛型约束不够精确
泛型约束(extends
)是用来限制泛型类型的,但有时会发现约束的类型并不完全符合预期,或者没能达到想要的效果
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const result = merge({ name: "Simin" }, { age: 25 }); // 没问题
const errorResult = merge(1, { age: 25 }); // 错误,数字类型没有 object 属性
使用合适的约束来限制类型,避免过于宽泛的约束。例如,确保 T
和 U
都是对象类型,而不是任意类型
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
泛型与联合类型的结合
假设有一个函数,它接受一个参数,这个参数可以是字符串或者数字(联合类型)。希望这个函数根据参数的类型做不同的事情。但是,把这个函数变成泛型函数的时候,TypeScript 可能会推断出一个不符合预期的类型。比如,传进去的是string | number,但它可能推断出一个更宽泛的类型,或者干脆推断错误,导致无法按预期使用
function getValue<T>(val: T | string): T {
return val; // 错误,val 可能是 string 类型,但我们希望它返回 T 类型
}
const result = getValue(42); // 可以推断为 number
const result2 = getValue("hello"); // 类型推断会失败,返回类型不一致