• 【TypeScript】类类型:如何高效使用类型化的面向对象编程利器?


    自我介绍:大家好,我是吉帅振的网络日志(其他平台账号名字相同),互联网前端开发工程师,工作5年,去过上海和北京,经历创业公司,加入过阿里本地生活团队,现在郑州北游教育从事编程培训。

    一、前言

    在JavaScript(ES5)中仅支持通过函数和原型链继承模拟类的实现(用于抽象业务模型、组织数据结构并创建可重用组件),自 ES6 引入 class 关键字后,它才开始支持使用与Java类似的语法定义声明类。TypeScript 作为 JavaScript 的超集,自然也支持 class 的全部特性,并且还可以对类的属性、方法等进行静态类型检测。

    二、类

    在实际业务中,任何实体都可以被抽象为一个使用类表达的类似对象的数据结构,且这个数据结构既包含属性,又包含方法,比如我们在下方抽象了一个狗的类。

    class Dog {
    
      name: string;
    
      constructor(name: string) {
    
        this.name = name;
    
      }
    
    
    
      bark() {
    
        console.log('Woof! Woof!');
    
      }
    
    }
    
    
    
    const dog = new Dog('Q');
    
    dog.bark(); // => 'Woof! Woof!'
    
    • 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

    首先,我们定义了一个 class Dog ,它拥有 string 类型的 name 属性(见第 2 行)、bark 方法(见第 7 行)和一个构造器函数(见第 3 行)。然后,我们通过 new 关键字创建了一个 Dog 的实例,并把实例赋值给变量 dog(见 12 行)。最后,我们通过实例调用了类中定义的 bark 方法(见 13 行)。如果使用传统的 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!'
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    在第 1~ 3 行,我们定义了 Dog 类的构造函数,并在构造函数内部定义了 name 属性,再在第 4 行通过 Dog 的原型链添加 bark 方法。和通过 class 方式定义类相比,这种方式明显麻烦不少,而且还缺少静态类型检测。因此,类是 TypeScript 编程中十分有用且不得不掌握的工具。

    三、继承

    在 TypeScript 中,使用 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
    
    • 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

    上面的例子展示了类最基本的继承用法。比如第 8 ~12 行定义的Dog是派生类,它派生自第 1~6 行定义的Animal基类,此时Dog实例继承了基类Animal的属性和方法。因此,在第 15~17 行我们可以看到,实例 dog 支持 bark、say、type 等属性和方法。

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

    细心的你可能发现了,这里的 Dog 基类与第一个例子中的类相比,少了一个构造函数。这是因为派生类如果包含一个构造函数,则必须在构造函数中调用 super() 方法,这是 TypeScript 强制执行的一条重要规则。

    如下示例,因为第 1~10 行定义的 Dog 类构造函数中没有调用 super 方法,所以提示了一个 ts(2377) 的错误;而第 12~22 行定义的 Dog 类构造函数中添加了 super 方法调用,所以可以通过类型检测。

    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!');
    
      }
    
    }
    
    • 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

    这里的 super() 是什么作用?其实这里的 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!');
    
      }
    
    }
    
    • 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

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

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

    类属性和方法除了可以通过 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'.
    
    • 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

    在上面的例子中我们可以看到,第 3 行 Son 类的 lastName 属性是私有的,只在 Son 类中可见;第 2 行定义的 firstName 属性是公有的,在任何地方都可见。因此,我们既可以通过第 10 行创建的 Son 类的实例 son 获取或设置公共的 firstName 的属性(如第 11 行所示),还可以操作更改 firstName 的值(如第 12 行所示)。不过,对于 private 修饰的私有属性,只可以在类的内部可见。比如第 6 行,私有属性 lastName 仅在 Son 类中可见,如果其他地方获取了 lastName ,TypeScript 就会提示一个 ts(2341) 的错误(如第 14 行)。

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

    目前,JavaScript 类支持 private 修饰符的提案已经到 stage 3 了。相信在不久的将来,私有属性在类型检测和运行阶段都可以被限制为仅在类的内部可见。

    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.
    
    • 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

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

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

    五、只读修饰符

    在前面的例子中,Son 类 public 修饰的属性既公开可见,又可以更改值,如果我们不希望类的属性被更改,则可以使用 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.
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    在第 2 行,我们给公开可见属性 firstName 指定了只读修饰符,这个时候如果再更改 firstName 属性的值,TypeScript 就会提示一个 ts(2540) 的错误(参见第 9 行)。这是因为只读属性修饰符保证了该属性只能被读取,而不能被修改。

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

    六、存取器

    除了上边提到的修饰符之外,在 TypeScript 中还可以通过gettersetter截取对类成员的读写访问。通过对类属性访问的截取,我们可以实现一些特定的访问控制逻辑。下面我们把之前的示例改造一下,如下代码所示:

    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"
    
    • 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

    在第 14~24 行,我们使用 myLastName 的gettersetter重写了之前的 GrandSon 类的方法,在 getter 中实际返回的是 lastName 属性。然后,在 setter 中,我们限定仅当 lastName 属性值为 ‘Tony’ ,才把入参 name 赋值给它,否则打印错误。在第 28 行中,我们可以像访问类属性一样访问getter,同时也可以像更改属性值一样给setter赋值,并执行一些自定义逻辑。在第 27 行,因为 grandSon 实例的 lastName 属性被初始化成了 ‘Tony’,所以在第 29 行我们可以把 ‘Rogers’ 赋值给 setter 。而 grandSon1 实例的 lastName 属性在第 32 行被初始化为 ‘Tony1’,所以在第 33 行把 ‘Rogers’ 赋值给 setter 时,打印了我们自定义的错误信息。

    七、静态属性

    以上介绍的关于类的所有属性和方法,只有类在实例化时才会被初始化。实际上,我们也可以给类定义静态属性和方法。因为这些属性存在于类这个特殊的对象上,而不是类的实例上,所以我们可以直接通过类访问静态属性,如下代码所示:

    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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    在第 2~3 行,通过 static 修饰符,我们给 MyArray 类分别定义了一个静态属性 displayName 和静态方法 isArray。之后,我们无须实例化 MyArray 就可以直接访问类上的静态属性和方法了,比如第 8 行访问的是静态属性 displayName,第 9~10 行访问的是静态方法 isArray。基于静态属性的特性,我们往往会把与类相关的常量、不依赖实例 this 上下文的属性和方法定义为静态属性,从而避免数据冗余,进而提升运行性能。

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

    八、抽象类

    接下来我们看看关于类的另外一个特性——抽象类,它是一种不能被实例化仅能被子类继承的特殊类。我们可以使用抽象类定义派生类需要实现的属性和方法,同时也可以定义其他被继承的默认属性和方法,如下代码所示:

    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
    
    • 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

    在第 1~10 行,通过 abstract 关键字,我们定义了一个抽象类 Adder,并通过abstract关键字定义了抽象属性xy及方法add,而且任何继承 Adder 的派生类都需要实现这些抽象属性和方法。同时,我们还在抽象类 Adder 中定义了可以被派生类继承的非抽象属性displayName和方法addTwice。然后,我们在第 12~23 行定义了继承抽象类的派生类 NumAdder, 并实现了抽象类里定义的 x、y 抽象属性和 add 抽象方法。如果派生类中缺少对 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;
    
      }
    
    }
    
    • 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

    在第 1~5 行,我们定义了一个包含 x、y、add 属性和方法的接口类型(详见 07 讲),然后在第 6~12 行实现了拥有接口约定的x、y 属性和 add 方法,以及接口未约定的 addTwice 方法的NumAdder类 。

    九、类的类型

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

    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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

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

    十、总结

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

  • 相关阅读:
    环信IM集成教程——Web端UIKit快速集成与消息发送
    Vue-CLI的安装、使用及环境配置(超详细)
    初心如磐、砥砺筑梦,谱写云原生时代新篇章
    Docker-系统环境
    图解LSTM VS GRU
    (附源码)spring boot大学生就业质量调查分析系统 毕业设计 161457
    面试总结之并发编程
    JVM 垃圾回收器分类
    每日五道java面试题之spring篇(五)
    SAMBA配置
  • 原文地址:https://blog.csdn.net/qq_42451979/article/details/126243575