首先,面向对象程序设计(Object Oriented Programming,OOP)是一种计算机编程架构

OOP特点 = 封装 + 继承 + 多态,OOP达到了软件工程的三个主要目标:重用性灵活性扩展性

面向对象使得软件的开发方法与过程尽可能接近人类认识世界、解决现实问题的方法和过程

人总喜欢把我们已知的,或者未知的事务分类,好人?坏人?人?动物?

因为这样归类,给一个具体对象打上标签之后方便我们记忆,甚至是扩展记忆的连贯性。

虽然说有时候具有很强的主观性,但不可否认,这是我们人类最容易记忆的方式。

所以,当我们的程序越来越复杂的时候,就需要一种可读性,可理解性更强,更适合人类的程序设计方式。那就是面向对象。所以在互联网行业,很多流行的后端语言都是面向对象的,比如java,c#等等,因为涉及的业务复杂,为了在这个纷繁复杂的程序业务世界中,找到最清楚的脉络,那就是像我们人类认知世界一样,把各个内容分类,打上标签。

所以面向对象中所说的类,就是对具有相似属性,相似行为的内容进行代码的归类。无非也就是为了开发者记忆,阅读和扩展。

以类型系统的角度来看:一个类,其实就是创建了一种新的数据结构类型。所以一个类,就是一个类型

在ES6之前,传统的JavaScript程序使用构造函数来创建类,基于原型的效果来实现继承。但是这样就让函数有了二义性,简单来说一个构造函数,既能表示是一个函数,也能表示是一个构造函数(类),虽然之前一直约定俗成的默认只要函数名首字母大写就是构造函数,但是却一直没有语法层面的区分,所以ES6之后加上了class和箭头函数,专门用来区分类和一般函数。

1. 访问修饰符

类的主要结构,无非就是属性、构造函数、方法、存取器、访问修饰符、装饰器,在typescript中,我们在需要的部分加上类型标注就行了

属性的类型标识类似于变量,而构造函数、方法、存取器的类型标注其实就是函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// TS中类的主要结构:属性、构造函数、方法、存取器、访问修饰符、装饰器
// 属性类似于变量,加上相关类型标注,当然也能通过值的类型进行类型推导
// 构造函数、方法、存取器类似于函数,加上参数的类型与返回类型

// 访问修饰符:可以修饰属性和方法
// public(默认): 可以在类、类的子类中,以及类和子类的实例对象访问
// protected: 仅能在类和子类中访问
// private: 仅能在类的内部被访问到
// # ES2022新特性,私有属性和方法
/*
class Animal{
public name: string;
protected color: string;
private _age: number;
#type: string;
constructor(name: string, color:string, _age: number, type:string) {
this.name = name;
this.color = color;
this._age = _age;
this.#type = type;
}

show() {
console.log(this.name, this.color, this._age)
}
}
*/
class Animal{

#type: string;
constructor(
public name: string,
protected color: string,
private _age: number,
type: string) {
this.#type = type;
}

show() {
console.log(this.name, this.color, this._age)
}
}

class Cat extends Animal{
info() {
// 父类中public修饰的属性和方法在子类中都能访问
console.log(this.name)
this.show();
// 父类中protected修饰的属性和方法在子类中都能访问
console.log(this.name)
this.show();

// 父类中private修饰的属性和方法在子类中 不能访问
// console.log(this._age);
}
}


const a = new Animal("小白", "白色", 3, 'Dog');
console.log(a.name);
a.show();
// console.log(a.color);
// console.log(a._age);

const c = new Cat("小猫", "花色", 2, 'Cat');
console.log(c.name);
c.info();

注意1:"strict": true会默认开启"strictPropertyInitialization":true,也就是属性必须初始化,如果希望关闭这个特性,单独设置"strictPropertyInitialization":false即可

  • public:在类、类的实例、子类中都能被访问。
  • protected:仅能在类与子类中被访问。
  • private:仅能在类的内部被访问。
  • #:仅能在类的内部被访问。ES2022新特性

需要注意的有两个地方:

1、private实际是typescript的访问修饰符,因此在转换为js代码之后,实际上是会消失的

2、私有字段#实际是ES2022中被正式采纳的标准

3、如果不添加任何访问修饰符,默认public,我们一般不会为构造函数添加修饰符,保持默认即可。

上面构造函数的写法还是略显麻烦,我们其实可以简写:

1
2
3
4
5
6
7
class Animal { 
#type: 'cat' | 'dog';
constructor(public name: string, public color: string, private _age: number, type: 'cat' | 'dog') {
this.#type = type;
}
......
}

# 标记的私有字段,目前还不能以类似于 publicprivate 修饰符的构造函数参数简写形式声明

2. 存取器(访问器)属性

访问器属性的写法几乎和JS一样,大家只需要注意一些小细节就行了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
class Animal {
public name: string;
protected color: string;
private _age: number;
#type: string;
constructor(name: string, color: string, _age: number, type: string) {
this.name = name;
this.color = color;
this._age = _age;
this.#type = type;
}

get age() {
return this._age;
}

set age(value: number) {
if (value < 0 || value > 150) throw new Error("年龄不符合规范...");
this._age = value;
}

get type(): string {
return this.#type;
}

set type(value: "Cat" | "Dog") {
this.#type = value;
}

show() {
console.log(this.name, this.color, this._age);
}
}

/* class Animal{

#type: string;
constructor(
public name: string,
protected color: string,
private _age: number,
type: string) {
this.#type = type;
}

show() {
console.log(this.name, this.color, this._age)
}
} */

class Cat extends Animal {
info() {
// 父类中public修饰的属性和方法在子类中都能访问
console.log(this.name);
this.show();
// 父类中protected修饰的属性和方法在子类中都能访问
console.log(this.name);
this.show();

// 父类中private修饰的属性和方法在子类中 不能访问
// console.log(this._age);
}
}

const a = new Animal("小白", "白色", 3, "Dog");
console.log(a.name);
a.show();
a.age = -1;
a.type = "Cat";

// console.log(a.color);
// console.log(a._age);

const c = new Cat("小猫", "花色", 2, "Cat");
console.log(c.name);
c.info();

访问器属性 如果有set,那么默认get返回具体的类型。

所以本身可选属性这种写法和访问器属性get,set一起写就有逻辑上的冲突。

1
2
3
4
5
6
7
8
9
10
11
class Animal { 
private _age?: number;
// ......其他省略
get age() {
return this._age;
}
set age(value:number) {
if (value < 0) throw new Error('年龄不能为负数')
this._age = value;
}
}

上面的代码就会直接报错:

1
2
不能将类型“number | undefined”分配给类型“number”。
不能将类型“undefined”分配给类型“number”。ts(2322)

修改方式一:当然就是去掉private _age: number;的可选属性,因为这本来就是和访问器属性冲突

修改方式二: 删除set属性访问器,如果set不是必要的,去掉set,当然也能避免这种逻辑冲突

修改方式三: 在get方法中加入对undefined的判断

1
2
3
4
get age() {
if(this._age === undefined) throw new Error('年龄未知')
return this._age;
}

修改方式四:非空断言

1
2
3
get age() {
return this._age!;
}

修改方式五:

一般我们都会默认打开tsconfig.json的配置属性"strict": true"strictNullChecks": true当然也随之开启。既然上面报错不能将类型“undefined”分配给类型“number”,其实就是因为把undefined作为了单独类型严格检查,当然不能赋值给number类型。如果不把undefinednull作为单独的类型严格检查,当然也就不会报这种错误了。"strictNullChecks": fasle即可。不过一般不建议随便修改工程配置项

3. 静态成员

在typescript中,你可以使用static关键字来表示一个成员为静态成员

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Animal { 

// 静态属性
static kingdom = "Animal";

// 静态方法
static showKingdom(): string {
console.log(Animal.kingdom);
console.log(this.kingdom);
return `The kingdom is ${Animal.kingdom}.`;
}
......
}
const s = Animal.showKingdom();
console.log(s)

在静态方法中虽然也能使用this,但是静态方法中的this 指的不是类的实例,而是类本身。这意味着在静态方法内部,this 指向的是类的构造函数,而不是类的实例。

其实如果我们把target降低到ES5以下(注意:降低target,如果没有lib的话,默认支持的语法同样也会降级,#这种写法肯定就不支持了),观察编译之后的js文件,你就会发现静态成员直接被挂载在函数体上,而实例成员挂载在原型上

1
2
3
4
5
6
7
8
9
10
// 静态方法
Animal.showKingdom = function () {
console.log(Animal.kingdom);
console.log(this.kingdom);
return "The kingdom is ".concat(Animal.kingdom);
};
// 实例方法
Animal.prototype.show = function () {
console.log(this.name, this.color, this._age);
};

4. 继承

有面向对象,那么肯定就有继承,和JavaScript一样,使用extends关键字来实现继承,当然类的继承还是具有单根性的特点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Father { 
constructor(public name: string) { }
info(): void {
console.log('Father Info');
}
}

class Child extends Father {
constructor(public name: string, public age: number) {
super(name);
}
override info(): string {
console.log("Child Info");
super.info();
return "";
}
}

父类和子类的概念相信大家已经不在陌生了,上面主要用到了几个关键字extendssuperoverride

**super:**在构造方法中调用使用super(),而且这种写法只能在构造方法中使用,如果子类有构造方法,那么必须调用super(),无论构造方法有没有参数,这样才可以把父子关系连接起来。不用担心你会忘记,你不写的话会报错!

在一般方法中调用使用super.xxx()的形式,表示调用的是父类的方法,特别是在子类重写父类的方法中(子类方法和父类方法同名的情况,其实就是覆盖父类的同名方法),这样可以很明显的区分调用的是父类的方法还是子类的方法。super只能调用父类的方法,不能访问父类的属性

override Typescript4.x新增的关键字,这是一个提醒关键字。使用override修饰的方法就表明该方法就是一个重写的覆盖父类的方法。如果子类中的方法没有在父类中出现过,那么不能使用override

5. this返回类型

this可以用作值,也能用作类型。

比如我们现在要做一个ES6的Set数据结构的简化版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class SimpleSet {
// 设置为元素设置为Map类型可以方便的使用内置的方法,我们不用重复造轮子
// Map类型也便于扩展
// 仅仅只关心键,值不关心,设置为boolean类型
private elements: Map<number, boolean>;

constructor() {
this.elements = new Map<number, boolean>();
}

add(element: number): SimpleSet {
this.elements.set(element, true);
return this; // 链式调用
}

has(element: number): boolean {
return this.elements.has(element);
}

delete(element: number): boolean {
return this.elements.delete(element);
}

values(): number[] {
return Array.from(this.elements.keys());
}
}

// 示例使用
const mySet = new SimpleSet();

mySet.add(1).add(2).add(3); // 链式调用

console.log(mySet.values()); // 输出: [1, 2, 3]

mySet.delete(2);
console.log(mySet.values()); // 输出: [1, 3]

其他都没有什么问题,但是如果我们还有一个子类,子类可能需要处理其他的内容。

1
2
3
4
5
class MutableSet extends SimpleSet {
show() {
console.log('MutableSet show')
}
}

乍一看没啥问题,但是我们如果再此调用add方法,就能看到具体的一些区别了

1
2
3
const mySet = new MutableSet();

mySet.add(1).add(2).add(3).show(); // add方法返回的是SimpleSet,根本调用不到show方法

所以,为了保证子类的this返回正确,只能重写覆盖父类的add方法,目的仅仅就只是为了改写返回的对象类型

1
2
3
4
5
6
7
8
9
class MutableSet extends SimpleSet {
add(element: number): MutableSet {
super.add(element);
return this;
}
show() {
console.log('MutableSet show')
}
}

其实,我们可以用一个简单的办法就能解决,在父类中,add方法的返回类型直接定义为this

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 父类
class SimpleSet {
add(element: number): this {
this.elements.set(element, true);
return this; // 链式调用
}
......
}

// 子类
class MutableSet extends SimpleSet {
show() {
console.log('MutableSet show')
}
}

// 示例使用
const mySet = new MutableSet();
mySet.add(1).add(2).add(3).show();

6. 抽象类

面向对象中还有一个概念:抽象类。抽象类是对类结构与方法的抽象。

面向对象的三大特征: 封装 继承 多态

关于后端语言对于多态的理解:

多态是同一个行为具有多个不同表现形式或形态的能力

6.1 多态存在的三个必要条件

  • 继承
  • 重写
  • 父类引用指向子类对象:Parent p = new Child();

image.png

后端java伪代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
abstract class Shape{
abstract void draw();
}
class Circle extends Shape{
void draw(){
System.out.println("Circle")
}
}

class Triangle extends Shape{
void draw(){
System.out.println("Triangle")
}
}

class Square extends Shape{
void draw(){
System.out.println("Square")
}
}

// 后端的多态
// 继承
// 重写
// 父类引用指向子类对象
Shape s = new Square();
s.draw();

6.2 TS中抽象类的语法

1
2
3
4
5
6
7
8
abstract class Foo {
abstract name: string;
abstract get nameGetter(): string;
abstract method(name: string): string;
static info(): void {
console.log('Foo Info');
}
}

注意,抽象类中的成员也需要使用 abstract 关键字才能被视为抽象类成员,反过来,如果一个类中有abstract修饰成员,那么必须在抽象类中

如果抽象类中全部是抽象成员,抽象方法或者静态方法,子类可以使用implements实现,当然也可以使用extends

1
2
3
4
5
6
7
8
9
class Baz implements Foo {
name = 'baz';
get nameGetter(): string {
return this.name;
}
method(name: string): string {
return name;
}
}

当然最关键的,抽象类并不能被实例化,抽象类出现的目的就是为了程序架构而生的,抽象类中全部都是抽象的方法,所以也没有实例化的必要。

1
const f = new Foo(); // error 无法创建抽象类实例

如果抽象方法中,有实例方法,那么子类必须要使用extends继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
abstract class Foo {
abstract name: string;
abstract get nameGetter(): string;
abstract method(name: string): string
static info(): void {
console.log('Foo Info');
}
foo() {
console.log('foo')
}
}

class Baz extends Foo {
name = 'baz';
get nameGetter(): string {
return this.name;
}
method(name: string): string {
return name;
}
}
const baz = new Baz();
const b = baz.method("hello");
console.log(b)
baz.foo();

其实道理很简单,当抽象类中编译之后其实在js(ES6)中还是一个普通的class类,全是抽象的,其实相当于什么都没有,这个实现关系只存在于TS中,所以使用implements还是extends都没有关系,对编译之后的js文件都没有影响。

但是如果抽象类中有具体的方法了,编译之后的js文件的class类中是有内容的,所以必须要使用extends

如果一个类中,并没有全部实现抽象类的抽象方法,那么这个类也必须是抽象的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
abstract class Foo {
abstract name: string;
abstract get nameGetter(): string;
abstract method(name: string): string;
static info(): void {
console.log('Foo Info');
}
}

abstract class Baz extends Foo {
// name = 'baz';
// get nameGetter(): string {
// return this.name;
// }
method(name: string): string {
return name;
}
}

class bar extends Baz {
name = 'bar';
get nameGetter(): string {
return this.name;
}
method(name: string): string {
return name;
}
}

7. 接口

面向对象中,另外一个非常重要的概念当然就是接口,按照面向对象的说法来说,接口是比抽象类还要抽象的概念,在接口中就只能有抽象的方法和属性,相当于就只能声明结构,不能有具体的实现。

比如上面的代码,我们改为interface实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
interface Foo {
name: string;
get nameGetter(): string;
method(name: string): string
}

interface Bar {
show(addr: string): string
}

class Baz implements Foo, Bar {
name = 'baz';
get nameGetter(): string {
return this.name;
}
method(name: string): string {
return name;
}
show(addr: string): string {
return addr;
}
}

在面向对象的语法特性中,接口主要是几点点:

1、类和接口的实现使用implements关键字

2、可以多实现

3、在接口中不能使用访问修饰符

4、接口和接口之间使用extends继承,而且可以多继承

其实通过编译之后,你会发现,抽象类至少还有一个空的类在那里,而interface的声明在编译之后的js文件夹中就直接删掉了

在我们不考虑面向对象这些特性的时候,纯前端程序员在使用接口的时候,是直接和类型别名type进行类比的。虽然继承,实现等也是表示类型关系。但是对于初学者来说,当我们需要一种类型的时候,用interface或者类型别名type来表示一种类型结构,可以减少很多心智负担。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
interface User{
readonly id: number
name: string
show: (addr: string) => void
}
// 对比类型别名的声明
type Person = {
readonly id: number
name: string
show: (addr: string) => void
}

const u: User = {
id: 1,
name: 'ruby',
show(addr: string): void {
console.log(addr)
}
}
u.id = 2; // error

7.1 声明合并

interface还有一种很重要的特性就是声明合并,就是typescript自动的把多个同名的interface接口声明组合在一起,当然这个特性不单单是接口有,枚举和namespace命名空间也有

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface User{
readonly id: number
name: string
show: (addr: string) => void
}
interface User {
age: number
}

const u: User = {
id: 1,
name: 'ruby',
show(addr: string): void {
console.log(addr)
},
age: 13
}

注意:虽然可以合并,但是并不能有冲突,如果出现键同名但是值不同类型的情况会报错

1
2
3
4
5
6
7
8
9
interface User{
readonly id: number
name: string
show: (addr: string) => void
age: string
}
interface User {
age: number // error
}

当然,接口和接口,接口和类型别名之间也能有继承关系,使用extends关键字。接口要继承类型别名,类型别名代码的类型应该是一个结构类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
interface User { 
id: number
name: string
}

interface Person extends User{
age: number
}

type Action = {
type: string
get(): string
set(v: string): void
}

interface Person extends Action {
sex: "男" | "女"
}

const p: Person = {
id: 1,
age: 13,
name: 'ruby',
sex: "男",
type: 'person',
get(): string {
return 'hello'
},
set(v: string): void {
console.log(v)
}
}

其实反过来,我们用type来实现也行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type PA = Person & Action & { sex: "男" | "女" }

const p: PA = {
id: 1,
age: 13,
name: 'ruby',
sex: "男",
type: 'person',
get(): string {
return 'hello'
},
set(v: string): void {
console.log(v)
}
}

8. 接口与类型别名的区别

这是一道在笔面试中经常出现的TS问题。当然你可以像背八股文一样,如下面这样回答:

  • 在对象结构扩展情况下,interface 使用 extends 关键字,而 type 使用交叉类型(&)。
  • 同名的 interface 会声明合并,当然需要兼容原接口的结构。
  • interface 与 type 都可以描述对象类型、函数类型、Class 类型,但 interface 无法像 type 那样表达基本数据类型、元组、联合类型等等。

关于这个问题,最好从基本的面向对象的区分来入手回答才能更加形象具体

首先,接口本来就是面向对象中出现的名字,其目的就是对事务或者行为抽象的总结提取。因此,接口本来就是用来描述某一个结构的。所以对于对象结构的扩展,接口使用的是extends专属于面向对象的关键字。而类型别名仅仅代码一个字面量的别名而已。和面向对象是没有任何实际意义上的关联的,类型别名只是人为的使用了交叉运算符&来模拟了继承的关系,实现对象结构扩展

其次,接口是面向对象的处理,因此在接口中,我们只能使用结构类型。而类型别名可以是任何类型,可以是基础类型,可以是函数,对象结构, 接口 无法像 类型别名 直接那样表达基础类型和元祖类型

正是由于面向对象,对于接口的声明就应该是抽象的描述,而不应该有复杂的类型逻辑运算,因此接口不能表达联合类型(交叉类型可以通过继承实现),也不应该在接口中去处理类型逻辑。类型别名则没有这些烦恼,因此如果要做类型运算首选的当然是类型别名

面向对象是对结构的设计,因此对于架构的考虑,接口可能需要对外公布的,所以接口有声明合并的特性。如果我们设计第三方库,有些类型需要考虑到使用者是否需要扩展,那么我们可以声明成接口。当然,如果不需要的话,直接就是类型别名了

8.1 接口与索引签名(映射)所引发的问题

但是接口的声明合并会引发一个可大可小的问题,稍微不注意,会给我们开发留下坑,要说明这个问题,我们一层层的来剖析,首先看下面的问题:

1
2
3
4
5
6
7
8
9
10
type Params = {   
id: string
}

// interface Params {
// id: string
// }

let p: Params = { id: '1' };
type A = typeof p

当时类型别名Params的时候,type A = typeof p能够很清楚的看到,类型就是字面量{id:'1'},但是当换成接口Params的时候,type A = typeof p只能看到类型是Params类型,大家有没有思考过这个问题呢?

image.png

这是因为,设置类型别名之后,VSCode知道当前的类型是固定的,不会再进行改变,而interface由于有声明合并的存在,并不能确定最后的接口内到底有什么内容,所以仅仅只是显示了接口名而已。

如果我们下面添加一个同名接口,就会出现问题。

image.png

接下来看索引签名的一个问题:

1
2
3
4
type MyRecord = { 
[key: string]: string
age: number // 报错 类型number的属性age不能赋值string索引类型string
}

因为索引签名已经规定了,键是string类型,值是string类型,下面我们要自定义自己的键值和上面规定的就不匹配了

所以,像这样的写法就错误了:

1
2
3
4
5
6
7
8
9
10
type MyRecord = { 
[key: string]: string
}

const record: MyRecord = {
name: "jack",
type: "admin",
sex: "男",
age: 20 // error
}

record前面属性键值都是匹配的,但是age不匹配,所以报错。

上面两个问题都搞清楚之后,下面的问题就好解决了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
interface MyInterface { 
name: string
}
type MyType = {
name: string
}

const example1: MyType = { name: 'example2'}
const example2: MyInterface = { name: 'example1' }


interface MyRecord {
[key: string]: string
}

let record: MyRecord = {}
record = example1;
record = example2; // error

上面MyInterfaceMyType明明一模一样,example1example2的值也差不多。但是当example2赋值给record的时候,却报错了。

原因其实就是TS不能根据自己的类型推导,确定类型签名与接口的值是匹配的,因为接口存在声明合并的情况,而且索引签名也存在如果出现和索引键值不匹配的情况会报错。

当然这是一个很简单的情况,有时候在不经意之间出现了这个问题,确实有点找不到原因所在,比如有下面的函数:

1
2
function useParams<ParamsOrKey extends string | Record<string, string | undefined>>() { }
useParams<MyType>()

Record<string, string | undefined>这个写法希望大家没有忘记,TS的工具函数,和下面这样写的意思是一样的:

1
2
3
{
[key:string]:string|undefined
}

不用管这个函数的泛型参数写的有点复杂,就是简单复刻了react-router路由的useParams钩子函数,其实就是接受一个字符串,或者索引类型的键值对。

当然泛型是上面的MyType类型别名的时候没有任何问题,但是如果是接口,直接报错:

1
2
3
function useParams<ParamsOrKey extends string | Record<string, string | undefined>>() { }
// 错误: 类型“MyInterface”不满足约束“string | Record<string, string | undefined>”。
useParams<MyInterface>()

如果你不知道上面的一些问题,这个错误够你找一天。

9. 类的类型理解升级

在typescript中的类有两个点必须要理解

1、在typescript中,类是结构化类型

2、在typescript中,类即声明值也声明类型,而且类的类型有两种:实例化对象类型与类构造函数类型

9.1 类是结构化类型

与typescript中的其他类型一样,typescript根据结构比较类,与类的名称无关。

类与其他类型是否兼容,要看结构

如果一个常规对象定义了同样的属性和方法,那么也与类兼容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class User {
constructor(public id: number, public name: string) { }
show(addr: string) {
console.log(addr)
}
}
class Person {
constructor(public id: number, public name: string) { }
show(addr: string) {
console.log(this.id + "---" + this.name + "---" + addr)
}
}

function desc(user: User) {
user.show("成都")
}

const u = new User(1, 'ruby');
const p = new Person(2, 'jack');

const a = {
id: 1,
name: 'ruby',
show(addr: string): void {
console.log("hello " + addr)
},
}

desc(u);
desc(p);
desc(a);

不过稍微要注意的是,如果类中有private或者protected修饰的字段,情况就不一样了,typescript会触发检查

9.2 类即是值也是类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class MyMap { 
state: Record<string, string> = {}

get(key: string): string | undefined {
return this.state[key]
}
set(key: string, value: string): void {
this.state[key] = value
}
values(): string[] {
return Object.values(this.state)
}
keys(): string[] {
return Object.keys(this.state)
}
static of(...entries: [string, string][]): MyMap {
const map = new MyMap();
entries.forEach(entry => map.set(entry[0], entry[1]))
return map;
}
}

const m1 = new MyMap();
m1.set("id","1")
m1.set("name", "jack")
console.log(m1.get("name"));
console.log(m1.values());

const m2 = MyMap.of(["name", "rose"], ["sex", "女"]);
console.log(m2.keys());

注意这句代码:

1
const m1:MyMap = new MyMap();

按照上面的写法m1:MyMap中的MyMap很明显是类型,new MyMap()既然已经在操作了,那明显应该是个值。

但是,如果仅仅是这样的话,中间缺失了一环

上面的代码中,m1指MyMap类的一个实例。这个是实例化类型。我们可以通过m1.xxx调用对应的属性和方法

new MyMap()这里既然表示是个值,那么他就应该有值所对应的类型,而且,对于静态方法和静态属性,我们不是还可以通过MyMap.xxx去调用吗?

所以,还有一个类型,表示的是MyMap()这个构造函数的类型

如果你觉得这么描述你不太清楚,我们换一种描述方式:

如果我现在有两个方法,其中一个方法需要的是对象,一个方法是类本身,那这个应该怎么限定?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class User{
constructor(public id: number, public name: string) { }
show() {
console.log(this.id, this.name);
}
}

function method1(target:User) {
console.log(target.id);
console.log(target.name);
target.show();
}

function method2(target: new (...args: [number, string]) => User) {
const t = new target(1, "jack");
}

method1(new User(1, "jack"));
method2(User);

构造函数类型的表示:

1
new (...args:any[]) => any;

但是,我们怎么表示MyMap类自身的构造函数类型呢?可以使用typeof关键字

1
type MyMapConstructorType = typeof MyMap;

甚至可以通过typescript给我们预留的工具,通过构造器类型再得到实例化类型:

1
type MyMapInstanceType = InstanceType<MyMapConstructorType>;

当然,我们并看不见这个类型中具体有哪些,我们可以模仿着ES关于类的处理,来模拟一下,比如String,Map这些类,大家可以看一下lib.es5.d.ts中的源代码

实例化类型的MyMap类型

1
2
3
4
5
6
7
interface MyMap {
state: Record<string, string>
get(key: string): string | undefined
set(key: string, value: string): void
values(): string[]
keys(): string[]
}

构造方法类型typeof MyMap

1
2
3
4
5
interface MyMapConstructor {
new(): MyMap
of(...entries: [string, string][]): MyMap
readonly prototype:MyMap
}

所以,对于构造函数类型,我们反过来推导一样成立:

1
2
3
var m: MyMapConstructor;
type M1 = typeof m;
type M2 = InstanceType<M1>;

如果你觉得MyMap复杂了一些,我们简化一下:

1
2
3
4
5
6
7
8
9
10
class Person {
constructor(public name: string) {}
}
type PersonInstance = InstanceType<typeof Person>
const person: PersonInstance = new Person('jack')

interface User {
new (name: string): User
}
type UserInstance = InstanceType<User> // User

其实这就对应着我们在vue的代码中,为模板标注类型,可以看到有这样的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script>
import {ref} from "vue"
import MyComp from "./components/MyComp.vue";

type MyCompConstructor = typeof MyComp;
type MyCompInstance = InstanceType<MyCompConstructor>;
const myComp = ref<MyCompInstance | null>(null)
// 简写
// const myComp = ref<InstanceType<typeof MyComp> | null>(null)

const openModal = () => {
myComp.value?.open();
}
</script>

<template>
<MyComp ref="myComp"/>
</template>

MyComp实际得到的是vue的配置对象,其实就相当于MyComp.vue.vue文件中,我们所写在script中的内容,包括data,methods,生命周期函数等等。

通过typeof MyComp其实得到了vue组件的构造函数类型,然后再通过InstanceType<MyCompConstructor>得到真正的vue实例对象类型,有了这样的类型之后,我们才能在声明的变量中访问到MyComp暴露出来的方法

10. 混入

10.1 JavaScript中的混入

在JavaScript中实现对象的混入很简单,我们经常在使用,一般我们都通过浅拷贝,简单地将一个对象的属性和方法复制到另一个对象中来完成。最常见的做法是使用 Object.assign() 或展开运算符(...)来实现

10.1.1 对象的混入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const canEat = {
eat: function () {
console.log("eating");
},
};

const canWalk = {
walk: function () {
console.log("walking");
},
};

// const person = Object.assign({}, canEat, canWalk);
const person = { ...canEat, ...canWalk };

person.eat(); // eating
person.walk(); // walking

当然这是基于对象的,如果是基于类的,也可以,基本也是两种做法,最简单的,其实就是把上面的person转换为class,然后把对象上的属性加入到class的原型上去

10.1.2 通过原型混入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const canEat = {
eat: function () {
console.log("eating");
},
};

const canWalk = {
walk: function () {
console.log("walking");
},
};

// const person = Object.assign({}, canEat, canWalk);
// const person = { ...canEat, ...canWalk };

// person.eat(); // eating
// person.walk(); // walking

class Person {
constructor(name) {
this.name = name;
}
}

// 将方法混入 Person 类的原型
Object.assign(Person.prototype, canEat, canWalk);

const person = new Person("Alice");
person.eat(); // eating
person.walk(); // walking

10.1.3 通过闭包高阶函数混入

这种做法其实在以前React使用类组件的时候,高阶组件经常见到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function withEating(Class) {
return class extends Class {
eat() {
console.log("eating");
}
};
}

function withWalking(Class) {
return class extends Class {
walk() {
console.log("walking");
}
};
}

class Person {
constructor(name) {
this.name = name;
}
}

const EatingAndWalkingPerson = withWalking(withEating(Person));

const person = new EatingAndWalkingPerson("Bob");
person.eat(); // eating
person.walk() // walking

10.2 Typescript实现混入

如果你理解了javascript混入,typescript的混入没有什么区别。我们通过typescript来实现一下高阶函数的混入。

这里有一个疑问需要解决一下,高阶函数的参数需要的是一个构造函数类型,如何声明一个构造函数类型呢?

要声明一个构造函数类型很简单,如下:

1
type ClassConstructor = new (...args: any[]) => any;

其中,最关键的就是new这个关键字,表示该签名是一个构造函数,意味着使用这个类型的函数可以通过 new 关键字被实例化。

当然我们可以加入泛型:

1
type Constructor<T = {}> = new (...args: any[]) => T;

那接下来的事情就简单了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 类构造器的类型
type Constructor<T = any> = new (...args: any[]) => T;

// 第一个混入,增加了一个时间戳属性
function Timestamped<TBase extends Constructor>(Base: TBase) {
return class extends Base {
timestamp = Date.now();
};
}

// 第二个混入,增加了一个打印方法
function Printable<TBase extends Constructor>(Base: TBase) {
return class extends Base {
print() {
console.log(this);
}
};
}

// 基础类
class MyClass {
constructor(public name: string) {}
}

// 创建一个混入了Timestamped和Printable的新类
const TimestampedPrintableMyClass = Timestamped(Printable(MyClass));

// 使用
const example = new TimestampedPrintableMyClass("test");
console.log(example.name); // 输出 'test'
console.log(example.timestamp); // 输出创建实例时的时间戳
example.print();

11. 构造函数相关类型工具

ConstructorParameters<Type>从构造函数类型中获取构造函数参数的元组类型

1
2
3
4
5
class User{
constructor(public id:number, public name: string){}
}

type ConstructorParamsType1 = ConstructorParameters<typeof User>; // [id: number, name: string]

对于ConstructorParameters的实现其实和之前的ReturnType十分类似,只不过现在我们需要构造函数类型去进行处理

1
2
3
type MyConstructorParams<T extends abstract new (...args: any[]) => any> = T extends abstract new (...args: infer R) => any ? R : never;

type ConstructorParamsType2 = MyConstructorParams<typeof User>;

我们可以扩展一个场景实现一下效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Book {
title: string;
content?: string;
constructor(title: string) {
this.title = title;
}
}
class CreateInstance<T extends new (...args: any[]) => any> {
private ClassConstructor: T;
constructor(classConstructor: T) {
this.ClassConstructor = classConstructor;
}

getInstance(...args: ConstructorParameters<T>): InstanceType<T> {
return new this.ClassConstructor(...args);
}
}

const instanceCreator = new CreateInstance(Book);
const book = instanceCreator.getInstance("Typescript类型全解");

另外和构造函数类型直接相关的就是InstanceType<Type>类型工具了,这个我们之前就用过了,可以通过构造函数类型得到实例对象类型。

1
2
3
4
5
class User{
constructor(public id:number, public name: string){}
}

type U = InstanceType<typeof User>;

要去实现这个类型工具也非常简单:

1
2
3
type MyInstanceType<T extends abstract new (...args: any[]) => any> = T extends abstract new (...args: any[]) => infer R ? R : never;

type InstanceUser = MyInstanceType<typeof User>;

再强调一句:typeof User,这里的User代表的是值

当然,可能会有些同学会思考,那么在TS中,可以直接写构造函数吗?

有这个思考,说明你对类型系统的认知还不够明确!

函数是具有二义性的,因此ES6中才有了箭头函数和类的区分,那我们TS中,如果你要使用类,别再考虑什么构造函数的写法,这是不正确的。要使用类,我们就应该使用class。要使用函数,那么这个函数就是一个普通函数。

不过这个思考非常有趣,我们可以接着这个错误思考,再来认知一下类的类型

1
2
3
4
5
function Person(id: number, name: string, age: number) {
this.id = id;
this.name = name;
this.age = age;
}

首先你这么写,在TS中就会直接报错,至少我们先把this的严格检查去掉noImplicitThis设置为false

但是这个我们在js中认知的构造函数,还不能直接new

1
const person = new Person(1, 'John Doe', 30); // error

会告知你:其目标缺少构造签名的 “new” 表达式隐式具有 “any” 类型

因为不知道new的到底是什么,我们可以使用简单的断言

1
const person = new (Person as any)(1, 'John Doe', 30);

来屏蔽这个错误,也能去掉严格检查的noImplicitAny设置为false

但是就算如此,我们得到的person,也仅仅是一个any类型的值,所以是没有任何TS提示的。

而且最关键的,如果我们使用InstanceType,试图通过这个工具帮我们找到实例对象类型也是徒劳的。

1
type PersonInstance = InstanceType<typeof Person>; // error

会告知你:类型“(id: number, name: string, age: number) => void”提供的内容与签名“new (…args: any): any”不匹配

因为typeof Person获取的仅仅就只是一个函数类型,而不会是一个构造函数类型

我们只能人为的构造类型去填补这些缺陷:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function Person(id: number, name: string, age: number) {
this.id = id;
this.name = name;
this.age = age;
}

interface Person {
id: number;
name: string;
age: number;
}
type PersonConstructor = new (id: number, name: string, age: number) => Person;
//当然也能写成接口形式:
// interface PersonConstructor {
// new(id: number, name: string, age: number): Person;
// readonly prototype:Person
// }

type PersonInstance = InstanceType<PersonConstructor>;

const person:PersonInstance = new (Person as any)(1, 'John Doe', 30);

除了ConstructorParameters<Type>InstanceType<Type> 这两个类型工具之外,再给大家介绍一个常用的类型工具Awaited<Type>

Awaited<Type>可以用来获取Promise中的类型(如await、then方法返回的被Promise包裹的数据的类型)。

其中Promise是我们常用的异步类,本身就有Promise类型和PromiseConstructor类型

1
type T = Awaited<Promise<Person>>; // Person

Awaited在我们处理异步场景的时候非常有用。

比如有时候我们从第三方库就仅仅能获取接口,并且这个接口可能还返回的是一个Promise类型,那么我们就可以通过Awaited帮我们获取具体函数返回的Promise类型的泛型,也就是真正的数据类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface User {
id: number;
firstName: string;
lastName: string;
age: number;
}
async function fetchUser(): Promise<User> {
const data = await fetch(
"https://mock.mengxuegu.com/mock/65b1f3d1c4cd67421b34cd0c/mock_ts/user"
).then((res) => {
return res.json();
});
return data;
}

比如上面的fetchUser()是一个第三方库的函数,如果我们希望获取真正返回的数据User的类型,就可以使用Awaited

1
2
3
4
5
6
7
8
type UserFetch = Awaited<ReturnType<typeof fetchUser>>

const user: UserFetch = {
id: 1,
firstName: "yuan",
lastName: "jin",
age: 18,
}
1
2
3
4
5
6
type Awaited<T> = T extends null | undefined ? T : // 当T是null | undefined时就直接取值
T extends object & { then(onfulfilled: infer F, ...args: infer _): any; } ? // `await` 仅使用可调用的 `then` 来解包对象类型。 非对象类型不会被解包
F extends ((value: infer V, ...args: infer _) => any) ? // 如果“then”的参数是可调用的,则提取第一个参数
Awaited<V> : // 递归调用Awaited,将解开的类型V传入
never : // `then` 的参数不可调用获取never
T; // 不是对象类型或者没有then的时候直接获取T类型

也就是说,Awaited可以深层嵌套,如果没有Promise的话,就直接返回类型:

1
2
3
4
5
6
7
8
9
type A = Awaited<Promise<string>>; // string

type B = Awaited<Promise<Promise<number>>>; // number

type C = Awaited<boolean | Promise<number>>; // number | boolean

type D = Awaited<number>; // number

type E = Awaited<null>; // null

那如果,我希望传入的如果不是Promise类型就直接报错呢?我们完全可以将Awaited再封装一下,使用PromiseLike

1
2
3
4
5
6
7
type MyAwaited<T extends PromiseLike<any>> = Awaited<T>;

//......

type F = MyAwaited<number>; // error 类型“number”不满足约束“PromiseLike<any>”。

type G = MyAwaited<null>; // error 类型“null”不满足约束“PromiseLike<any>”。