Typescript基础学习总结(三)

6类类型-高效使用类型化的面向对象编程利器

集面向对象抽象、封装、多态三要素为一体的编程利器,类类型。

在JavaScript(ES5)中仅支持通过函数和原型链继承模拟类的实现(用于抽象业务模型、组织数据结构并创建可重用组件),自 ES6 引入 class 关键字后,它才开始支持使用与Java类似的语法定义声明类。

6.1类

任何实体都可以被抽象为一个使用类表达的类似对象的数据结构,且这个数据结构既包含属性,又包含方法。

如果使用传统的 JavaScript 代码定义类,我们需要使用函数+原型链的形式进行模拟,如下代码所示:

function Dog(name: string) {
  this.name = name; // ts(2683) 'this' implicitly has type 'any' because it does not have a type annotation.
}
Dog.prototype.bark = function () {
  console.log('Woof! Woof!');
};
​
const dog = new Dog('Q'); // ts(7009) 'new' expression, whose target lacks a construct signature, implicitly has an 'any' type.
dog.bark(); // => 'Woof! Woof!'
复制代码

和通过 class 方式定义类相比,这种方式明显麻烦不少,而且还缺少静态类型检测。

6.2继承

使用 extends 关键字就能很方便地定义类继承的抽象模式

class Animal {
  type = 'Animal';
  say(name: string) {
    console.log(`I'm ${name}!`);
  }
}

class Dog extends Animal {
  bark() {
    console.log('Woof! Woof!');
  }
}

const dog = new Dog();
dog.bark(); // => 'Woof! Woof!'
dog.say('Q'); // => I'm Q!
dog.type; // => Animal
复制代码

说明:派生类通常被称作子类,基类也被称作超类(或者父类)。

派生类如果包含一个构造函数,则必须在构造函数中调用 super() 方法,这是 TypeScript 强制执行的一条重要规则。如下:

class Dog extends Animal {
  name: string;
  constructor(name: string) { // ts(2377) Constructors for derived classes must contain a 'super' call.
    this.name = name;
  }

  bark() {
    console.log('Woof! Woof!');
  }
}

class Dog extends Animal {
  name: string;
  constructor(name: string) {
    super(); // 添加 super 方法
    this.name = name;
  }

  bark() {
    console.log('Woof! Woof!');
  }
}
复制代码

这里的 super 函数会调用基类的构造函数

class Animal {
  weight: number;
  type = 'Animal';
  constructor(weight: number) {
    this.weight = weight;
  }
  say(name: string) {
    console.log(`I'm ${name}!`);
  }
}

class Dog extends Animal {
  name: string;
  constructor(name: string) {
    super(); // ts(2554) Expected 1 arguments, but got 0.
    this.name = name;
  }

  bark() {
    console.log('Woof! Woof!');
  }
}
复制代码

将鼠标放到第 15 行 Dog 类构造函数调用的 super 函数上,我们可以看到一个提示,它的类型是基类 Animal 的构造函数:constructor Animal(weight: number): Animal 。并且因为 Animal 类的构造函数要求必须传入一个数字类型的 weight 参数,而第 15 行实际入参为空,所以提示了一个 ts(2554) 的错误;如果我们显式地给 super 函数传入一个 number 类型的值,比如说 super(20),则不会再提示错误了。

6.3公共、私有与受保护的修饰符

类属性和方法除了可以通过 extends 被继承之外,还可以通过修饰符控制可访问性

在 TypeScript 中就支持 3 种访问修饰符,分别是 public、private、protected

  • public 修饰的是在任何地方可见、公有的属性或方法;

  • private 修饰的是仅在同一类中可见、私有的属性或方法;

  • protected 修饰的是仅在类自身及子类中可见、受保护的属性或方法。

在之前的代码中,示例类并没有用到可见性修饰符,在缺省情况下,类的属性或方法默认都是 public。如果想让有些属性对外不可见,那么我们可以使用private进行设置,如下所示:

class Son {
  public firstName: string;
  private lastName: string = 'Stark';
  constructor(firstName: string) {
    this.firstName = firstName;
    this.lastName; // ok
  }
}

const son = new Son('Tony');
console.log(son.firstName); //  => "Tony"
son.firstName = 'Jack';
console.log(son.firstName); //  => "Jack"
console.log(son.lastName); // ts(2341) Property 'lastName' is private and only accessible within class 'Son'.
复制代码

注意:TypeScript 中定义类的私有属性仅仅代表静态类型检测层面的私有。如果我们强制忽略 TypeScript 类型的检查错误,转译且运行 JavaScript 时依旧可以获取到 lastName 属性,这是因为 JavaScript 并不支持真正意义上的私有属性

class Son {
  public firstName: string;
  protected lastName: string = 'Stark';
  constructor(firstName: string) {
    this.firstName = firstName;
    this.lastName; // ok
  }
}

class GrandSon extends Son {
  constructor(firstName: string) {
    super(firstName);
  }

  public getMyLastName() {
    return this.lastName;
  }
}

const grandSon = new GrandSon('Tony');
console.log(grandSon.getMyLastName()); // => "Stark"
grandSon.lastName; // ts(2445) Property 'lastName' is protected and only accessible within class 'Son' and its subclasses.
复制代码

在第 3 行,修改 Son 类的 lastName 属性可见修饰符为 protected,表明此属性在 Son 类及其子类中可见。如示例第 6 行和第 16 行所示,我们既可以在父类 Son 的构造器中获取 lastName 属性值,又可以在继承自 Son 的子类 GrandSon 的 getMyLastName 方法获取 lastName 属性的值。

需要注意:虽然我们不能通过派生类的实例访问protected修饰的属性和方法,但是可以通过派生类的实例方法进行访问。比如示例中的第 21 行,通过实例的 getMyLastName 方法获取受保护的属性 lastName 是 ok 的,而第 22 行通过实例直接获取受保护的属性 lastName 则提示了一个 ts(2445) 的错误。

6.4只读修饰符

如果我们不希望类的属性被更改,则可以使用 readonly 只读修饰符声明类的属性

class Son {
  public readonly firstName: string;
  constructor(firstName: string) {
    this.firstName = firstName;
  }
}
const son = new Son('Tony');
son.firstName = 'Jack'; // ts(2540) Cannot assign to 'firstName' because it is a read-only property.
复制代码

注意:如果只读修饰符和可见性修饰符同时出现,我们需要将只读修饰符写在可见修饰符后面。

6.5存取器

在 TypeScript 中还可以通过getter、setter截取对类成员的读写访问

通过对类属性访问的截取,我们可以实现一些特定的访问控制逻辑。

class Son {
  public firstName: string;
  protected lastName: string = 'Stark';
  constructor(firstName: string) {
    this.firstName = firstName;
  }
}
class GrandSon extends Son {
  constructor(firstName: string) {
    super(firstName);
  }
  get myLastName() {
    return this.lastName;
  }
  set myLastName(name: string) {
    if (this.firstName === 'Tony') {
      this.lastName = name;
    } else {
      console.error('Unable to change myLastName');
    }
  }
}
const grandSon = new GrandSon('Tony');
console.log(grandSon.myLastName); // => "Stark"
grandSon.myLastName = 'Rogers';
console.log(grandSon.myLastName); // => "Rogers"
const grandSon1 = new GrandSon('Tony1');
grandSon1.myLastName = 'Rogers'; // => "Unable to change myLastName"
复制代码

6.6静态属性

以上介绍的关于类的所有属性和方法,只有类在实例化时才会被初始化。实际上,我们也可以给类定义静态属性和方法。

因为这些属性存在于类这个特殊的对象上,而不是类的实例上,所以我们可以直接通过类访问静态属性,如下代码所示:

class MyArray {
  static displayName = 'MyArray';
  static isArray(obj: unknown) {
    return Object.prototype.toString.call(obj).slice(8, -1) === 'Array';
  }
}
console.log(MyArray.displayName); // => "MyArray"
console.log(MyArray.isArray([])); // => true
console.log(MyArray.isArray({})); // => false
复制代码

通过 static 修饰符,我们给 MyArray 类分别定义了一个静态属性 displayName 和静态方法 isArray。之后,我们无须实例化 MyArray 就可以直接访问类上的静态属性和方法了

基于静态属性的特性,我们往往会把与类相关的常量、不依赖实例 this 上下文的属性和方法定义为静态属性,从而避免数据冗余,进而提升运行性能。

注意:上边我们提到了不依赖实例 this 上下文的方法就可以定义成静态方法,这就意味着需要显式注解 this 类型才可以在静态方法中使用 this;非静态方法则不需要显式注解 this 类型,因为 this 的指向默认是类的实例。

6.7抽象类

它是一种不能被实例化仅能被子类继承的特殊类

我们可以使用抽象类定义派生类需要实现的属性和方法,同时也可以定义其他被继承的默认属性和方法,如下代码所示:

abstract class Adder {
  abstract x: number;
  abstract y: number;
  abstract add(): number;
  displayName = 'Adder';
  addTwice(): number {
    return (this.x + this.y) * 2;
  }
}
class NumAdder extends Adder {
  x: number;
  y: number;
  constructor(x: number, y: number) {
    super();
    this.x = x;
    this.y = y;
  }
  add(): number {
    return this.x + this.y;
  }
}
const numAdder = new NumAdder(1, 2);
console.log(numAdder.displayName); // => "Adder"
console.log(numAdder.add()); // => 3
console.log(numAdder.addTwice()); // => 6
复制代码

通过 abstract 关键字,我们定义了一个抽象类 Adder,并通过abstract关键字定义了抽象属性x、y及方法add,而且任何继承 Adder 的派生类都需要实现这些抽象属性和方法

如果派生类中缺少对 x、y、add 这三者中任意一个抽象成员的实现,那么第 12 行就会提示一个 ts(2515) 错误,关于这点你可以亲自验证一下。

抽象类中的其他非抽象成员则可以直接通过实例获取,比如第 26~28 行中,通过实例 numAdder,我们获取了 displayName 属性和 addTwice 方法。

因为抽象类不能被实例化,并且派生类必须实现继承自抽象类上的抽象属性和方法定义,所以抽象类的作用其实就是对基础逻辑的封装和抽象。

实际上,我们也可以定义一个描述对象结构的接口类型(详见 07 讲)抽象类的结构,并通过 implements 关键字约束类的实现。

使用接口与使用抽象类相比,区别在于接口只能定义类成员的类型

interface IAdder {
  x: number;
  y: number;
  add: () => number;
}
class NumAdder implements IAdder {
  x: number;
  y: number;
  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
  add() {
    return this.x + this.y;
  }
  addTwice() {
    return (this.x + this.y) * 2;
  }
}
复制代码

6.8类的类型

类的最后一个特性——类的类型和函数类似,即在声明类的时候,其实也同时声明了一个特殊的类型(确切地讲是一个接口类型),这个类型的名字就是类名,表示类实例的类型;在定义类的时候,我们声明的除构造函数外所有属性、方法的类型就是这个特殊类型的成员。

class A {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}
const a1: A = {}; // ts(2741) Property 'name' is missing in type '{}' but required in type 'A'.
const a2: A = { name: 'a2' }; // ok
复制代码

我们在定义类 A ,也说明我们同时定义了一个包含字符串属性 name 的同名接口类型 A。因此,在第 7 行把一个空对象赋值给类型是 A 的变量 a1 时,TypeScript 会提示一个 ts(2741) 错误,因为缺少 name 属性。在第 8 行把对象{ name: ‘a2’ }赋值给类型同样是 A 的变量 a2 时,TypeScript 就直接通过了类型检查,因为有 name 属性。

在 TypeScript 中,因为我们需要实践 OOP 编程思想,所以离不开类的支撑。在实际工作中,类与函数一样,都是极其有用的抽象、封装利器

7接口类型与类型别名

这一讲我们将学习 TypeScript 与 JavaScript 不一样却堪称精华之一的特性——接口类型与类型别名。这些特性让 TypeScript 具备了 JavaScript 所缺少的、描述较为复杂数据结构的能力。在使用 TypeScript 之前,可能我们只能通过文档或大量的注释来做这件事。

7.1Interface 接口类型

TypeScript 不仅能帮助前端改变思维方式,还能强化面向接口编程的思维和能力,而这正是得益于 Interface 接口类型。通过接口类型,我们可以清晰地定义模块内、跨模块、跨项目代码的通信规则。

TypeScript 对对象的类型检测遵循一种被称之为“鸭子类型”(duck typing)或者“结构化类型(structural subtyping)”的准则,即只要两个对象的结构一致,属性和方法的类型一致,则它们的类型就是一致的。

function Study(language: { name: string; age: () => number }) {
  console.log(`ProgramLanguage ${language.name} created ${language.age()} years ago.`);
}
Study({
  name: 'TypeScript',
  age: () => new Date().getFullYear() - 2012
});
复制代码

在调用函数的过程中,TypeScript 静态类型检测到传递的对象字面量类型为 string 的 name 属性和类型为() => number 的 age 属性与函数参数定义的类型一致,于是不会抛出一个类型错误。

如果我们传入一个 name 属性是 number 类型或者缺少age属性的对象字面量,

Study({
  name: 2,
  age: () => new Date().getFullYear() - 2012
});
Study({
  name: 'TypeScript'
});
复制代码

这时,第 2 行会提示错误: ts(2322) number 不能赋值给 string,第 7 行也会提示错误: ts(2345) 实参(Argument)与形参(Parameter)类型不兼容,缺少必需的属性 age。

同样,如果我们传入一个包含了形参类型定义里没有的 id 属性的对象字面量作为实参,也会得到一个类型错误 ts(2345),实参(Argument)与形参(Parameter)类型不兼容,不存在的属性 id,如下代码所示:

/** ts(2345) 实参(Argument)与形参(Parameter)类型不兼容,不存在的属性 id */
Study({
  id: 2,
  name: 'TypeScript',
  age: () => new Date().getFullYear() - 2012
});
复制代码

有意思的是,在上边的示例中,如果我们先把这个对象字面量赋值给一个变量,然后再把变量传递给函数进行调用,那么 TypeScript 静态类型检测就会仅仅检测形参类型中定义过的属性类型,而包容地忽略任何多余的属性,此时也不会抛出一个 ts(2345) 类型错误。

let ts = {
  id: 2,
  name: 'TypeScript',
  age: () => new Date().getFullYear() - 2012
};
Study(ts); // ok
复制代码

这并非一个疏忽或 bug,而是有意为之地将对象字面量和变量进行区别对待,我们把这种情况称之为对象字面量的 freshness(在 12 讲中会再次详细介绍)。

因为这种内联形式的接口类型定义在语法层面与熟知的 JavaScript 解构颇为神似,所以很容易让我们产生混淆。下面我们通过如下示例对比一下解构语法与内联接口类型混用的效果。

/** 纯 JavaScript 解构语法 */
function StudyJavaScript({name, age}) {
  console.log(name, age);
}
/** TypeScript 里解构与内联类型混用 */
function StudyTypeScript({name, age}: {name: string, age: () => number}) {
    console.log(name, age);
}
/** 纯 JavaScript 解构语法,定义别名 */
function StudyJavaScript({name: aliasName}) { // 定义name的别名
  console.log(aliasName);
}
/** TypeScript */
function StudyTypeScript(language: {name: string}) {
  // console.log(name); // 不能直接打印name
  console.log(language.name);  
}
复制代码

在函数中,对象解构和定义接口类型的语法很类似(如第 12 行和 17 行所示),注意不要混淆。实际上,定义内联的接口类型是不可复用的,所以我们应该更多地使用interface关键字来抽离可复用的接口类型

/ ** 关键字 接口名称 */
interface ProgramLanguage {
  /** 语言名称 */
  name: string;
  /** 使用年限 */
  age: () => number;
}
复制代码

接口的语法格式是在 interface 关键字的空格后+接口名字,然后属性与属性类型的定义用花括弧包裹

function NewStudy(language: ProgramLanguage) {
  console.log(`ProgramLanguage ${language.name} created ${language.age()} years ago.`);
}
复制代码

我们还可以通过复用接口类型定义来约束其他逻辑。比如,我们通过如下所示代码定义了一个类型为 ProgramLanguage 的变量 TypeScript 。

let TypeScript: ProgramLanguage;
复制代码

接着,我们把满足接口类型约定的一个对象字面量赋值给了这个变量,不会报错。

TypeScript = {
  name: 'TypeScript',
  age: () => new Date().getFullYear() - 2012
}
复制代码

而任何不符合约定的情况,都会提示类型错误。
如以下示例中额外多出了一个接口并未定义的属性 id,也会提示一个 ts(2322) 错误:对象字面量不能赋值给 ProgramLanguage 类型的变量 TypeScript。

TypeScript = {
  name: 'TypeScript',
  age: () => new Date().getFullYear() - 2012,
  id: 1
}
复制代码

7.2可缺省属性

如果我们希望缺少 age 属性的对象字面量也能符合约定且不抛出类型错误,确切地说在接口类型中 age 属性可缺省,那么我们可以在属性名之后通过添加如下所示的? 语法来标注可缺省的属性或方法。

/** 关键字 接口名称 */
interface OptionalProgramLanguage {
  /** 语言名称 */
  name: string;
  /** 使用年限 */
  age?: () => number;
}
let OptionalTypeScript: OptionalProgramLanguage = {
  name: 'TypeScript'
}; // ok
复制代码

当属性被标注为可缺省后,它的类型就变成了显式指定的类型与 undefined 类型组成的联合类型. 比如示例中 OptionalTypeScript 的 age 属性类型就变成了如下所示内容:

(() => number) | undefined;
复制代码

发散思考一下:你觉得如下所示的接口类型 OptionalTypeScript2 和 OptionalTypeScript 等价吗?

/** 关键字 接口名称 */
interface OptionalProgramLanguage2 {
  /** 语言名称 */
  name: string;
  /** 使用年限 */
  age: (() => number) | undefined;
}
复制代码

答案当然是不等价,这与 05 讲中提到函数可缺省参数和参数类型可以是 undefined 一样,可缺省意味着可以不设置属性键名类型是 undefined 意味着属性键名不可缺省

既然值可能是 undefined ,如果我们需要对该对象的属性或方法进行操作,就可以使用类型守卫(详见 11 讲)或 Optional Chain(在第 5 行的属性名后加 ? ),如下代码所示:

if (typeof OptionalTypeScript.age === 'function') {
  OptionalTypeScript.age();
}
OptionalTypeScript.age?.();
复制代码

7.3只读属性

我们可以在属性名前通过添加 readonly 修饰符的语法来标注 name 为只读属性。

interface ReadOnlyProgramLanguage {
  /** 语言名称 */
  readonly name: string;
  /** 使用年限 */
  readonly age: (() => number) | undefined;
}
 
let ReadOnlyTypeScript: ReadOnlyProgramLanguage = {
  name: 'TypeScript',
  age: undefined
}
/** ts(2540)错误,name 只读 */
ReadOnlyTypeScript.name = 'JavaScript';
复制代码

需要注意的是,这仅仅是静态类型检测层面的只读,实际上并不能阻止对对象的篡改。因为在转译为 JavaScript 之后,readonly 修饰符会被抹除。因此,任何时候与其直接修改一个对象,不如返回一个新的对象?,这会是一种比较安全的实践。

7.4定义函数类型

接口类型不仅能用来定义对象的类型,接口类型还可以用来定义函数的类型 (备注:仅仅是定义函数的类型,而不包含函数的实现)

interface StudyLanguage {
  (language: ProgramLanguage): void
}
/** 单独的函数实践 */
let StudyInterface: StudyLanguage 
  = language => console.log(`${language.name} ${language.age()}`);
复制代码

实际上,我们很少使用接口类型来定义函数的类型,更多使用内联类型或类型别名(本讲后半部分讲解)配合箭头函数语法来定义函数类型,具体示例如下:

type StudyLanguageType = (language: ProgramLanguage) => void
复制代码

我们给箭头函数类型指定了一个别名 StudyLanguageType,在其他地方就可以直接复用 StudyLanguageType,而不用重新声明新的箭头函数类型定义。

7.5索引签名

在实际工作中,使用接口类型较多的地方是对象,比如 React 组件的 Props & State、HTMLElement 的 Props,这些对象有一个共性,即所有的属性名、方法名都确定

实际上,我们经常会把对象当 Map 映射使用,比如下边代码示例中定义了索引是任意数字的对象 LanguageRankMap 和索引是任意字符串的对象 LanguageMap。

let LanguageRankMap = {
  1: 'TypeScript',
  2: 'JavaScript',
  ...
};
let LanguageMap = {
  TypeScript: 2012,
  JavaScript: 1995,
  ...
};
复制代码

这个时候,我们需要使用索引签名来定义上边提到的对象映射结构,并通过 “[索引名: 类型]”的格式约束索引的类型。

索引名称的类型分为 string 和 number 两种,通过如下定义的 LanguageRankInterface 和 LanguageYearInterface 两个接口,我们可以用来描述索引是任意数字或任意字符串的对象。

interface LanguageRankInterface {
  [rank: number]: string;
}
interface LanguageYearInterface {
  [name: string]: number;
}
{
  let LanguageRankMap: LanguageRankInterface = {
    1: 'TypeScript', // ok
    2: 'JavaScript', // ok
    'WrongINdex': '2012' // ts(2322) 不存在的属性名
  };
  
  let LanguageMap: LanguageYearInterface = {
    TypeScript: 2012, // ok
    JavaScript: 1995, // ok
    1: 1970 // ok
  };
}
复制代码

注意:在上述示例中,数字作为对象索引时,它的类型既可以与数字兼容,也可以与字符串兼容,这与 JavaScript 的行为一致。因此,使用 0 或 ‘0’ 索引对象时,这两者等价。

同样,我们可以使用 readonly 注解索引签名,此时将对应属性设置为只读就行

{
  interface LanguageRankInterface {
    readonly [rank: number]: string;
  }
  
  interface LanguageYearInterface {
    readonly [name: string]: number;
  }
} 
复制代码

注意:虽然属性可以与索引签名进行混用,但是属性的类型必须是对应的数字索引或字符串索引的类型的子集,否则会出现错误提示。

{
  interface StringMap {
    [prop: string]: number;
    age: number; // ok
    name: string; // ts(2411) name 属性的 string 类型不能赋值给字符串索引类型 number
  }
  interface NumberMap {
    [rank: number]: string;
    1: string; // ok
    0: number; // ts(2412) 0 属性的 number 类型不能赋值给数字索引类型 string
  }
  interface LanguageRankInterface {
    name: string; // ok
    0: number; // ok
    [rank: number]: string;
    [name: string]: number;
  }
}
复制代码

因为接口 StringMap 属性 name 的类型 string 不是它所对应的字符串索引(第 3 行定义的 prop: string)类型 number 的子集,所以会提示一个错误。同理,因为接口 NumberMap 属性 0 的类型 number 不是它所对应的数字索引(第 8 行定义的 rank: number)类型 string 的子集,所以也会提示一个错误。

另外,由于上边提到了数字类型索引的特殊性,所以我们不能约束数字索引属性与字符串索引属性拥有截然不同的类型

{
  interface LanguageRankInterface {
    [rank: number]: string; // ts(2413) 数字索引类型 string 类型不能赋值给字符串索引类型 number
    [prop: string]: number;
  }
}
复制代码

这里我们定义了 LanguageRankInterface 的数字索引 rank 的类型是 string,与定义的字符串索引 prop 的类型 number 不兼容,所以会提示一个 ts(2413) 错误。

这里埋个伏笔:如果我们确实需要使用 age 是 number 类型、其他属性类型是 string 的对象数据结构,应该如何定义它的类型且不提示错误呢?

比如如下示例中定义的 age 属性是数字、其他任意属性是字符串的对象,我们应该怎么定义它的类型呢?


{
  age: 1, // 数字类型
  anyProperty: 'str', // 字符串
  ...
}
复制代码

由于属性和索引签名的类型限制,使得我们不能通过单一的接口来描述这个对象,这时我们该怎么办呢?08 讲中我们会解决这个问题。

7.6继承与实现

在 TypeScript 中,接口类型可以继承和被继承,比如我们可以使用如下所示的 extends 关键字实现接口的继承。

{
  interface DynamicLanguage extends ProgramLanguage {
    rank: number; // 定义新属性
  }
  
  interface TypeSafeLanguage extends ProgramLanguage {
    typeChecker: string; // 定义新的属性
  }
  /** 继承多个 */
  interface TypeScriptLanguage extends DynamicLanguage, TypeSafeLanguage {
    name: 'TypeScript'; // 用原属性类型的兼容的类型(比如子集)重新定义属性
  }
}
复制代码

注意:我们仅能使用兼容的类型覆盖继承的属性

{
  /** ts(6196) 错误的继承,name 属性不兼容 */
  interface WrongTypeLanguage extends ProgramLanguage {
    name: number;
  }
}
复制代码

我们既可以使用接口类型来约束类,反过来也可以使用类实现接口,那两者之间的关系到底是什么呢?这里,我们通过使用如下所示的 implements关键字描述一下类和接口之间的关系。

/** 类实现接口 */
{
  class LanguageClass implements ProgramLanguage {
    name: string = '';
    age = () => new Date().getFullYear() - 2012
  }
}
复制代码

7.7Type 类型别名

接口类型的一个作用是将内联类型抽离出来,从而实现类型可复用。其实,我们也可以使用类型别名接收抽离出来的内联类型实现复用。

此时,我们可以通过如下所示“type 别名名字 = 类型定义”的格式来定义类型别名。

/** 类型别名 */
{
  type LanguageType = {
    /** 以下是接口属性 */
    /** 语言名称 */
    name: string;
    /** 使用年限 */
    age: () => number;
  }
}
复制代码

此外,针对接口类型无法覆盖的场景,比如组合类型、交叉类型(详见 08 讲),我们只能使用类型别名来接收,如下代码所示:

{
  /** 联合 */
  type MixedType = string | number;
  /** 交叉 */
  type IntersectionType = { id: number; name: string; } 
    & { age: number; name: string };
  /** 提取接口属性类型 */
  type AgeType = ProgramLanguage['age'];  
}
复制代码

我们定义了一个 IntersectionType 类型别名,表示两个匿名接口类型交叉出的类型;同时定义了一个 AgeType 类型别名,表示抽取的 ProgramLanguage age 属性的类型。

7.8Interface 与 Type 的区别

适用接口类型标注的地方大都可以使用类型别名进行替代,这是否意味着在相应的场景中这两者等价呢?

实际上,在大多数的情况下使用接口类型和类型别名的效果等价,但是在某些特定的场景下这两者还是存在很大区别。比如,重复定义的接口类型,它的属性会叠加,这个特性使得我们可以极其方便地对全局变量、第三方库的类型做扩展,如下代码所示:

{
  interface Language {
    id: number;
  }
  
  interface Language {
    name: string;
  }
  let lang: Language = {
    id: 1, // ok
    name: 'name' // ok
  }
}
复制代码

先后定义的两个 Language 「接口」属性被叠加在了一起,此时我们可以赋值给 lang 变量一个同时包含 id 和 name 属性的对象。

不过,如果我们重复定义类型别名,如下代码所示,则会提示一个 ts(2300) 错误。

{
  /** ts(2300) 重复的标志 */
  type Language = {
    id: number;
  }
  
  /** ts(2300) 重复的标志 */
  type Language = {
    name: string;
  }
  let lang: Language = {
    id: 1,
    name: 'name'
  }
}
复制代码

接口类型是 TypeScript 最核心的知识点之一,掌握好接口类型,养成面向接口编程思维方式和惯性,将让我们的编程之路愈发顺利、高效。

类型别名使得类型可以像值一样能赋予另外一个变量(别名),大大提升了类型复用性,最终也提升了我们的编程效率。

8高级类型:联合类型和交叉类型

8.1联合类型

联合类型(Unions)用来表示变量、参数的类型不是单一原子类型,而可能是多种不同的类型的组合。

我们主要通过“|”操作符分隔类型的语法来表示联合类型。这里,我们可以把“|”类比为 JavaScript 中的逻辑或 “||”,只不过前者表示可能的类型。

function formatPX(size: unknown) {
  if (typeof size === 'number') {
    return `${size}px`;
  }
  if (typeof size === 'string') {
    return `${parseInt(size) || 0}px`;
  }
  throw Error(` 仅支持 number 或者 string`);
}
formatPX(13);
formatPX('13px');
复制代码

说明:在学习联合类型之前,我们可能免不了使用 any 或 unknown 类型来表示参数的类型(为了让大家养成好习惯,推荐使用 unknown)。

通过这样的方式带来的问题是,在调用 formatPX 时,我们可以传递任意的值,并且可以通过静态类型检测(使用 any 亦如是),但是运行时还是会抛出一个错误,例如:

formatPX(true);
formatPX(null);
复制代码

这显然不符合我们的预期,因为 size 应该是更明确的,即可能也只可能是 number 或 string 这两种可选类型的类型。

所幸有联合类型,我们可以使用一个更明确表示可能是 number 或 string 的联合类型来注解 size 参数,如下代码所示:

function formatPX(size: number | string) {
  // ...
}
formatPX(13); // ok
formatPX('13px'); // ok
formatPX(true); // ts(2345) 'true' 类型不能赋予 'number | string' 类型
formatPX(null); // ts(2345) 'null' 类型不能赋予 'number | string' 类型
复制代码

当然,我们可以组合任意个、任意类型来构造更满足我们诉求的类型。

function formatUnit(size: number | string, unit: 'px' | 'em' | 'rem' | '%' = 'px') {
  // ...
}
formatUnit(1, 'em'); // ok
formatUnit('1px', 'rem'); // ok
formatUnit('1px', 'bem'); // ts(2345)
复制代码

我们也可以使用类型别名抽离上边的联合类型,然后再将其进一步地联合,

type ModernUnit = 'vh' | 'vw';
type Unit = 'px' | 'em' | 'rem';
type MessedUp = ModernUnit | Unit; // 类型是 'vh' | 'vw' | 'px' | 'em' | 'rem'
复制代码

我们也可以把接口类型联合起来表示更复杂的结构.

interface Bird {
  fly(): void;
  layEggs(): void;
}
interface Fish {
  swim(): void;
  layEggs(): void;
}
const getPet: () => Bird | Fish = () => {
  return {
   // ...
  } as Bird | Fish;
};
const Pet = getPet();
Pet.layEggs(); // ok
Pet.fly(); // ts(2339) 'Fish' 没有 'fly' 属性; 'Bird | Fish' 没有 'fly' 属性
复制代码

在联合类型中,我们可以直接访问各个接口成员都拥有的属性、方法,且不会提示类型错误。但是,如果是个别成员特有的属性、方法,我们就需要区分对待了,此时又要引入类型守卫(详见 11 讲)来区分不同的成员类型。

只不过,在这种情况下,我们还需要使用基于 in 操作符判断的类型守卫

if (typeof Pet.fly === 'function') { // ts(2339)
  Pet.fly(); // ts(2339)
}
if ('fly' in Pet) {
  Pet.fly(); // ok
}
复制代码

8.2交叉类型

在 TypeScript 中,还存在一种类似逻辑与行为的类型——交叉类型(Intersection Type),它可以把多个类型合并成一个类型,合并后的类型将拥有所有成员类型的特性。

在 TypeScript 中,我们可以使用“&”操作符来声明交叉类型,

{
  type Useless = string & number;
}
复制代码

如果我们仅仅把原始类型、字面量类型、函数类型等原子类型合并成交叉类型,是没有任何用处的,因为任何类型都不能满足同时属于多种原子类型,因此,在上述的代码中,类型别名 Useless 的类型就是个 never。

8.3合并接口类型

联合类型真正的用武之地就是将多个接口类型合并成一个类型,从而实现等同接口继承的效果,也就是所谓的合并接口类型

type IntersectionType = { id: number; name: string; } 
    & { age: number };
  const mixed: IntersectionType = {
    id: 1,
    name: 'name',
    age: 18
  }
复制代码

我们通过交叉类型,使得 IntersectionType 同时拥有了 id、name、age 所有属性,这里我们可以试着将合并接口类型理解为求并集

这里,我们来发散思考一下:如果合并的多个接口类型存在同名属性会是什么效果呢?

此时,我们可以根据同名属性的类型是否兼容(详见 12 讲)将这个问题分开来看。

如果同名属性的类型不兼容,比如上面示例中两个接口类型同名的 name 属性类型一个是 number,另一个是 string,合并后,name 属性的类型就是 number 和 string 两个原子类型的交叉类型,即 never,如下代码所示:

type IntersectionTypeConfict = { id: number; name: string; } 
    & { age: number; name: number; };
  const mixedConflict: IntersectionTypeConfict = {
    id: 1,
    name: 2, // ts(2322) 错误,'number' 类型不能赋给 'never' 类型
    age: 2
  };
复制代码

如果同名属性的类型兼容,比如一个是 number,另一个是 number 的子类型、数字字面量类型,合并后 name 属性的类型就是两者中的子类型

如下所示示例中 name 属性的类型就是数字字面量类型 2,因此,我们不能把任何非 2 之外的值赋予 name 属性。

type IntersectionTypeConfict = { id: number; name: 2; } 
  & { age: number; name: number; };
  let mixedConflict: IntersectionTypeConfict = {
    id: 1,
    name: 2, // ok
    age: 2
  };
  mixedConflict = {
    id: 1,
    name: 22, // '22' 类型不能赋给 '2' 类型
    age: 2
  };
复制代码

8.3合并联合类型

另外,我们可以合并联合类型为一个交叉类型,这个交叉类型需要同时满足不同的联合类型限制,也就是提取了所有联合类型的相同类型成员。这里,我们也可以将合并联合类型理解为求交集

在如下示例中,两个联合类型交叉出来的类型 IntersectionUnion 其实等价于 ’em’ | ‘rem’,所以我们只能把 ’em’ 或者 ‘rem’ 字符串赋值给 IntersectionUnion 类型的变量。

type UnionA = 'px' | 'em' | 'rem' | '%';
  type UnionB = 'vh' | 'em' | 'rem' | 'pt';
  type IntersectionUnion = UnionA & UnionB;
  const intersectionA: IntersectionUnion = 'em'; // ok
  const intersectionB: IntersectionUnion = 'rem'; // ok
  const intersectionC: IntersectionUnion = 'px'; // ts(2322)
  const intersectionD: IntersectionUnion = 'pt'; // ts(2322)
复制代码

既然是求交集,如果多个联合类型中没有相同的类型成员,交叉出来的类型自然就是 never 了

type UnionC = 'em' | 'rem';
  type UnionD = 'px' | 'pt';
  type IntersectionUnionE = UnionC & UnionD;
  const intersectionE: IntersectionUnionE = 'any' as any; // ts(2322) 不能赋予 'never' 类型
复制代码

8.4联合、交叉组合

在前面的示例中,我们把一些联合、交叉类型抽离成了类型别名,再把它作为原子类型进行进一步的联合、交叉。其实,联合、交叉类型本身就可以直接组合使用,这就涉及 |、& 操作符的优先级问题。实际上,联合、交叉运算符不仅在行为上表现一致,还在运算的优先级和 JavaScript 的逻辑或 ||、逻辑与 && 运算符上表现一致 。

联合操作符 | 的优先级低于交叉操作符 &,同样,我们可以通过使用小括弧 () 来调整操作符的优先级。

type UnionIntersectionA = { id: number; } & { name: string; } | { id: string; } & { name: number; }; // 交叉操作符优先级高于联合操作符
  type UnionIntersectionB = ('px' | 'em' | 'rem' | '%') | ('vh' | 'em' | 'rem' | 'pt'); // 调整优先级
复制代码

我们也可以把分配率、交换律等基本规则引入类型组合中,然后优化出更简洁、清晰的类型

 type UnionIntersectionC = ({ id: number; } & { name: string; } | { id: string; }) & { name: number; };
  type UnionIntersectionD = { id: number; } & { name: string; } & { name: number; } | { id: string; } & { name: number; }; // 满足分配率
  type UnionIntersectionE = ({ id: string; } | { id: number; } & { name: string; }) & { name: number; }; // 满足交换律
复制代码

8.5类型缩减

如果将 string 原始类型和“string字面量类型”组合成联合类型会是什么效果?效果就是类型缩减成 string 了。
对于 number、boolean(其实还有枚举类型,详见第 9 讲)也是一样的缩减逻辑

type URStr = 'string' | string; // 类型是 string
  type URNum = 2 | number; // 类型是 number
  type URBoolen = true | boolean; // 类型是 boolean
  enum EnumUR {
    ONE,
    TWO
  }
  type URE = EnumUR.ONE | EnumUR; // 类型是 EnumUR
复制代码

TypeScript 对这样的场景做了缩减,它把字面量类型、枚举成员类型缩减掉,只保留原始类型、枚举类型等父类型,这是合理的“优化”。

可是这个缩减,却极大地削弱了 IDE 自动提示的能力

 type BorderColor = 'black' | 'red' | 'green' | 'yellow' | 'blue' | string; // 类型缩减成 string
复制代码

在上述代码中,我们希望 IDE 能自动提示显示注解的字符串字面量,但是因为类型被缩减成 string,所有的字符串字面量 black、red 等都无法自动提示出来了。
不要慌,TypeScript 官方其实还提供了一个黑魔法,它可以让类型缩减被控制。如下代码所示,我们只需要给父类型添加“& {}”即可。

  type BorderColor = 'black' | 'red' | 'green' | 'yellow' | 'blue' | string & {}; // 字面类型都被保留
复制代码

此时,其他字面量类型就不会被缩减掉了,在 IDE 中字符串字面量 black、red 等也就自然地可以自动提示出来了。

此外,当联合类型的成员是接口类型,如果满足其中一个接口的属性是另外一个接口属性的子集,这个属性也会类型缩减,如下代码所示:

 type UnionInterce =
  | {
      age: '1';
    }
  | ({
      age: '1' | '2';
      [key: string]: string;
    });
复制代码

这里因为 ‘1’ 是 ‘1’ | ‘2’ 的子集,所以 age 的属性变成 ‘1’ | ‘2’.

利用这个特性,我们来实现 07 讲中埋下的那个伏笔,如何定义如下所示 age 属性是数字类型,而其他不确定的属性是字符串类型的数据结构的对象?

{
  age: 1, // 数字类型
  anyProperty: 'str', // 其他不确定的属性都是字符串类型
  ...
}
复制代码

在这里提到这个伏笔,想必你应该明白了,我们肯定要用到两个接口的联合类型及类型缩减,这个问题的核心在于找到一个既是 number 的子类型,这样 age 类型缩减之后的类型就是 number;同时也是 string 的子类型,这样才能满足属性和 string 索引类型的约束关系。

哪个类型满足这个条件呢?我们一起回忆一下 02 讲中介绍的特殊类型 never。

never 有一个特性是它是所有类型的子类型,自然也是 number 和 string 的子类型,所以答案如下代码所示:

type UnionInterce =
  | {
      age: number;
    }
  | ({
      age: never;
      [key: string]: string;
    });
  const O: UnionInterce = {
    age: 2,
    string: 'string'
  };
复制代码

学习和掌握联合和交叉类型后,可以培养我们抽离、复用公共类型的意识和能力。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享