阅读 1252

类型即正义:TypeScript 从入门到实践(四):5000字长文带你重新认识泛型

本文由图雀社区成员 pftom 写作而成,欢迎加入图雀社区,一起创作精彩的免费技术教程,予力编程行业发展。

欢迎阅读 类型即正义,TypeScript 从入门到实践系列:

本文所涉及的源代码都放在了 Github  或者 Gitee 上,如果您觉得我们写得还不错,希望您能给❤️这篇文章点赞+GithubGitee仓库加星❤️哦~

此教程属于 React 前端工程师学习路线的一部分,欢迎来 Star 一波,鼓励我们继续创作出更好的教程,持续更新中~

在之前的文章中,我们了解了 TypeScript 主要分为 JS 语言侧和类型侧两个部分。

在介绍了类型侧的一些基础知识,我们用这些学到的基础知识去注解对应的 JS 内容,将 JS 内容如变量、函数、类等类型化,这样确保写出的代码非常利于团队协作,且能快速排错。

在了解了之前几篇文章里面的知识之后,你应该可以使用 TypeScript 进行正常的项目开发了。

源起

为什么要学泛型?因为它可以帮助你 “面向编辑器代码提示编程” :)

学习准备

配置 TypeScript 环境

创建一个 node 项目:

mkdir ts-study
cd ts-study && npm init -y
复制代码

配置 TypeScript 环境:

npm install typescript # 安装 TypeScript
npx tsc --init # 生成 TypeScript 配置文件
复制代码

修改 tsconfig.json 文件,设置对应的 TS 编译器需要编译的文件如下:

{
  "compilerOptions": {
    "outDir": "./dist" // 设置编译输出的文件夹
  },
  "include": [
    // 需要编译的ts文件一个*表示文件匹配**表示忽略文件的深度问题
    "./src/**/*.ts"
  ],
  "exclude": ["node_modules", "dist", "**/*.test.ts"] // 排除不需要编译的文件夹
}
复制代码

配置 TypeScript 编译执行脚本,使用 VSCode 编辑器打开 ts-study 项目,然后修改 package.json 的 scripts 字段如下:

{
  "name": "ts-study",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "tsc",
    "build:w": "tsc -w"
  },
  "author": "pftom <1043269994@qq.com>",
  "license": "MIT",
  "dependencies": {
    "typescript": "^3.7.4"
  }
}
复制代码

接着在项目根目录新建 src 文件夹,并在里面新建  index.ts  文件,接着在项目根目录下的命令行执行 npm run build:w 开始监听 index.ts 文件的修改。

经过上面的操作,你的 VSCode 编辑器应该是如下样子:

image.png
其中 TERMINAL 终端表示正在监听文件修改并编译中,当前文件的编译结果没有错误,因为我们的 src/index.ts 里面还没有写任何内容。一切准备就绪,开始 “面向编辑器代码提示编程”!✌️

从一个简单的例子说起

先不扯那么多泛型的概念,我们先来看一个简单的例子,在 src/index.ts 里面编写如下代码:

function getTutureTutorialsInfo(info) {
  return info;
}
复制代码

我们编写了一个获取图雀教程信息的函数,接收 info 输入,然后返回 info ,即明确参数类型和返回类型一样。现在这个还只是一个 JavaScript 函数,我们来给它进行类型注解。

写一个 Low 一点的 TS 函数

.... 这怎么注解?此时正在阅读的你可能会冒出这个疑问。

对的,这怎么注解?我们面临着如下几个难题:

  • info 类型未知,它可能是 string 、 number 或者其他类型
  • info 类型未知的情况下,我们还要注解返回值类型,并且此返回值类型要和 info 类型一致,所以我们的返回值类型这里也未知。

相信有同学会尝试这样去解决:

function getTutureTutorialsInfo(info: any): any {
  return info;
}
复制代码

很好!你成功写了第一个 "AnyScript` 函数,这个函数和 JS 函数无异,根本无法用到 TS 的强大类型补全机制,不信你可以在你的 VSCode 编辑器里面尝试加入如下代码:

function getTutureTutorialsInfo(info: any): any {
  console.log(info.length);
  return info;
}
复制代码

可以看到我们添加了一个打印语句 console.log ,如果你没有 Copy 上面的代码,而是选择手敲的话,你会发现输入 info. 的时候,编辑器里面没有提示补全 length 属性,因为给 info 注解了 any 类型,TS 编译器无法推断此 info 是什么类型,所以也没有对应的补全,没有补全的 TypeScript 代码是没有生命的😿

类型的函数?

那么思考一下,这里该如何做类型注解了?相信你已经有答案了,这就是我们这一节要引出的重点:“泛型” ,我将它称之为 “类型的函数”,对应 JS 的函数一样,声明一个 “类型变量”,然后在类型函数代码块里面可以使用这个 “类型变量”。

一个 JS 函数如下:

function jsFunc(varOne) {
  const res = varOne + 1;
  return res;
}
复制代码

可以看到一个 JS 函数,有一个 varOne 参数,这个参数变量可以在函数体中使用。接下来我们来看一下为什么我把泛型称之为 “类型的函数”,修改我们 src/index.ts 里面的内容:

function getTutureTutorialsInfo<T>(info: T): T {
  console.log(info.length);
  return info;
}
复制代码

可以看到我们给 getTutureTutorialsInfo 后面加上 <T> 这个类似我们上面那个 JS 函数的 (varOne) ,然后我们在原 JS 函数参数和返回值中使用了这个 “类型变量”  T : (info: T): T ,这样我们就解决了上面的两个难题:

  • 我们定义了 T 这样一个 “类型变量”,并用它来注解我们的 JS 函数参数 info 和其返回值,T 既然是一个 “类型变量”,那么接收此 “类型变量” 的 “类型的函数” - 泛型,在之后被调用的时候,我们可以根据需求指定传入的类型,比如 string 、 number 等,这就确保了调用函数的用户来决定 info 的类型 T ,这样参数的类型就确定了。
  • 参数和返回值类型都使用了 T 来做类型标注,所以参数值和返回值类型一致。

但是稍等,上面的代码在编辑器中报错了:

image.png
因为我们将这个函数泛型化了,明确了泛型变量 T 是一个明确类型,所以我们之前的 info.length 会报错,当然这里有同学会问了,我要是这里 T 在之后泛型 (类型的函数)调用的时候传入的是 string 类型,那不是就有 length 属性了嘛,很遗憾,因为 T 还可能是 number 类型,而 number 类型的变量没有 length 属性,所以 TS 编译器报错了。

为了解决上面的问题,我们可以更近一步,对函数做出修改如下:

function getTutureTutorialsInfo<T>(info: T[]): T[] {
  console.log(info.length);
  return info;
}
复制代码

这样就好啦,不仅类型确定了,而且参数和返回值类型也一致,并且我们还能明确的使用 info.length 了,因为 TS 编译器知道 info 是一个数组,这个时候你在 VSCode 编辑器里面输入 info. ,应该会提示你如下效果:

image.png
有了代码补全的 TS 充满了活力🔥!

经过上面的例子,我们发现,其实泛型,就像是一个关于 “类型的函数” 一样,给定输入的类型变量,然后可以使用输入变量经过组合比如 T[] 、进行联合类型或交叉类型操作,来作为注解类型使用。

类型函数的使用

上面我们定义了第一个泛型 - “类型的函数”,接下来我们来尝试使用我们的泛型,在 src/index.ts 中对代码做出对应的修改如下:

function getTutureTutorialsInfo<T>(info: T[]): T[] {
  console.log(info.length);
  return info;
}

getTutureTutorialsInfo<string>(['hello tuture', 'hello world'])
复制代码

可以看到对应 <T> 定义了泛型中的类型变量,我们在调用泛型时,也对应写了 <string> ,这样 T 就在 getTutureTutorialsInfo 函数中就会以 string 的类型被使用,参数 info 和返回值也就对应了 string[] ,你的 VSCode 编辑器里面调用的效果应该如下图,将鼠标移动到 getTutureTutorialsInfo 上,会直接显示 getTutureTutorialsInfo 函数的类型定义,可以看到已经成功将 T 换成了 string 。

image.png

并且我们还了解到,泛型的使用和 JS 函数的调用一脉相承,更加坚定了我们 泛型 就是 “类型的函数” 的说法和认知。

注意:

  • 上面的泛型中使用的 T 变量,其实只是一个 TypeScript 界比较习惯性的用法,常用的还有 U 等,当然你也可以写成 YourT ,这里不限制。
  • 上面的泛型调用时,T 接受的类型可以是任意类型,比如对象、函数等类型,不仅仅限于 string 、 number 等

泛型,再回顾

我们在上面用了很多的笔墨来试图将泛型和 “类型的函数” 划上等号,目的是为了让你理解泛型它工作的一个本来面貌。了解了泛型本来面貌之后,相信诸如使用泛型可以使得 TS 代码组件化,复用代码,你也能了然如胸了。

泛型是在调用时再限定类型

我们在定义泛型的时候,是一系列类型变量,如 T 、 U 等,这些变量实际的类型我们在定义的时候是不知道的,只有在进行泛型调用的时候,由用户给定实际的类型,所以这里有一种延迟声明类型的作用。

泛型是否也有多个类型变量?

那么,既然泛型可以看做是 “类型的函数”,那么函数能接收多个参数的话,我们的泛型也能接收多个类型变量,比如:

function getTutureTutorialsInfo<T, U>(info: T[], profile: U): T[] {
  console.log(info.length);
  console.log(profile);
  return info;
}

getTutureTutorialsInfo<string, object>(['hello tuture'], { username: 'tuture'})
复制代码

可以看到,我们修改了 getTutureTutorialsInfo 函数的泛型定义,添加了一个新的类型变量 U ,并用 U 来注解了函数的第二个参数 profile 的类型。

同样,在调用 getTutureTutorialsInfo 函数的时候,我们也需要传入两个类型变量,这里我们的 profile 被认为是一个 object 类型。

匿名函数泛型?

在之前的内容中,我们通过命名函数来讲解了泛型,那么匿名函数如何使用泛型了?其实和命名函数类似,只不过匿名函数是如下形式:

const getTutureTutorialsInfo: <T>(info: T[]) => T[] = (info) => {
  console.log(info.length);
  return info;
}

// 或者
const getTutureTutorialsInfo: <T>(info: T[]) => T[] = function (info) {
  console.log(info.length);
  return info;
}
复制代码

我们直接给匿名函数被赋值的变量进行匿名函数的注解,并加上泛型,你应该回想起之前给一个变量注解函数类型时的样子:

(args1: type1, args2: type2, ..., args3: type3) => returnType
复制代码

而匿名函数泛型只不过在之前加上了 <T> 类型变量,然后可以用于注解参数和返回值。

泛型默认类型参数?

既然我们声称泛型是关于 “类型的函数”,为了更加深刻的论证我们这个观点,我们再进一步。

我们都知道函数存在默认参数一说,那么作为 “类型的函数” - 泛型,是否也有默认类型参数这一说了?不好意思,还真的有!我们来看个例子:

function getTutureTutorialsInfo<T, U = number>(info: T[], profile: U): T[] {
  console.log(info.length);
  console.log(profile);
  return info;
}

getTutureTutorialsInfo<string, string>(['hello world'], 'hello tuture')
复制代码

可以看到我们给类型变量 U 一个默认的类型参数 number (还记得 ES6 里面有默认值的参数必须靠后放置嘛?)

之后我们在进行泛型调用的时候,却给 U 传了 string 类型,把这段代码放到 src/index.ts 里面,应该不会报错,并且编辑器里面有良好的提示:

image.png

泛型,继续前进

接下来我们继续深入泛型,解答之前文章里的一些疑问,比如:

  • 泛型数组
  • 类泛型

同时我们还会了解一些新的概念,比如:

  • 接口泛型
  • 类型别名泛型
  • 泛型约束

解决遗留的问题

泛型数组

这个我们已经在上面的例子中用到了,泛型实际上定义了一系列类型变量,然后我们可以对这些类型变量做任意的组合以适应各种不同的类型注解需求,其中一个组合例子就是泛型数组 - 某个类型变量的数组形态,也就是我们上面提到的 info: T[] ,其中 T[] 就是泛型数组。

当然泛型数组的表达形式还有另外一种:

Array<T> 
复制代码

即以泛型调用的形式返回一个关于泛型变量 T 的数组类型。所以我们的 getTutureTutorialsInfo 函数可以写成如下样子:

function getTutureTutorialsInfo<T>(info: Array<T>): Array<T> {
  console.log(info.length);
  return info;
}

getTutureTutorialsInfo<string>(['hello tuture', 'hello world'])
复制代码

类泛型

类泛型的形式和函数泛型类似,我们来看一个类泛型的定义的调用,在 src/index.ts 里面额外添加下面的内容:

// 上面是 getTutureTutorialsInfo 泛型函数的定义和调用

class Tuture<T> {
 	info: T[];
}

let tutorial = new Tuture<string>()
tutorial.info = ['hello world', 'hello tuture'];
复制代码

类泛型的定义也是在类名之后添加 <T> 这样的形式,然后就可以在类中使用 T 类型变量来注解类型。而类泛型的调用和函数泛型的调用类似。

学习了类泛型,我们再来解析一下在上一篇文章中提到的那个 TodoInput 组件,类似下面这样:

class TodoInput extends React.Component<TodoInputProps, TodoInputState> {
  // ... 组件内容 
}
复制代码

这个实际上分为两个部分,首先是 React.Component 组件基类的类泛型调用,然后是 TodoInput 集成自这个类泛型。因为派生类 TodoInput 可以获取到父类的属性和方法,所以在 TodoInput 中使用的 this.props 和 this.state 在被类型注解之后,就可以在编码时自动补全,你在写代码的时候应该可以享受到如下好处:

image.png

开启新篇章

了解了函数泛型、类泛型,你有可能有一点想法了关于泛型,是不是我们之前的很多讲解过的内容,如类型别名、接口等。你想对了!TS 会在尽可能多的地方,能用泛型就用上泛型,因为泛型可以将代码组件化,方便复用,所有智能的编译器,能不让你多写的东西,就绝对不会让你多写,通通用泛型给整上。

接口泛型

在了解接口泛型之前,我们先来看一个接口是怎么写的,在 src/index.ts 里面添加如下代码:

interface Profile {
  username: string;
  nickName: string;
  avatar: string;
  age: string;
}
复制代码

一般我们的 Profile 类似上面的内容,但是有时候有些字段会根据需求的不同而不同,比如 age 这个字段,有些人喜欢定义成数字类型 number ,有些人喜欢定义成字符串类型 string ,所以这又是一个延迟赋予类型的例子,可以借助泛型来解决,我们修改一下上面的代码:

 interface Profile<T> {
  username: string;
  nickName: string;
  avatar: string;
  age: T;
}

type ProfileWithAge = Profile<string>
复制代码

可以看到,接口泛型的声明和调用与函数、类泛型的类似,它允许你在接口里面定义一些属性,使用类型变量来注解,在调用时指明这个属性的类型。

类型别名泛型

因为在很多场景下,类型别名和接口充当类似的角色,所以在了解完接口泛型之后,我们有必要来了解学习一下类型别名如何结合泛型使用,和接口类似,将上面的接口泛型 Profile 用类型别名重写如下:

type Profile<T> = {
  username: string;
  nickName: string;
  avatar: string;
  age: T;
}

type ProfileWithAge = Profile<string>
复制代码

可以看到,基本一致!

泛型约束

我们来解决之前的一个遗留问题,那就是即使我使用了泛型,我还是不知道某个被泛型的类型变量注解的变量的一个结构是怎么样的即:

function getTutureTutorialsInfo<T, U>(info: T[], profile: U): T[] {
  console.log(info.length);
  console.log(profile);
  return info;
}

getTutureTutorialsInfo<string, object>(['hello tuture'], { username: 'tuture'})
复制代码

上面我们用类型变量 U 注解了 profile 参数,但我们在使用 profile 的时候,依然不知道它是什么类型,也就是说泛型虽然解决了类型的可复用性,但是还是不能让我们写代码时获得自动补全的能力😭

重申:没有补全的 TypeScript 代码是没有生命的!

那么我们如何让在既使用泛型的同时,还能获得代码补全了?答案相信你也猜到了, 那就是我们这一节要讲的泛型约束。 我们修改 src/index.ts  里面的代码如下:

type Profile<T> = {
  username: string;
  nickName: string;
  avatar: string;
  age: T;
}

function getTutureTutorialsInfo<T, U extends Profile<string>>(info: T[], profile: U): T[] {
  console.log(info.length);
  console.log(profile);
  return info;
}

复制代码

可以看到,我们复用了之前定义的 getTutureTutorialsInfo 和 Profile ,但是在 getTutureTutorialsInfo 泛型中第二个类型变量做了点改进,之前只是单纯的 U ,现在是 U extends Profile<string> , Profile<string> 表示调用类型别名泛型生成一个 age 为 string 的新类型别名,然后通过 U extends ... 的方式,用 Profile<string> 来限制 U 的类型,也就是 U 必须至少包含 Profile<string> 的类型。

这个时候,我们在 VSCode 编辑器里面尝试输入 profile. ,应该可以神奇的发现,有了自动补全:

image.png
并且还能了解到 age 是 string 属性!

再次!有了代码补全的 TS 充满了活力🔥!

当然这里的用于约束的 Profile<string> 可以是一个类型别名,也可以是一个接口,也可以是一个类:

class Profile<T>  {
  username: string;
  nickName: string;
  avatar: string;
  age: T;
}

// 或者
interface Profile<T>  {
  username: string;
  nickName: string;
  avatar: string;
  age: T;
}

// 或者
type Profile<T> = {
  username: string;
  nickName: string;
  avatar: string;
  age: T;
}
复制代码

更近一步,这里的用于约束类型变量的类型可以是一些更加高级的类型如联合类型、交叉类型等:

type Profile<T> = {
  username: string;
  nickName: string;
  avatar: string;
  age: T;
}

type Tuture = {
	github: string;
  remote: string[];
}

function getTutureTutorialsInfo<T, U extends Profile<string> & Tuture>(info: T[], profile: U): T[] {
  console.log(info.length);
  console.log(profile);
  return info;
}

复制代码

可以看到我们使用了 Profile<string> 和 Tuture 的交叉类型来约束 U ,在我们的 VSCode 编辑器里面应该会有如下补全效果:

image.png

深入实践,注解构造函数

在了解泛型的基础知识,并且结合函数、接口、类型别名和类进行结合使用之后,相信你对如何使用泛型已经有了一点经验了。

而了解了泛型,你就可以开始尝试深入 TS 类型编程的世界了!接下来我们开始深入一下高阶的 TS 类型编程知识,并尝试讲解一些比较边缘情况如何进行类型注解。

我们需要一个 createInstance 函数,它接收一个类构造函数,然后返回此类的实例,并能在调用之后获得良好的代码补全提示(!很重要),并且此函数还需要有足够好的通用性能处理任意构造函数(!泛型) 。我们尝试在 src/index.ts  里面编写一个类以及一个创建此类实例的方法:

class Profile<T> {
  username: string;
  nickName: string;
  avatar: string;
  age: T;
}

class TutureProfile extends Profile<string> {
	github: string; 
  remote: string[];
}

function createInstance(B) {
  return new B();
}

const myTutureProfile = createInstance(TutureProfile);
复制代码

不要问我为什么 createInstance 的参数是 B ,因为我们最后很 new B() 。😁

当我们编写了上面这个 createInstance 时,当我们尝试在调用之后输入 . : createInstance(TutureProfile). ,发现编辑器里面没有补全提示实例化对象的相关属性如 username 等

image.png
首先我们来解析一下构造函数的样子,因为 TS 类型是鸭子类型,是基于代码的实际样子来进行类型注解的。构造函数是可被实例化的函数,即可以通过 new XXX() 进行调用来创建一个实例,所以构造函数的注解应该类似这样:

interface ConstructorFunction<C> {
 	 new (): C;
}
复制代码

即形如 new (): C 的函数形式,表示可以通过调用 new XXX() 生成一个 XXX 的实例。即某个类:

class Profile<T> {
  username: string;
  nickName: string;
  avatar: string;
  age: T;
}
复制代码

我们注解其构造函数类似下面:

const profileConstructor: ConstructorFunction<Profile<string>> = Profile;
复制代码

这里有同学还记得嘛,我们在上一篇文章中讲到一个类在声明的时候会声明两个东西:1)用于注解此类实例的类型 2)以及此类的构造函数。这个例子是用来表达类在声明时声明的这两样东西的最佳例子之一即:

  • ConstructorFunction 接口泛型接收的 C 用来注解 new () 生成的实例,此为第一:用于注解此类实例的类型。
  • 用于注解 Profile 的构造函数的类型 ConstructorFunction<Profile<string>> ,在注解 profileConstructor 变量之后,其初始化赋值是 Profile 本身,并且你可以在你的 VSCode 编辑器里面编写上面的代码,应该不会报错,这说明了第二:声明了此类的构造函数。

了解了构造函数如何进行类型注解之后,我们来完成第三点要求,让这个 createInstance 更具通用性,二话不说,泛型走起!最终代码如下:

class Profile<T> {
  username: string;
  nickName: string;
  avatar: string;
  age: T;
}

class TutureProfile extends Profile<string> {
	github: string; 
  remote: string[];
}

interface ConstructorFunction<C> {
 	 new (): C;
}


function createInstance<A extends Profile<string>>(B: ConstructorFunction<A>) {
  return new B();
}

const myTutureProfile = createInstance(TutureProfile);
复制代码

现在你在 VSCode 编辑器 createInstance(TutureProfile) 后面输入 . 应该可以看到代码补全:

image.png
这个例子其实关于 extends 类型约束那一块有点多余,但是为了组合我们在这一篇里面学到的知识,所以我额外把它也加上了,可以看到我们重拾了所有的代码补全,代码补全🐂🍺

上面类中如 remote 等属性会有红色下划线是因为报了 Property 'remote' has no initializer and is not definitely assigned in the constructor.ts(2564) ,字面意思就是没有初始化这些属性,这个不重要,可以通过配置移除,也可以初始化。It's your choice!

参考资料

想要学习更多精彩的实战技术教程?来图雀社区逛逛吧。