TypeScript 的泛型(Generics)

3,019 阅读2分钟

认识泛型

泛型 是编程语言的一个特性,在 Java、c++ 都被采用了,所以它并非TS独创。

那么 泛型 到底是什么呢?我们先通过百科的解释有个大概的印象:“允许程序员在强类型程序设计语言 中编写代码时定义一些 可变部分,这些部分在使用前必须作出指明。"

让我们来好好了解下 TypeScript 中的泛型(Generics)怎么用。

我们粗略的查了下,TS 现在 3.8.x 版本,过去有 11 个大版本中都对 泛型 进行了改进,可见其的重要和复杂性。)

泛型接口

下面我们定义一个简单的接口,其中所有的值类型都是不确定的,而是根据使用场景不同而进行限制数据类型:

interface Data<T> {
	value: T;
	makeArray?(v: T): T[];
}

const d1: Data<number>  = { value: 1, double: v => v * 2 };
const d3: Data<string>  = { value: 'hi', double: v => v + v };
const d2: Data<boolean> = { value: true, double: v => v };

这就是所谓的泛型接口,它满足泛型定义中的三大点:

  1. 强类型检测;
  2. 可变性;
  3. 使用时定义;

这里最重要的部分就是 <T> ,我们称之为泛型变量(又称为类型变量、泛型参数),下面主要是围绕它进行讲解。

认识泛型变量

它是一种特殊的变量,声明时表示任何类型,使用时进行指定数据类型,然后完成类型检测。

它的命名规则与变量名一样,但因为它被赋值后不可在改变的特性,又趋于常量,因此始终建议将它大写。(它可以是 _X、$1、QWE 等,因 T 是 Type 的简写,因此官方推荐 T

注意:对于空的泛型列表,会被标记为错误。例如:function f<>() {}

以上虽然已经是泛型的最简单实践,泛型就是这么回事,现在打了个照面,让我们看看它更丰富的应用场景吧。

泛型类

在类中的使用方式与 接口 很相似,它及进行了声明也进行了实现,让我们沿用上面的定义:

class Data<T> {
	value: T;
	makeArray?(v: T): T[];
	constructor (val: T) {
		this.value = val;
	}
}

const d1 = new Data<number>();
const d2 = new Data<string>();

除了添加一个 constructor 方法之外,其他都一样,毕竟 class 也可以当做 interface 来使用。

备注:在类中无法为静态成员使用 泛型变量,因静态成员无法参与实例,而缺乏上下文,,否则会提示:Static members cannot reference class type parameters.

泛型函数

除了接口泛型,它被用在函数上同样频繁,让我们来定义 获取参数数组 的函数(非泛型函数):

function makeArray(...args: number[]): number[] {
	return args;
}

makeArray(1, 2);        // [1, 2]
makeArray('hi', 'hi');  // Error:类型错误

此函数的特性:

  1. 参数类型 等于 返回的数组子类型;
  2. 只能输入数值类型;

变成泛型函数

上面的函数出现了局限性,让我们保留 “特性1” ,改进 ”特性2“,实现接收任意类型:

function makeArray<T>(...args: T[]): T[] {
	return args;
}

makeArray<number>(1, 2);           // [1, 2]
makeArray<string>('hi', 'hello');  // ['hi', 'hello']

调用此函数时传入 <number> 或任意其他类型,此类型赋给 类型变量,然后被应用到使用 T 的地方,满足了 ”使用前必须作出指明“ 的定义。

传入联合类型

当我们期望函数接收多个类型时,可以使用联合类型(不了解联合类型的同学,可以简单的理解为 or 的概念):

function makeArray<T>(...args: T[]): T[] {
	return args;
}

makeArray<number>(1, 'hi');
makeArray<number | string>(1, 'hi'); // [1, 'hi']

运用类型推断

当我们编写 TS 代码时,即便你不声明数据类型,在 TS 进行解析时仍然会对其进行隐式处理。它在默默的帮我们找出一些潜在的编码风险:

let double = (val: number) => val * 2;

// 这里 TS 对其进行类型推断
let text = 'hi';

double(text); // Error 类型错误

在此我们没有指定 text 变量的数据类型,但是 TS 能够根据赋值推断出 let text: string 数据类型。而 double() 函数定义中声明的参数只接受 number 类型,因此报错提示。(对于类型推断原理也可以单独拿出来讲一讲,所有暂且知道有次个功能就行。

TS的类型推断可喜欢为:

  • 基础类型推断(上面的例子);
  • 最佳通用类型推断;
  • 上下文类型推断;

基于上下文类型推断特性也可以用到 泛型函数 中,进行简化我们的代码:

function makeArray<T>(...args: T[]): T[] {
	return args;
}

// 类型推断出:function double<number>(x: number): number[]
makeArray(1, 2);  // [1, 2]

// 类型推断出:function double<string>(x: string): string[]
makeArray('hi', 'hello');  // ['hi', 'hello']

是的,我们确实只是删掉函数后面的 数据类型 代码,原理和上面变量推断一样。

备注:TS无法推断联合类型,也就是说下面的代码是错误的。

toArray<numbe>(1, 'hi'); 
// Error: Argument of type "hi" is not assignable to parameter of type 'number'.

他只会推断出第一个变量的类型,然后以此进行检查,所以这种情况你必须传入联合类型。

小结

以上的所有 泛型 运用,都是针对 “输入“ 和 ”输出“ 数据类型与数据关系的检查。其实在TS中 函数体内部 也受到 泛型变量 的限制,避免我们再使用数据时出错,我们称之为 泛型的约束,让我们看看是怎么回事吧。


泛型约束

上面的例子泛型都接受的简单的类型,下面我来看看它非常重要的另一个特性:泛型约束。

在开发中,我们的 泛型变量 往往会接受复杂的描述接口,携带各种属性和方法。现在有个需求,需要一个公共方法,此方法只做一件事,就是打印对象数据的 name 属性:

function logName<T>(data: T): void {
	console.log(data.name); // Property 'name' does not exist on type 'T'.
}

你可能疑惑为什么会报错呢?因为 T 可能是任何数据结构,可能接受的数据是个 字符串 或 数字呢。所有 TS 为了最谨慎考虑,禁止我们这样操作。但我们就是需要这般,该怎么办呢?

答:使用 extends 关键字实现类型的约束,大多情况下我们使用 接口 来进行定义具体的约束内容,官网称之为 ”用接口来描述约束条件“。

接口 描述约束条件

其实就是定义一个普通的接口,按我们上面需求,那么声明一个需要 name 属性的接口即可:

interface Animal {
	name: string;
}

然后再应用到上面的 logName() 方法:

function logName<T extends Animal>(data: T): void {
	console.log(data.name);
}

logName({ name: 'zhangsan' });  // 正常
logName({ name: 'zhangsan', age: 3 });  // Error:不存在的属性

当然,如果此方法不是公共方案,也就不需要支持 “可变性”,那么下面的定义效果完全一样:

function logName(data: { name: string }): void {
	console.log(data.name);
}

既然是公共方案,一定会接受除 Animal 接口意外的数据结构,例如 { name: 'zhangsan', age: 3 }; ,这该怎么做呢?

这里是接口定义,就可以基于接口的继承特性,进行任意的扩展。在泛型约束中也是如此:

interface Person extends Animal {
	age: number;
}

logName<Person>({ name: 'zhangsan', age: 3 });  // 正常

这里我们再来看看语法 T extends Animal ,他的含义有几点:

  • T 在没有明确指定时,被解析为 Animal 接口的类型(区别于默认值);
  • T 接受 Animal 接口的类型;
  • T 接受 任何继承自 Animal 接口的类型,所有的后代继承;

默认约束类型

对于约束我们可以设置一个默认类型,当没有指定时生效:

function logName<T extends Animal = Person>(data: T): void {
	console.log(data.name);
}

logName({ name: 'zhangsan', age: 3 });  // 正常

在没有设置默认值时,则以约束接口为约束类型。

关于 默认值约束条件 的规则,在使用是一定要注意:

  • 有默认类型的类型参数被认为是可选的。
  • 必选的类型参数不能在可选的类型参数后。
  • 如果类型参数有约束,类型参数的默认类型必须满足这个约束。
  • 当指定类型实参时,你只需要指定必选类型参数的类型实参。 未指定的类型参数会被解析为它们的默认类型。
  • 如果指定了默认类型,且类型推断无法选择一个候选类型,那么将使用默认类型作为推断结果。
  • 一个被现有类或接口合并的类或者接口的声明可以为现有类型参数引入默认类型。
  • 一个被现有类或接口合并的类或者接口的声明可以引入新的类型参数,只要它指定了默认类型。

基础类型 约束条件

上面的类型变量 T 不用刻意使用 接口 的方式,也可以被约束为 任何类型,下面的方式一般不会被用在开发中,仅仅是为了更好的理解 泛型约束 <T extends U> 的原理。

无论是固定的值,还是基础类型都可以用作约束:

type T1 = 'hi';
type T2 = boolean;
type T3 = null;
// ...

function log<T extends T1>(value: T): void {
	console.log(value);
}

log('hi'); // 正常
log(1);    // Error:1 不能被分配 'hi'

正常开发中直接 value: string 更简单,毕竟这样的约束是无具备 “可变性” 的,因为 基础类型 已经是最小单位了。

联合类型 约束条件

同样也可以用被用作 联合类型,效果如下:

type T1 = string | number; // 联合类型

function log<T extends T1>(value: T): void {
	console.log(value);
}

log('hi'); // 正常
log(1);    // 正常
log(true); // Error

字面量 约束条件

约束条件也可以使用接口字面量的方式,他对输入数据的要求,是对象中必须包含字面量中描述的属性:

function log<T extends { name: string }>(value: T): void {
	console.log(value);
}

log({ name: 'hi' }); // 正常
log({ name: 'hi', target: 'zhangsan' });    // 正常
log({ target: 'zhangsan' });    // ...'target' does not exist in type '{ name: string; }'
log(true); // Error

这样一样不具备 “可变性”,但是在后面我们会巧用它的这一特性。

小结

泛型约束的本质主要是限制 泛型变量 的使用场景范围,无论是运用到 class 还是 function,是实现减少定义一个模块的失误。

目前为止,我们对泛型基础认知已经全部了解到了,如果你都能 Get 到,一定能够在开发中获得收益。对于一些不懂点把前面的内容翻读一遍,或者提出你的问题。接下来就是关于泛型的实践和不会常用到的一些知识了(你也可以看做是所谓的高级知识吧)。


泛型实践

我们运用泛型约束来看看,可以做哪些切实能提升开发效率的事情。

非泛型函数 的实践

我们需要一个简单的函数,传入两个参数,一个是 Data 数据对象,另一个是对象的索引 Key 值,然后返回对应的 Value 值。

这是粗暴的 any 写法(以往写 JS 代码的习惯):

function value(data: any, key: any) {
	return data[key];
}

value(null, null); // 编写正常,执行报错;

在没有定义参数类型情况下,因此会出现的不足(确保编辑器支持 TS 检测并实时返回):

  1. 无法检测参数是否传入,以及传入是否正确;
  2. 无法检测 data 和 key 的关系,key 是否为有效的 data 键值;
  3. 无法检测 返回值 value 的类型正确性;

我们改进成 TS 写法:

interface Animal {
	name: string;
	age?: number;
}

function value(data: Animal, key: string): any{
	return data[key]; // Error:缺少索引标识
}

这样算是能做到类型检测,但是会报错。因为其中 data 参数被指定 Animal 接口类型,而 TS 要求在对明确类型的对象进行 key 访问时,此 key 必须是 “索引标识” 类型。

所谓索引标识(index signature),就是接口类型的所有属性名称,此处的 Animal 接口的索引标识为 'name' | 'age' ,是一种以联合类型为格式的一组 key 字符串。因此我们需要改成:

function value(data: Animal, key: 'name' | 'age'): any{
	return data[key];
}

这么暴力的声明当然不会被开发者接受的,因此 TS 提供了方便的功能 keyof 关键字。它能直接获取 接口 的索引标识,效果如下:

function value(data: Animal, key: keyof Animal): any{
	return data[key];
}

如此已经接近上面的不足中的第 1、2 点了,让我们继续完善,让它支持对返回值的检查,将返回类型 any 优化成,只能返回 Animal 接口中被声明过的所有类型:string | number | undefined

function value(data: Animal, key: keyof Animal): string | number | undefined {
	return data[key];
}

虽然满足了第 3 点的需求,但是同样过于粗暴,也同样 TS 为我们提供了简单获取方式:索引访问 + keyof。与对象获取value原理差不多 data[key] 一样,写成 Animal[keyof Animal] 便可快速获得 string | number | undefined 这个联合类型:

function value(data: Animal, key: keyof Animal): Animal[keyof Animal] {
	return data[key];
}

(为什么要先用粗暴的方式,因为知道为什么用,比会用更重要)

目前提供的函数既满足了需求,也充分的发挥了 TS 的功效。但它有一个很大的遗憾,那就是缺乏可变性。目前为止为引入 泛型 我们做足了铺垫(对于以上的理解程度直接影响泛型的使用),让我们继续往下看。

为什么有个 undefined?因为 age 是 Animal 接口的选填属性,而 TS 会自动为此属性补充一个 undefined 类型,因此它会隐式的变成 (property) Animal.age?: number | undefined

泛型函数 的约束实践

我们只需要对上面的函数,引入 泛型 进行简单的修改,便能满足可变性的效果:

function value<T extends Animal, K extends keyof T>(data: T, key: K): T[K]{
	return data[key];
}

在此我們声明了两个 类型变量 TK ,对于 T extends Animal 的含义前面已经了解了。

K 的含义是获取 T 类型的 索引标识 ,需要注意:这里是 K extends keyof T 而非 K extends keyof Animal,因为 T 是传入的类型,因此始终以 T 为索引才能实现可变性的 索引标识 获取。

泛型继承约束

!!!以下为 1.8 的泛型特性,现在已经有问题!!!

function assign<T extends U, U>(target: T, source: U): T {
    for (let id in source) {
        target[id] = source[id];
    }
    return target;
}

let x = { a: 1, b: 2, c: 3, d: 4 };
assign(x, { b: 10, d: 20 });
assign(x, { e: 0 });  // 错误

解构剩余绑定

TS3.2 支持的泛型变量中结构剩余参数的功能,在我们的项目中经常被使用到特性。让我们看看官方给的例子:

function excludeTag<T extends { tag: string }>(obj: T) {
    let { tag, ...rest } = obj;
    return rest;  // Pick<T, Exclude<keyof T, "tag">>
}

const taggedPoint = { x: 10, y: 20, tag: "point" };
const point = excludeTag(taggedPoint);  // { x: number, y: number }

这也是基于 泛型约束 特性实现了一个效果,这让我们筛选 Component 的 props 时非常有用。

高阶函数类型推断

确保大家对高阶函数的认知是一致,让我们在此重新了解下它的定义。

在数学和计算机科学中,高阶函数是至少满足下列一个条件的函数:

  • 接受一个或多个函数作为输入
  • 输出一个函数

因此在 JS 中的 map、reduce、filter 都是高阶函数,对于 React 中的高阶组件是一个道理。

让我们说回 TS 的高阶类型推断。现在有一个需求,编写一个高阶函数,接受一个函数参数,然后输出一个函数。对于输入的函数不加副作用的执行,但打印一个日志:

/**
 * 声明 类型变量 A、B,他们对于的类型,取决于被传入的函数。
 */
function executeLog<A, B>(f: (...a: A[]) => B): (...a: A[]) => B{
	console.dir(f);
	return v => f(v);
}

如此,他可以接受任何函数,让我们沿用最上面定义的一个简单泛型函数:

function doubleBase<T>(x: T): T[] {
	return [x, x];
}

const double = executeLog(doubleBase);

double('hi');
// ƒ doubleBase(x) 
// ['hi', 'hi']

看似没问题,其实 TS 解析时,将我们传入的 doubleBase<T>(x: T): T[] 函数做了强制推断,它的泛型 T 被推断为成了 {} ,这个空对象可以理解为 any 含义。(如果编写 go 的同学,可以理解成和 interface{} 同样的意思

因此我们得到的函数类型为 const double: (...a: {}[]) => {}[] ,当执行如下代码时:

double<number>('hi'); // Error:此函数不存在泛型

对于这个输出一个泛型函数,安全性缺失的问题,在 TS3.4 版本做出改进(我们目前大多前端项目 TS 版本在 3.0 左右)。上面同样的代码,我们能够得到的函数类型为 const double2: <T>(...a: T[]) => T[],因此达到理想:

double<number>('hi'); // Error:"hi" 无法分配给 number

基于此特性才促使 Promise<T> 这类函数生效,以及更灵活的开发体验。

小结

这些技巧性的知识点,获取在第三方项目会被频繁使用,或者你负责的是公共方案,或者你有阅读源码的爱好,那么了解这些特性会对你非常有帮助。


泛型在组件中的应用

在 TS 对 React 的支持下,让开发者高效和高质量的进行开发,我们分部从函数组件和类组件来实践泛型组件。

JSX元素里的 泛型参数

让我们先来看看对于 Props 进行泛型化的效果:

class GenericComponent<P> extends React.Component<P> {
	render () {
		const { children, ...rest } = this.props;
		return (<div {...rest}>{ children }</div>)
	}
}

type Props = { a: number; b: string; };

const t1 = <GenericComponent<Props> a={10} b="hi"/>;  // OK
const t2 = <GenericComponent<Props> a={10} b={20} />; // Error:参数 b 数据类型错误

我们可以在 React 的 d.ts 文件中看到 interface Component<P = {}, S = {}, SS = any> 的定义,它给出了三个 泛型变量,对应的是:

  • P:Props
  • S:State
  • SS:snapshot

大家常用的是前两个,可以看到他们给了默认值 {} ,它本身是不限制数据内容。然后我们基于此类型进行封装,实现约束和默认值的设置。

泛型标记模版里的 泛型参数

标记模版是ECMAScript 2015引入的一种调用形式。 类似调用表达式,可以在标记模版里使用泛型函数,TypeScript会推断使用的类型参数。

TypeScript 2.9允许传入泛型参数到标记模版字符串。

declare function styledComponent<Props>(strs: TemplateStringsArray): Component<Props>;

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

styledComponent<MyProps> `
  font-size: 1.5em;
  text-align: center;
  color: palevioletred;
`;

declare function tag<T>(strs: TemplateStringsArray, ...args: T[]): T;

// inference fails because 'number' and 'string' are both candidates that conflict
let a = tag<string | number> `${100} ${"hello"}`;

装饰器中 泛型使用

我们以 React 中的 @contextProvider() 举例。

export function contextProvider<
	P1,
	P2,
	PassName extends string = 'passContextValue'
	>(
		ContextConsumer: React.ComponentType<React.ConsumerProps<P2>>,
		passPropName: PassName = "passContextValue"
	)
{
	return (
		ComponentClass: React.ComponentType<P1 & { [key in PassName]?: P2 }>
  ) => {
		class WrappedComponent extends React.PureComponent<
			P1
			& { [key in PassName]?: P2 }
			& { passContextValue?: P2 }
			> {
				render() {
					return (
						<ContextConsumer>
							{
								(value: P2) => {
									const passProps: { [key in PassName]: P2 } = {
										[passPropName]: this.props[passPropName] || value
									} as any;
									return (<ComponentClass { ...this.props } {...passProps } />);
								}
							}
						</ContextConsumer>);
				  }
			};
			return WrappedComponent as any;
    }
}

@contextProvider(Consumer, "form")
@observer
export class Demo extends React.Component<{ form?: Form }> {
	// ...
}

在这个装饰器中涉及到的泛型应用:

  • 共创建了三个泛型变量:P1、P2、PassName
  • P1 和 P2 都是 props,PassName 限制为字符串,默认值为 passContextValue
  • 接受两个参数:ContextConsumer、passPropName
  • 返回一个函数,接受需要装饰的目标类:ComponentClass

总结

可以看出 泛型 的使用非常灵活,也很容易上手,当我们需要抽离公共功能模块实现解耦时,就可以轻松的发挥泛型的价值。

无论你听多少遍,看多少遍,都不及你去动手敲一遍,获取去阅读一个优质的开源项目。