TypeScript 简介

TypeScript 是由微软开发的开源编程语言(后面简称 TS),是 JavaScript 的超集(后面简称 JS),由于 JS 过于灵活,如果使用不当,在长期维护迭代的大型企业应用中,会存在潜在的 Bug 和风险,而 TS 更适合大型企业应用,是因为 TS 需要编译成 JS 运行,所以在编译阶段可以进行语法错误,类型错误检查,提前知道代码中潜在的问题,不至于等到代码运行时报错。


TypeScript 与 JavaScript 关系图
TypeScript 与 JavaScript 关系图


TS 是前端开发工程化新的趋势,目前很多的开源项目内部都是用 TS 编写,也有很多大牛在推广 TS,目前使用 TS 开发的典型项目有 VSCodeAngular6Vue3React16 等。

如何使用 TS

安装

在使用 TS 之前应该先进行全局安装,安装 TS 的命令:

$ npm install typescript -g

全局安装后会提供 tsc 命令,tsc -v 可以查看当前安装 TS 版本。

编译

我们可以通过命令单独对文件进行编译,也可以编译整个项目的 TS 文件。

编译单个文件:

$ tsc <filename>

块级作用域变量名检测,两个文件(无论 JS 还是 TS)不允许有相同的变量名。

编译整个项目的 TS 文件需要在项目中设置 tsconfig.json 的配置文件,快速生成配置文件命令如下:

tsc --init

生成 TS 配置文件以后,编译命令 tsc 不需要再指定文件名,会默认读取 tsconfig.json 文件的配置进行编译,关于 tsconfig.json 属性详解请看(TO DO)。

TS 文件发生变化时,可以通过 --watch 参数进行实时监听,并当 TS 文件变化时实时编译,也可以把命令配置在 package.json 中通过 npm 执行。

/* package.json 文件 */
{
  "scripts": {
    "build": "tsc",
    "start": "tsc --watch"
  }
}

TS 的数据类型

TS 中提供了强大的类型系统,编译时帮助我们对代码中定义的数据类型和值进行检查,如果使用支持 TS 比较好的编辑器,如 VSCode,可以在代码编写时根据智能提示及时发现错误。

基本数据类型

基本数据类型定义

TS 中包含了 JS,所以 TS 也有 6 种基本数据类型,stringnumberbooleanundefinednullsymbol

let name: string = 'panda';
let age: number = 18;
let merried: boolean = false;
let un: undefined = undefined;
let nu: null = null;
let sym: symbol = Symbol();

上面声明变量的值与所定义的数据类型必须严格符合,否则编译时报错(编辑器默认就会报错,后面统一说成报错)。

undefinednull 是其他基本类型的子类型:

  • 类型定义为 undefined 的变量只能赋值 undefined
  • 类型定义为 null 的变量只能赋值 null
  • 类型定义为除 undefinednull 以外的其他类型,可以赋值为 undefinednull
/* 默认会报错 */
let name: string = undefined;
let age: number = null;

其他类型的赋值也必须严格与其定义相对应(默认行为,也被叫做严格类型检查模式),可以通过配置 tsconfig.json 文件 compilerOptionsstrictNullChecks 属性值为 false 跳过严格检查。

/* tsconfig.json 文件 */
{
  "compilerOptions": {
    "strictNullChecks": false
  }
}

数据类型 any

希望定义的数据类型可以赋值任何类型的值,可以将数据类型定义为 any,这样相当于没有使用类型校验,等同于直接使用 JS(戏称 AnyScript)。

let value: any = 18;
value = 'hello world';
value = true;
value = null;
value = undefined;
value = Symbol();

上面代码中的赋值操作不会报任何错误,因为变量的值类型定义为了 any

类型推论

TS 中,如果定义的变量没有定义类型,则会对变量的类型进行推测,同样可以在代码编译阶段按照推测的类型校验。

let num = 10;
num = 'hello'; // 报错,不能将类型“"hello"”分配给类型“number”

let str = 'world';
str = 1; // 报错,不能将类型“1”分配给类型“string”

let value; // let value: any

类型推论规则:

  • 从报错的结果可以看出,TS 中声明变量如果没有指定值的类型,则会默认将声明变量时赋值数据的类型定义为该变量的值类型;
  • 当声明变量没有直接赋值时,TS 会将变量的值类型定义为 any

基本包装类

TS 也包含基本包装类,基本数据类型是没有方法的(只有对象可以调用对应原型上的方法),在基本数据类型调用方法时是先进行包装过程,把基本类型包装成对象类型。

/* 基本数据类型调用方法的包装 */
let num = 10;
num.toFixed(2);

// new Number(num).toFixed(2);

联合类型

有些时候定义的变量在不同场景会赋不同类型的值,我们想要指定这个变量只接受某几个固定类型的值,可以使用联合类型的方式。

/* 联合类型 */
let value: string | number = 'hello';
value = 10;
value = true; // 报错

被定义联合类型的变量可以指定符合联合类型中任意一种类型的值,定义时不同的数据类型用 | 隔开,当被赋值不符合联合类型时会报错。

上面联合类型的写法如果多处相同会让代码冗余,在 TS 中提供了 type 关键字来声明类型,用法如下。

// 冗余的写法
let x: string | number | boolean = 'hello';
let y: string | number | boolean = 10;
let z: string | number | boolean = true;

// 更改后...
type MyType = string | number | boolean;
let x: MyType = 'hello';
let y: MyType = 10;
let z: MyType = true;

类型断言

被定义联合类型的变量可以通过类型断言指定为更具体的类型,不可以指定联合类型中不包含的类型。

let value: string | number | boolean;
value = 'hello';
value = 10;
value = true;

console.log((value as string).length); // 报错

断言的语法是将变量使用 as 关键字指定要断言的类型,上面代码中因为变量 value 值最后已经是布尔,而强行将 value 指定为 string 类型去获取 length 属性报错,因为布尔值无法调用 length 属性。

值的联合

值的联合(又叫字面量联合)与联合类型不同的是,联合类型只是指定了变量的值类型必须为哪几种,而值的联合则限定了变量的值,变量赋值必须是值的联合中的其中某一个,否则报错。

let point: 1 | 6 | 10;
point = 10;
point = 'hello'; // 报错

let level: 'A' | 'B' | 'C';
level = 'B';
level = 'b'; // 报错

数组

TS 中,数组是引用类型,定义一个数组类型时需要定义数组内部元素的类型。

/* 普通数组类型定义 */
let names: string[] = ['Jim', 'Peter'];
let ages: number[] = [18, 20];
/* 泛型定义(泛型会在后面详细说明) */
let names: Array<string> = ['Jim', 'Peter'];
let ages: Array<number> = [18, 20];

上面两种方式定义的数组内元素类型必须统一,如果数组内要支持多种数据类型则可以使用联合类型或元组类型。

let data: Array<string | number> = ['James', 25];

元组

如果让数组内部元素类型不同该怎么办,在 TS 中有一种特殊的数组类型定义叫做 “元组类型”(tuple)。

/* 元组类型定义 */
let people: [string, number] = ['Jim', 18];

元组的越界问题:

let tuple: [string, number] = ['hello', 100];

tuple.push(false);
console.log(tuple); // ['hello', 100, false]
tuple[2] // 报错

元组类型可以越界添加元素,如使用数组的 push 方法,但是访问越界元素会报错,强烈不建议让元组越界。

元组类型和数组类型的特点如下表:

元组数组
元素可以是不同类型元素必须为相同类型
有预定的长度没有预定的长度
用于表示一个结构用于表示一个列表


对象

TS 可以通过 object 来声明对象类型。

let obj: object = {x: 1, y: 2};
obj.x = 3; // 报错

object 类型并不能定义对象上具体属性的类型,所以对 x 属性重新赋值会报错,也可以在创建对象时直接定义属性的类型如下:

let obj: {x: number, y: number} = {x: 1, y: 2};
obj.x = 3

对象类型内部成员的类型很少使用上面的方式,通常使用接口(后面介绍)进行定义。

枚举

枚举类型是 JS 中所不包含的数据类型,通过 enum 关键字定义,在业务需求中经常会出现某个业务类型对应固定的值,前后端交互的参数都是通过这个值进行约束和传递的。

数字枚举

enum Gender {
  BOY,
  GIRL
}
/* 编译后 */
var Gender;
(function (Gender) {
  Gender[Gender["BOY"] = 0] = "BOY";
  Gender[Gender["GIRL"] = 1] = "GIRL";
})(Gender || (Gender = {}));

从编译结果来看,枚举类型编译成 JS 后帮助我们创建了一个类似于映射表的同名对象(实现原理,反向映射),所以可以在 TS 中通过对象属性的方式获取枚举的值。

/* 获取枚举值 */
console.log(Gender.BOY); // 0
console.log(Gender[1]); // "GIRL"

如果给第一个枚举值设置数值类型的初始值,则后面的枚举值会依次递增。

字符串枚举

enum Week {
  MONDAY = '1',
  TUESDAY = '2'
}
/* 编译后 */
var Week;
(function (Week) {
  Week["MONDAY"] = "1";
  Week["TUESDAY"] = "2";
})(Week || (Week = {}));

字符串枚举是在枚举过程中给枚举项明确赋值,值类型为字符串类型。

异构枚举

异构枚举是将数字枚举和字符串枚举混合使用(容易引起混淆,不建议使用)。

enum Answer {
  N,
  Y = 'Yes'
}

常量枚举

常量枚举的语法是在创建枚举的 enum 关键字前使用 const 声明。

const enum Colors {
  RED,
  YELLOW,
  BLUE
}

let colors: Array<number> = [Colors.RED, Colors.YELLOW, Colors.BLUE];
/* 编译后 */
var colors = [0 /* RED */, 1 /* YELLOW */, 2 /* BLUE */];

常数枚举与其他类型枚举的不同是,在编译阶段不会为枚举的类型创建对象,使用枚举类型值的位置直接编译成对应的枚举值。

枚举成员

枚举成员主要分为两类:

  • const member:以编译阶段计算结果,以常量的形式出现在运行时环境;
    • 没有初始值的枚举值;
    • 对已有枚举成员的引用;
    • 常量的表达式。
  • computed member:编译阶段不会计算,会被保留在程序的执行阶段。
    • 动态计算的表达式;
    • 后面的枚举值必须赋值初始值。
enum Char {
  // const member
  a, // 无初始值
  b = Char.a, // 对已有成员的引用
  c = 1 + 2, // 常量表达式
  // computed member
  d = Math.random(),
  e = '123'.length,
}
Char.a = 1 // 报错

枚举成员的值为只读类型,在定义后不能重新赋值。

函数

参数类型定义

function sum(a: number, b: number) {
  return a + b;
}

sum(1, 2); // 3

在函数中经常会定义可选参数,即非必传,但是 TS 中函数如果按照上面方式定义在调用时不传会报错,可选参数的类型定义如下。

/* 可选参数 */
function people(name: string, age?: number) {
  console.log(name);
}

people('jim');

使用 ?: 替代 :,即代表该参数为可选参数,在 TS 中也支持使用默认参数和剩余参数,使用方式如下。

/* 默认参数 */
function fn(sum: number = 0) {
  console.log(sum);
}

fn(); // 0
/* 剩余参数 */
function sum(prefix: string, ...args: number[]) {
  return prefix + args.reduce((sum, val) => sum + val, 0);
}

sum('$', 1, 2, 3); // 6

在使用默认参数和剩余参数时,设置的参数初始值和传入的剩余参数与定义类型不符合,则会报错。

返回值类型定义

function sum(a: number, b: number): number {
  return a + b;
}

特殊的返回值类型:

  • never:是其他类型的子类型,代表不会出现的值,作为没有返回值的返回类型,函数无法执行完成;
  • void:表示没有任何类型,指函数没有返回值,函数可以正常执行完,如果一个函数没有返回值,则 TS 认为返回值为 void 类型。
/* never 为返回值类型的函数 */
function fn1(): nerver {
  throw new Error('报错了');
  console.log(1);
}

function fn2(): nerver {
  while (true) {}
  console.log(1);
}

上面两个函数返回值设置为 never,因为抛错和死循环导致都没有执行完毕,此时函数编译不会报错。

/* void 为返回值类型的函数 */
function fn1(): void {
  console.log(1);
}

function fn2(): void {
  return null;
}

上面的 fn2 函数只在非严格检查模式下不会报错,非严格检查模式下返回值类型被定义为 void 的函数可以返回 nullundefined

函数表达式

之前的函数参数和返回值类型定义是函数声明的方式,声明函数同样有另外一种方式,函数表达式,即给变量赋值为函数,那如何为接收函数的变量定义类型呢?

let fullName: (x: string, y: string) => string;

fullName = function (firstName:string, lastName:string): string {
  return firstName + lastName;
}

括号中 xy 代表定义参数的类型,箭头后代表定义返回值的类型,也可以使用 type 关键字进行声明。

type Fn = (x: string, y: string) => string;

let fullName: Fn = function (firstName: string, lastName: string): string {
  return firstName + lastName;
}

赋值函数的参数类型必须与变量声明的函数参数类型严格一致,赋值函数的返回值类型必须与变量声明的函数返回值类型严格一致。

函数重载

函数重载是强类型语言中的特性,在 Java 中代表多个同名函数参数不相同,而在 TS 中有所不同,代表为同一个函数提供多个参数类型及返回值定义。

// 联合类型
type MyType = string | number | boolean

// 重载限定函数 double
function double(val: string): string;
function double(val: number): number;
function double(val: boolean): boolean;

// 函数
function double(val: MyType) {
  if (typeof val === 'string') return val + val;
  if (typeof val === 'number') return 2 * val;
  if (typeof val === 'boolean') return !val;
}

double('hello'); // hellohello
double(5); // 10
double(true); // false

重载限定某个函数的方式只有函数声明、函数名、参数及返回值的类型,而没有函数体。

类的定义

TS 类的定义与 JS 相同,使用 class 关键字声明,但可以直接对属性、方法定义参数类型和返回值类型。

class Person {
  name: string
  getName(): void {
    console.log(this.name)
  }
}

let p = new Person();
p.name = 'neil';
p.getName(); // neil

存取器

TS 中,我们可以通过存取器来改变一个类中属性的读取和赋值行为,并可以定义存取器的参数及返回值类型。

class Person {
  myName: string,
  constructor(myName: string) {
    this.myName = myName;
  }
  get name(): string {
    return this.myName;
  }
  set name(newVal: string) {
    this.myName = newVal;
  }
}

let p = new Person('neil');
console.log(p.name); // neil

p.name = 'jim';
console.log(p.name); // jim

参数属性

上面都在类中定义属性,也可以使用参数属性进行简化,代码如下。

/* 简化前 */
class Person {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}
/* 简化后 */
class Person {
  constructor(public myName: string) {}
}

let p = new Person('neil');
console.log(p.myName); // neil

public 修饰符写在 constructor 的参数前,代表创建一个与参数同名的公有属性。

只读属性

TS 中可以在类中定义只读属性,只需要通过 readonly 修饰符修饰即可,只在编译阶段进行检查。

class Person {
  constructor(public readonly myName: string) {
    this.myName = myName;
  }
}

let p = new Person('neil');
console.log(p.myName); // neil
p.myName = 'jim'; // 报错

不同类别的(非互斥)的修饰符可以同时修饰一个变量,使用空格隔开。

类的继承

类与类之间通过 extends 关键字实现继承,子类继承父类后拥有父类的属性和方法,可以增强代码的复用性。

// 父类
class Parent {
  name: string;
  age: number;
  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }
  getName(): string {
    return this.name;
  }
  setName(name: string): void {
    this.name = name;
  }
}

// 子类继承父类
class Child extends Parent {
  hobby: string;
  constructor(name: string, age: number, hobby: number) {
    super(name, age);
    this.hobby = hobby;
  }
  getHobby(): number {
    return this.hobby;
  }
}

let c = new Child('neil', 20, 'swim');
console.log(c.getHobby()); // swim
console.log(c.getName()); // neil
console.log(c.getAge()); // 20

访问控制修饰符

TS 的类中有三种访问控制修饰符:

  • public:公有的属性,所有地方都能访问;
  • protected:受保护的属性,不能被实例化只能被继承,只能在自己和自己的子类中被访问;
  • private:私有属性,既不能被实例化也不能被继承,只有自己内部可以访问。
// 父类
class Parent {
  public name: string;
  protected age: number;
  private money: number;
  constructor(name: string, age: number, money: number) {
    this.name = name;
    this.age = age;
    this.money = money;
  }
  getName() {
    console.log('父类:' + this.name);
  }
  getAge() {
    console.log('父类:' + this.age);
  }
  getMoney() {
    console.log('父类:' + this.money);
  }
}

// 子类
class Child extends Parent {
  constructor(name, age, money) {
    super(name, age, money);
  }
  getName() {
    console.log('子类:' + this.name);
  }
  getAge() {
    console.log('子类:' + this.age);
  }
  getMoney() {
    console.log('子类:' + this.money); // 报错
  }
}

在上面代码中 name 属性可以通过父类的实例访问、子类的实例访问,父类的 getName 方法访问,子类的 getName 方法访问,age 属性可以只能通过父类和子类的 getAge 方法访问,money 属性只能通过父类 getMoney 方法访问,其他不被允许的访问方式都会报错。

静态属性和方法

TS 中给类定义私有属性和方法的修饰符为 static,与 JS 的类相同。

class Father {
  static myName: string = 'hello';
  static getMyName(): string {
    return Father.myName;
  }
}

console.log(Father.myName); // hello
console.log(Father.getMyName()); // hello

抽象类

抽象类是一种抽象的概念,使用 abstract 关键字定义,无法被实例化(无法使用 new),只能被继承。

抽象类的内部包含抽象方法和抽象属性,同样使用 abstract 关键字定义,抽象方法不能在抽象类中实现,具体实现细节只能在抽象类的子类中实现,且必须实现。

abstract class Animal {
  abstract name: string;
  abstract speak();
}

class Cat extends Animal {
  name: string;
  speak() {
    console.log('喵喵喵');
  }
}

class Dog extends Animal {
  name: string;
  speak() {
    console.log('汪汪汪');
  }
}

继承抽象类的子类中必须包含所有抽象类中所定义的内容,继承过程,子类覆盖抽象类所定义抽象方法的行为叫做 “重写”,多个子类创建不同的抽象方法的现象被叫做 “多态”。

接口

接口是 TS 中的核心之一,主要有两个作用:

  • 用于描述或约束一种对象结构,描述属性的名称和值的类型;
  • 用来表示行为的抽象,让类去实现接口。

接口的定义和使用

TS 中使用 interface 关键字来定义接口,接口成员可以是属性或方法名,多个成员可以使用分号、逗号或换行隔开,主要定义属性的名称和值的类型。

/* 定义接口,使用接口创建对象 */
interface UserInterface {
  name: string;
  age: number;
}

let user: UserInterface = {
  name: 'hello',
  age: 20
};

上面 UserInterface 接口创建的对象 user,属性和值的类型必须与定义的接口严格对应。

/* 让类去实现定义的接口 */
interface Flyable {
  fly(): void;
}

class Bird implements Flyable {
  fly() {
    console.log('bird fly');
  }
}

让类实现一个接口使用 implements 关键字,接口在定义方法时与抽象类非常类似,只需要定义方法和类型,不需要具体实现,在实现接口的类中对方法进行具体实现。

/* 一个类实现多个接口 */
// 接口
interface Speakable {
  name: string;
  speak(): void;
}

interface Eatable {
  food: string;
  eat(): void;
}

// 类
class Person implements Speakable, Eatable {
  name: 'hello';
  food: 'cake';
  speak() {
    console.log('say hello');
  }
  eat() {
    console.log('eat cake');
  }
}

继承与实现接口的区别:

  • 一个类可以实现多个接口,一个接口可以被多个类实现;
  • 而一个父类(包含抽象类)可以被多个类所继承,一个子类只能继承一个父类(单继承)。

接口的只读属性

接口中可以定义只读属性,这样通过接口创建对象的值在修改时会报错,可以在定义接口属性是加上 readonly 修饰符实现。

/* 接口的只读属性 */
interface Person {
  id: number;
  readonly name: string;
}

let p: Person = {
  id: 1;
  name: 'hello';
};

console.log(p.id); // 1
p.name = 2; // 报错

接口的可选属性

在上面接口定义时,创建的对象和实现接口类的属性方法都必须与接口定义的属性名、类型一致,其实在接口定义时也可以定义一些非必须的可选属性,在使用接口创建对象或使用类实现接口时,这样的属性不定义不会报错。

/* 接口的可选属性 */
interface Person {
  id: number;
  name: string;
  age?: number;
}

let p1: Person = {
  id: 1,
  name: 'hello',
  age: 20
};

let p2: Person = {
  id: 2,
  name: 'world'
};

定义接口的可选属性与函数定义可选参数类似,都是使用 ?: 替代原本的 : 即代表可选。

接口的未知属性

当接口中存在可选属性时,也只是满足某些属性可以在使用接口时(对象、类),可选属性可以不创建,但是如果对象和类上扩展了未知的其他任意属性,则编译会报错,此时定义接口未知属性,可以解决编译时报错的问题。

interface Person {
  id: number;
  name: string;
  [proName: string]: any;
}

let p: Person = {
  id: 1,
  name: 'hello',
  age: 20,
  city: 'Beijing'
};

proName 名字是任意的,可以随意取,string 代表属性的类型,any 代表属性值的类型,上面的写法不固定,可以根据需求定义,只需满足 [name: type]: type 的结构即可。

根据上面的未知属性格式可以衍生出 “可索引接口” 专门用来限制长度未知的数组、属性名和属性个数未知的对象。

可索引接口限制数组和对象:

/* 限制数组 */
interface UserInterface {
  [index: number]: string;
}

let userArr: UserInterface = ['a', 'b', 'c'];
userArr = ['a', 'b', 1]; // 报错

上面的数组 userArr 内的的索引为数字类型,满足可索引接口的要求,但是使用 UserInterface 约束后,所有项必须是字符串,如果改成其他类型则会报错。

/* 限制对象 */
interface UserInterface {
  [index: string]: string;
}

let userObj: UserInterface = {
  jim: '1',
  bob: '2'
};

上面的对象 userObj 使用 UserInterface 约束后,键和值必须都为字符串,否则报错。

接口的继承

接口的继承同类的继承相同,使用 extends 关键字实现,下面是一段接口继承的代码。

// 父接口
interface Speakable {
  speak(): void;
}

// 子接口
interface SpeakChinese extends Speakable {
  speakChinese(): void;
}

// 类实现接口
class ChinesePerson implements SpeakChinese {
  speak() {
    console.log('speak');
  }
  speakChinese() {
    console.log('你好');
  }
}

当类实现的接口继承了其他的接口,那这个类的内部需要同时实现子接口和父接口的属性和方法。

函数型接口

函数型接口专门对函数或方法传入的参数和返回值进行约束。

interface Discount {
  (price: number): number;
}

function discount(price: number): number {
  return price * 0.8;
}

const dFun: Discount = discount;

上面代码中 Discount 接口内部括号内代表参数类型定义,后面代表返回值类型,函数 discount 定义的参数和返回值类型必须与 Discount 接口内部定义的类型保持一致。

构造函数型接口

TS 中存在对构造函数或类的实例化的类型约束,即在执行 new 操作的时候进行约束检查。

// 被约束实例化动作的类
class Animal {
  constructor(public name: string) {}
}

// 约束实例化的接口
interface WithNameClazz {
  new (name: string): Animal;
}

// 工厂函数
function createAnimal(Clazz: WithNameClazz, name: string) {
  return new Clazz(name);
}

let animal = createAnimal(Animal, 'hellop');

上面代码的 interface 中,new 代表约束的动作为实例化操作,name 为参数,string 为参数的类型,而 Animal 代表返回值需要是一个 Animal 类的实例。

泛型

“泛型” 是指在定义函数、接口和类的时候,不预先指定具体的类型,而在使用的时候再进行指定的一种特性。

泛型函数

在介绍泛型函数之前先创建一个普通的函数,参数为长度和值,返回一个长度为传入长度、内部元素都为传入值的数组。

/* 为使用泛型定义的函数 */
function createArray(len: number, val: any): any[] {
  const result: any[] = [];
  for (let i = 0; i < len; i++) {
    result[i] = val;
  }
  return result;
}

console.log(createArray(3, 'x')); // ['x', 'x', 'x']

在上面函数中我们类型都是提前进行定义,包括参数、返回值和函数内部变量,这样如果函数内给数组每一项赋值操作没有使用 val 参数传入的值,而是使用其他值,这样无法进行限定,下面使用泛型重新编写上面的函数。

function createArray<T>(len: number, val: T): T[] {
  const result: T[] = [];
  for (let i = 0; i < len; i++) {
    result[i] = val;
  }
  return result;
}

console.log(createArray<string>(3, 1)); // 报错

上面的 T 是泛型的占位符,代表 Type 的意思,也可以使用其他字母代替(类比函数的形参),真正定义类型的时候是在函数调用时传入的。

泛型类

class MyArray<T> {
  list: T[] = [];
  add(val: T) {
    this.list.push(val);
  }
  getFirst(): T {
    return this.list[0];
  }
}

let myArray = new MyArray<number>();
myArray.add(1);
myArray.add(2);
console.log(myArray.getFirst());

泛型类与泛型函数相似,都是通过占位符 T 占位,在真正实例化的时候传入类型。

泛型接口

上面接口一节中,接口内部属性及函数型接口等,成员类型也可以使用泛型进行约束,在某个具体的对象使用接口或类实现接口时传入具体类型,下面是一个函数型接口使用泛型的例子。

interface SUM<T> {
  (a: T, b: T): T
}

const sum: SUM<number> = function (a: number, b: number): number {
  return a + b;
}

泛型占位符(上面为 T)只在约束的函数、类和接口内部可以使用,可类比函数的形参。

默认泛型类型

默认泛型类型指的是定义泛型的默认值,被约束的函数、类或者接口在使用时不传入具体类型,则会使用默认类型,编译时会检查是否符合这个默认类型。

class MyArray<T = number> {
  list: T[] = [];
  add(val: T) {
    this.list.push(val);
  }
}

let myArray = new MyArray();
myArray.add(1);
myArray.add('a'); // 报错

定义多个泛型

上面的泛型中都只使用了一个占位符 T,其实泛型是允许有多个的。

function swap<A, B>(tuple: [A, B]): [B, A] {
  return [tuple[1], tuple[0]];
}

console.log(swap<string, number>(['a', 1])); // [1, 'a']

上面方法中定义了两个泛型,函数参数为一个元组类型的数组,函数返回值为两项交换后的数组。

泛型的约束

在函数或类中使用泛型,由于预先不知道泛型的具体类型,所以不能随便使用泛型约束变量的属性和方法。

function logger<T>(val: T): void {
  console.log(val.length); // 报错
}

泛型的继承

如果一定要在函数或类中使用泛型约束变量的属性和方法时,可以预先通过接口定义使用的属性和方法,再通过泛型去继承这个接口即可。

// 接口
interface LengthWise {
  length: number;
}

// 函数
function logger<T extends LengthWise>(val: T): void {
  console.log(val.length);
}

logger<number>('hello'); // 报错
logger<string>('hello'); // 5

泛型的流程控制

在定义泛型的时候能够加入逻辑分支,可以使泛型更加灵活。

interface Fish {
  nameFish: string;
}

interface Water {
  nameWater: string;
}

interface Bird {
  nameBird: string;
}

interface Sky {
  nameSky: string;
}

type Condition<T> = T extends Fish ? Water : Sky;

let con: Condition<Fish> = {
  nameWater: 'hello'
}

上面定义了四个接口 FishBirdWaterSky,定义一个 Condition 类型和泛型,如果泛型为 Fish 则继承 Water,否则继承 Sky,在变量 con 使用类型 Condition 时,传入 Fish 接口。

泛型的别名

泛型可以使用 type 关键字定义别名,还记得在基本数据类型一节数组的定义中有泛型的定义方式。

let arr: Array<string> = ['a', 'b', 'c'];

其实上面的 Array 就是定义泛型的别名,<string> 则是在调用这个泛型时传入的具体类型,看了下面例子就明白了。

// 定义泛型别名
type Cart<T> = { list: T[] } | T[];

// 使用泛型别名
let cart1: Cart<string> = ["a", "b", "c"];
let cart2: Cart<string> = {
  list: ["a", "b", "c"]
};

JS 中有很多类数组对象,如 arguments、获取的 DOM 元素集合等等,每一种类数组对象 TS 都定义了对应的泛型别名,下面看两个案例。

/* arguments 对象的泛型 */
function sum(...args: number[]) {
  let params: IArguments = arguments;
  let result = 0;
  for (let i = 0; i < params.length; i++) {
    result += params[i];
  }
  return result;
}
/* DOM 节点类数组对象的泛型 */
let root = document.getElementById('root');
let children: HTMLCollection = root.children;
let childNodes: NodeListOf<ChildNode> = root.childNodes;

上面用来定义 arguments 类数组对象的泛型别名为 IArguments,获取 DOM 节点的元素节点集合的泛型别名为 HTMLCollection,而全部子节点的泛型别名为 NodeListOf<ChildNode>,如果使用 VSCode 编辑器,可以将鼠标放在变量前面会自动提示对应的泛型别名。

接口和泛型别名的区别:

  • 接口会创建一个新的名称,而别名不会(只是用 type 关键字创建了一个变量);
  • 别名不能被继承和实现;
  • 定义一个类型的时候使用接口,要使用联合类型或者元组类型时,泛型别名会更合适。

结构类型系统

如果传入的变量和声明的类型不匹配,TS 会进行兼容性检查,不是基于定义的类型名称来决定的,而是基于类型的组成结构。

基本数据类型的兼容性

基本数据类型也有兼容性判断,如果赋值过程中右侧值的类型符合左侧值的类型,则不会报错,如果左侧类型定义含有 toString 方法的约束,则右侧值凡是可以通过 toString 转换成字符串的都不会报错,赋值时如果右侧值传入的属性多余左侧定义的值时也会报错。

let num1: string | number;
let str1: string;
num1 = str1;

let num2: {
  toString(): string;
};
let str2: number;
num2 = str2;

type People = {
  name: string;
  age: number;
}

let p: People = {
  name: 'hello',
  age: 20,
  gender: 'male' // 报错
};

枚举的兼容性

默认的枚举类型与数字类型兼容,数字类型与枚举类型兼容,不同的枚举类型之间是不兼容的。

enum Colors {
  RED,
  YELLOW,
  BLUE
}

// 兼容数字类型
let colorRed: number = Colors.RED;

// 兼容枚举类型
let colorYello: Colors;
colorYello = Colors.Red;
colorYello = 1;
// 所有枚举值没有初始值
enum E {
  a,
  b
}

// 所有枚举值初始值都为数值
enum F {
  a = 1,
  b = 2
}

// 所有枚举值类型都为字符串
enum G {
  a = 'apple',
  b = 'banana'
}

赋值可以超出枚举值范围:

let e: E = 3; // 不报错

不同枚举约束的变量不可以进行比较:

let e: E = 1;
let f: F = 2;

console.log(e === f); // 报错

相同枚举不同枚举值约束的变量不可以进行比较:

let e1: E.a = 1;
let e2: E.b = 2;
let e3: E.a = 1;

console.log(e1 === e2) // 报错
console.log(e1 === e3) // true

字符串枚举和字符串枚举值约束赋值:

// 字符串枚举约束赋值必须是类型中的枚举值
let g1: G = G.a

// 字符串的枚举值约束赋值必须是这个枚举值
let g2: G.b = G.b

函数的兼容性

比较函数的兼容性时要先比较函数的参数,再比较函数的返回值。

参数的兼容性

/* 参数的类型比较 */
type SumFunc = (a: number, b: number) => number;
let sum: SumFunc;

// 不报错的赋值
sum = function(a: number, b: number): number {
  return a + b;
}

sum = function(a: number): number {
  return a;
}

sum = function(): number {
  return 0;
}

// 报错的赋值
sum = function(a: number, b: number, c: number): number {
  return a + b + c;
}

函数的类型检查可以兼容少传参数或不传参数,但是多传参数会报错。

参数的双向协变

参数的 “双向协变” 是指变量定义的参数类型去兼容变量赋值定义的参数类型,或者变量赋值的参数类型去兼容定义的参数类型,只要有一个成立即可,所谓兼容必须是包含的关系。

type LogFunc = (val: number | string) => void;
let log: LogFunc;

// 变量定义类型兼容赋值定义类型
log = function (val: string) {
  console.log(val);
}

// 变量赋值类型兼容变量定义的类型
log = function (val: number | string | boolean) {
  console.log(val);
}

返回值的兼容性

/* 返回值的类型比较 */
type GetPerson = () => { name: string, age: number };
let getPerson: GetPerson;

// 不报错的赋值
getPerson = function () {
  return { name: 'hello', age: 20 };
}

getPerson = function () {
  return { name: 'hello', age: 20, gender: 1 };
}

// 报错的赋值
getPerson = function () {
  return { name: 'hello' };
}

// 可能调用 age 属性的方法
getPerson().age.toFixed(2);

返回值可以兼容多返回的属性,属性少了会报错,因为可能会调用返回值缺失属性的方法。

类的兼容性

class Parent {
  name: string;
}

class Child extends Parent {
  age: number;
}

let p1: Parent = new Parent();
let c1: Child = new Child();

let p2: Parent = new Child();
let c2: Child = new Parent(); // 报错

父类和子类能不能赋值给限制了父类或子类泛型的变量,能不能赋值和是父类或子类没任何关联,主要看属性是否满足,而上面 p2 没有报错的原因是子类的实例继承了父类的属性,所以提供了父类泛型所要求的属性,而 c2 报错是因为父类的实例并没有提供子类泛型要求的属性。

接口的兼容性

比较属性的兼容性

在检查参数类型时,并不是真正的比较接口类型,而是比较具体的属性是否兼容。

interface Person {
  name: string;
  age: number;
  gender: number;
}

let p: Person = {
  name: 'hello',
  age: 20,
  gender: 0
};

鸭式变形法

“鸭式变形法” 是很多动态语言的类型风格,指的是一只鸟如果看起来像鸭子,游起来像鸭子,叫起来像鸭子,这只鸟就可以被当做一直鸭子,回到 TS 中,传入接口的对象只要符合接口的必要条件,即传入的属性不必接口约束的少,就认为可以通过校验,不会报错。

上面的案例可以稍微做改造如下:

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

interface Person {
  name: string;
  age: number;
  gender: number;
}

let p: Person = {
  name: 'hello',
  age: 20,
  gender: 0
};

function getName(a: Animal): string {
  return a.name;
}

getName(p); // hello;

上面的代码中定义了两个接口 AnimalPersonPerson 定义的属性更多,当一个符合 Person 的对象传给参数用 Animal 约束的函数时,检测的是接口的属性,只要提供的属性不比约束的属性少,则不会报错。、

在实际的开发应用中,有一个常见的场景,就是前端代码要对后端返回的数据进行定义和约束,往往后端返回的数据及类型对于前端并不是全部必要的,则可以利用该特性使用接口对必要的字段进行兼容。

interface List {
  id: number;
  name: string;
}

interface Result {
  data: List[];
}

function render(result: Result) {
  result.data.forEach(({ id, name }) => {
    console.log(id, name)
  });
}

render({
  data: [
    {id: 1, name: 'A', sex: 'male'}, // 报错
    {id: 2, name: 'B'}
  ]
});

有一种特殊情况,就是直接传入对象字面量,则 TS 会对额外的字段进行检查,绕过检查的方式一共有三种:

  • 将对象字面量直接赋值给变量;
  • 使用类型断言;
  • 在定义接口时使用可索引签名;
// 第一种方式:对象字面量赋值给变量
let result = {
  data: [
    {id: 1, name: 'A', sex: 'male'},
    {id: 2, name: 'B'},
  ]
};

render(result);
// 第二种方式:使用类型断言
render({
  data: [
    {id: 1, name: 'A', sex: 'male'}, // 报错
    {id: 2, name: 'B'},
  ]
} as Result);

// 或

// React 中容易产生歧义
render(<Result>{
  data: [
    {id: 1, name: 'A', sex: 'male'}, // 报错
    {id: 2, name: 'B'},
  ]
});
// 第三种方式:定义接口使用可索引签名
interface List {
  id: number;
  name: string;
  [x: string]: any;
}

可索引接口的兼容性

用数值类型去索引一个接口,相当于给数组创建接口,可索引返回值的具体类型约束数组成员类型。

interface StringArray {
  [index: number]: string;
}

const chars: StringArray = ['A', 'B']

使用字符串类型作为可索引类型,则不能添加其他类型的属性:

interface Names {
  [x: string]: string;
  y: number; // 报错
}

可是使用字符串和数值类型同时作为可索引类型,数字类型返回值必须是字符串类型返回值的子类型:

interface Names {
  [x: string]: string;
  [y: number]: string;
}

泛型的兼容性

泛型在判断兼容性的时候会先判断具体类型,再进行兼容性的判断,即用到了就会比较,没用到就不会比较。

/* 空接口 */
interface Empty<T> {}

let x: Empty<string>;
let y: Empty<number>;
x = y;
/* 属性使用了泛型的接口 */
interface NotEmpty<T> {
  data: T;
}

let x: NotEmpty<string>;
let y: NotEmpty<number>;
x = y; // 报错,因为 number 类型的属性不能赋值给 string 类型的定义

// 等价于
interface NotEmptyString{
  data: string
}

interface NotEmptyNumber{
  data: number
}

let xString: NotEmptyString;
let yNumber: NotEmptyNumber;
xString = yNumber; // 报错

类型保护

类型保护就是一些表达式在编译时,能通过类型信息确保某个具体作用域内变量的类型。

typeof 和 instanceof 类型保护

/* typeof 类型保护 */
function double(val: string | number | boolean) {
  if (typeof val === 'string') {
    return val.repeat(2);
  }
  if (typeof val === 'number') {
    return val * 2;
  }
  if (typeof val === 'boolean') {
    return !val;
  }
}

上面方法因为使用 typeof 做了类型判断,所以分别在 if 判断的作用域内 val 的值会变成判断后对应的类型,可以调用类型对应的方法,而在判断的外面无法确认值的类型,调用方法编译时报错。

/* instanceof 类型保护 */
class Bird {
  nameBird: string;
}

class Dog {
  nameDog: string;
}

function getName(animal: Bird | Dog) {
  if (animal instanceof Bird) {
    return animal.nameBird;
  }

  if (animal instanceof Dog) {
    return animal.nameDog;
  }
}

typeof 类似,使用 instanceof 判断了函数 getName 是否是 BirdDog 类的实例,判断的作用域内可以获取判断结果对应类上定义的属性。

null 类型保护

之前我们提到过在 tsconfig.json 文件 compilerOptionsstrictNullChecks 属性设置为 true 时,则会对 null 进行严格检查,此时下面代码将会报错。

function getFirstLetter(str: string | null) {
  return str.charAt(0); // 报错
}

此时的报错就是由于对 null 的类型保护引起的,有如下解决方法。

/* 方法一 */
function getFirstLetter(str: string | null) {
  str = str || '';
  return str.charAt(0);
}
/* 方法二 */
function getFirstLetter(str: string | null) {
  if (str == null) {
    return '';
  }
  return str.charAt(0);
}

上面两种方式第一种是通过设置初始值的方式来保证在调用字符串方法时 str 的值已经是一个字符串类型,第二种则是通过判断直接返回,没有走到调用字符串方法的代码,类型检查自然不会报错,下面还有一个稍微复杂的案例。

function getFirstLetter(str: string | null) {
  function log() {
    console.log(str.tirm()); // 报错
  }

  str = str || '';
  log();
  return str.charAt(0);
}

上面代码中,str 调用 tirm 方法的代码封装在了函数 log 内部,当 log 调用时 str 的值已经变为了字符串,但是还是报错了,原因是代码编译阶段由上至下一行一行解析,所以解析函数 log 的时候就已经在函数内部报错了。

function getFirstLetter(str: string | null) {
  function log() {
    console.log(str!.tirm()); // 强制调用
  }

  str = str || '';
  log();
  return str.charAt(0);
}

上面代码在 log 函数中,str 调用 tirm 方法时加了一个 !,意思为强行调用,不管类型是否符合都会调用,相当于忽略了函数 getFirstLetter 的参数类型检查。

链判断运算符

上面的强制调用方式虽然通能过编译,但是在运行时有报错的风险,应该慎用,在 JS 中有一个提案叫链判断运算符,针对上面情况,会先判断是否为字符串再去调用字符串方法,这样写起来比较繁琐,而链判断运算符就是用来解决这个问题,通过 ?. 来调用。

/* 链判断运算符的几种用法 */
a?.b;
// 如果 a 不含有 b 属性则返回 undefined,否则返回 a.b,等同于
a == null ? undefined : a.b;

a?.[b];
// 如果 a 不含有键为 b 变量值的属性则返回 undefined,否则返回 a[b],等同于
a == null ? undefined : a[b];

a?.b()
// 如果 a 不含 b 属性则返回 undefined,否则执行 a.b(),如果 a.b 不是一个函数抛出类型错误,等同于
a == null ? undefined : a.b();

a?.()
// 如果 a 不是函数则返回 undefined,否则执行 a(),等同于
a == null ? undefined : a();

可辨识的联合类型

可辨识的联合类型是指,通过联合类型中的共同属性进行类型保护的一种技巧,通过同属性值的判断可以确定联合类型中的具体类型。

interface WarningButton {
  class: 'warning';
  name1: 'modified';
}

interface DangerButton {
  class: 'danger';
  name2: 'delete';
}

// 定义联合类型
type Button = WarningButton | DangerButton;

function getButton(button: Button) {
  if (button.class === 'warning') {
    return button.name1;
  }

  if (button.class === 'danger') {
    return button.name2;
  }
}

in 操作符

在可辨识的联合类型中如果没有共同的属性,共同属性判断不同值区分的方法行不通,这时可以用 in 操作符和不同属性判断进行类型保护。

interface Bird {
  talon: number;
}

interface Dog {
  leg: number;
}

function getNumber(animal: Bird | Dog) {
  if ('talon' in animal) {
    console.log(animal.talon);
  }

  if ('leg' in animal) {
    console.log(animal.leg);
  }
}

自定义类型保护

TS 中的上面用到的类型保护大部分为表达式,其实可以不使用这些表达式,通过自定义类型保护函数的方式实现同样的效果。

interface Bird {
  talon: number;
}

interface Dog {
  leg: number;
}

// 自定义类型保护函数
function isBird(animal: Bird | Dog): animal is Bird {
  // return (animal as Bird).talon > 0;
  return (<Bird>animal).talon > 0;
}

function getNumber(animal: Bird | Dog) {
  if (isBird(animal)) {
    console.log(animal.talon);
  } else {
    console.log(animal.leg);
  }
}

上面的自定义保护函数 isBird 中参数类型与 getNumber 参数一致,返回值的 animal is Bird 是一个类型谓词,语法为 param is type,代表返回是否满足 Bird 接口,由于 animal 并不知道自己符合哪一个接口,所以使用类型断言指定成了 BirdisBird 代码中注释和非注释两种方式都可以实现类型断言,这样自定义类型检查函数 isBird 就可以在 getNumber 中使用并实现类型保护。

类型变换

交叉类型

交叉类型表示为将多个类型合并为一个类型。

interface Bird {
  name: string;
  fly(): void;
}

interface Person {
  name: string;
  eat(): void;
}

// 取的是接口的并集
type BirdMan = Bird & Person;

// 实现接口必须包含两个接口所有的属性和方法
let birdMan: BirdMan = {
  name: 'niao',
  fly() {
    console.log('fly');
  },
  eat() {
    console.log('eat');
  }
};

typeof 获取类型

TS 使用时如果数据内容在变化,要不停的更改对应的接口,此时可以使用 typeof 关键字快速获取一个变量的类型。

/* 先定义类型,后定义变量 */
interface People {
  name: string;
  age: number;
}

let p: People = {
  name: 'hello',
  age: 20;
};
/* 先定义变量,后定义接口 */
let p: People = {
  name: 'hello',
  age: 20;
};

type People = typeof p;

function getName(p: People) {
  return p.name;
}

上面获取的类型定义 People 是根据 p 对象的属性和值生成,然后可以使用获取的类型去限制其他对象。

索引访问操作符

TS 中的索引访问操作符指可以通过 [] 获取一个类型的子类型。

// 定义嵌套类型接口
interface Person {
  name: string;
  age: number;
  // 对象,包含 name 属性
  job: {
    name: string;
  };
  // 成员为对象组成的数组,对象中含有 name 和 level 属性
  hobbies: { name: string; level: number }[];
}

// 获取 Person 接口 job 对象中 name 属性的类型定义为 FEJob 的类型
let FEJob: Person["job"] = {
  name: "FE"
};

// 获取数组中任意一项中 level 属性的类型作为 hobbyLevel 的类型
let hobbyLevel: Person['hobbies'][0]['level'] = 10;

keyof

TS 中定义的关键字 keyof 是索引类型查询操作符,用来定义类型,当定义一个获取对象属性值的函数,传入的值为对象和属性名,这样属性名参数的类型定义为 string 不准确,因为很可能传入一个对象本身没有的属性字符串,keyof 就是用来解决类似这样问题的。

interface Person {
  name: string;
  age: number;
  gender: 'male' | 'female';
}

// 使用 keyof 定义类型
type PersonKey = keyof Person;

function getValueByKey(p: Person, key: PersonKey) {
  return p[key];
}

let: person: Person = {
  name: 'hello',
  age: 20,
  gender: 'male'
};

getValueByKey(person, 'name'); // hello
getValueByKey(person, 'say'); // 报错

映射类型

在某一个类型定义中使用 in 操作符和 keyof 操作符批量映射修改一个新的类型定义。

interface Person {
  name: string;
  age: number;
  gender: 'male' | 'female';
}

// 映射 Person 接口定义的类型,把每一个属性都变成可选的
type PartPerson = {
  [key in keyof Person]?: Person[key];
}

let p1: PartPerson = {
  name: 'hello'
};

上面的代码中 keyof Person 取到了 Person 接口的每一个属性的类型,key 通过运算符 in(批量映射)代表获取的每一个属性名,?: 代表可选,Person[key] 代表对应的值。

内置工具类型

TS 内部内置了一些工具类型来帮助我们更好、更方便的使用类型系统。

Partial

Partial 可以将传入类型定义的属性由非可选变为可选,功能跟映射类型一节实现的功能非常相似,只是具体被映射的类型是通过参数传入的,所以底层是使用泛型实现的。

// 定义接口
interface Person {
  name: string;
  age: number;
}

// 使用 Partial
let p: Partial<Person> = {
  name: 'hello'
};

// Partial 的原理
type Partial<T> = {
  [key in keyof T]?: T[key]
}

Required

Required 可以将传入类型定义的属性变为必选,使用 -?: 替换原有的 : 也可以实现。

// 定义接口
interface Person {
  name: string;
  age: number;
}

// 使用 Required
let p: Required<Person> = {
  name: 'hello'
}; // 报错

// Required 的原理
type MyRequired<T> = {
  [key in keyof T]-?: T[key]
}

Readonly

Readonly 可以将传入类型定义的属性每一项都加上 readonly 修饰符来实现属性的只读。

// 定义接口
interface Person {
  name: string;
  age: number;
}

// 使用 Readonly
let p: Readonly<Person> = {
  name: 'hello',
  age: 10
};

p.name = 'world'; // 报错

// Readonly 的原理
type MyReadonly<T> = {
  readonly [key in keyof T]: T[key];
}

Pick

Pick 可以摘取传入类型定义的属性中的某一项返回。

// 定义接口
interface Person {
  name: string;
  age: number;
}

// 使用 Pick
let p: Pick<Person, 'name'> = {
  name: 'hello'
};

// Pick 的原理
type MyPick<T, K extends keyof T> = {
  [key in K]: T[key];
}

内置条件类型

还记得在泛型一节中提到了泛型的流程控制,在 TS 中内置了一些常用的条件类型。

Exclude

// 定义
Exclude<T, U> // 从 T 可分配给的类型中排除 U

// 使用
type E = Exclude<string | number, string>
let x: E = 10;
let y: E = 'hello'; // 报错

Extract

// 定义
Extract<T, U> // 从 T 可分配的类型中提取 U

// 使用
type E = Extract<string | number, string>
let x: E = 10; // 报错
let y: E = 'hello';

NonNullable

// 定义
NonNullable<T> // 从 T 中排除 null 和 undefined

// 使用
type E = NonNullable<string | null | undefined>
let x: E = null; // 报错
let y: E = 'hello';

ReturnType

// 定义
ReturnType<T> // 获取函数类型的返回类型

// 使用
function getUserInfo() {
  return { name: "hello", age: 10 };
}

type UserInfo = ReturnType<typeof getUserInfo>;

let user: UserInfo = {
  name: 'haha',
  age: 18
};

InstanceType

// 定义
InstanceType<T> // 获取构造函数类型的实例类型

// 使用
class Person {
  constructor(public name) {}
  getName() {
    console.log(this.name);
  }
}

type P = InstanceType<typeof Person>;

let p: P = {
  name: 'hello',
  getName() {
    console.log('myName');
  }
};

未完待续…