typescript学习-class

128 阅读10分钟

typescript的作者与C#的作者是同一个人,以面向对象特性的思想来设计,因此和传统的面向对象语言一样,很多特性都是相通的,以下总结关于学习官方文档的一些收获

一、属性修饰符

1.常规修饰符

在typescript中,常规修饰符有以下四种:

  • readonly

    只读修饰符,用于表明属性是否只读

  • public

    公有成员修饰符,当不声明任何属性可见性时,则默认是公有的,表明在class外部也可以访问对应的成员

  • protected

    受保护成员修饰符,在继承类中,可以访问对应的成员

  • private

    私有成员修饰符,只有在当前类中,可以访问对应的成员

2.特殊修饰符

这儿说的特殊修饰符,从本质上来说,并不是typescript所有的,应该是javascript所有的,真正的私有成员修饰符#

  • 不完全的成员保护

    typescript是javascript的增强类型,不能改变javascript的运行时态,因此不能实现javascript没有的特性,上面的所有的常规修饰符,在编译后都会被移除,所以在运行时,没任何的成员可见性保护。甚至在代码层面,typescript也留下了后门,如下:

    class A {
      private a = 10
    }
    let a = new A()
    console.log('a' in a) // true
    console.log(a['a']) // 10 在使用[]时,会有提示
    let keys = Object.keys(a)
    console.log(keys) // ['a']
    

    在运行时,typescript的private修饰符无法做到真正的成员可见性保护,但这些常规修饰符的主要目的,在于表明对于一个成员的使用预期(比如不希望能从外部修改),因此修饰符本身是有存在意义的。普通的.语法不会提示任何非公有成员,但[]语法却可以提示,这就是所谓的非完全的成员访问限制

  • 真正的成员保护

    随着javascript的发展,javascript的语言规范被一次次修改。在新的特性中,类的私有成员被采用为规范之一,那就是使用#去修饰一个成员,如下:

    class A {
      #a = 10
    }
    let a = new A()
    console.log('a' in a) // false
    console.log(a['a']) // 提示不存在
    let keys = Object.keys(a)
    console.log(keys) // []
    

    由于typescript会一直紧跟ECAMScript的规范进行更新,而浏览器对于新特性的实现具有滞后性,因此这个特性可能还未在所有浏览器上实现,但依旧可以从代码层面实现。typescript编译结果:

    • ES2021及以上

      在将typescript编译到这个规范区间时,代码直接使用#修饰符实现私有特性

    • 更低版本

      js代码中,会使用weakMap实现这个特性,利用weakMap无引用,自动释放内存的特性。

二、继承

1.继承原理

我们知道,js的继承是通过原型链来实现的,而class是在ES6之后被提出并实现的,在ES6之前,开发者们也一直在通过function实现完善的继承方案,最终得出较为完美的方案为组合寄生继承,这个地方不做展开介绍,建议自行百度,现在的class实现继承,也被认为是组合寄生继承的语法糖。简单来说,就是借用构造函数继承属性,将方法放到原型链中,实现的效果为,所有的实例对象拥有私有的属性和公有的方法

2.重载约束

在类的继承中,子类是可以重新实现父类方法的,这种方式被称之为方法重载。方法重载是需要满足一定约束的,子类的定义需要满足父类方法的定义,如下:

type Type = {
  length:number
}
class A {
  do(param: Type):number{
    return param.length
  }
}
class B extends A {
  do(param: Type):number {
    return param.length + 30
  }
}

3.重定义

假如某些成员变量是在父类中被初始化,但父类中该成员的类型是一个父类型,传入的初始化类型是一个子类型时,便可以采用成员重定义,如下:

interface Animal {
  dateOfBirth: any;
}
 
interface Dog extends Animal {
  breed: any;
}

class AnimalHouse {
  resident: Animal;
  constructor(animal: Animal) {
    this.resident = animal;
  }
}
 
class DogHouse extends AnimalHouse {
  constructor(dog: Dog) {
    super(dog);
  }
}
let dog:Dog = {
  breed:1,
  dateOfBirth:'2022'
}
let dogH = new DogHouse(dog)

代码很简单,存在一只狗和一个狗屋,开发者是清晰知道狗屋内的动物是狗的,由于resident是在父类中初始化,类型是动物,因此最终得到的resident是动物类型,而不是狗。如果在子类中重定义类型,严格判空设置下,提示初始化,如下:

class DogHouse extends AnimalHouse {
  resident:Dog // 属性“resident”没有初始化表达式,且未在构造函数中明确赋值
  constructor(dog: Dog) {
    super(dog);
  }
}

此时,便可以使用declare关键字类重定义,但需要编译版本>=ES2022或者开启useDefineForClassFields,如下:

class DogHouse extends AnimalHouse {
  declare resident:Dog
  constructor(dog: Dog) {
    super(dog);
  }
}

三、抽象类

抽象类和普通类大部分特性是保持一致的,只不过抽象类可以拥有抽象成员,并且无法实例化。子类必须实现抽象类中的抽象成员,简单示例如下:

abstract class A {
  abstract a:number
  abstract make(param:number):void
}
class B extends A {
  make(param: number): void {
  }
  a: number = 10
}

抽象类一般作为基类存在,制定一些约束规范,更像是interface和普通class的整合结构。需要注意的是,抽象类不一定全是抽象成员,也可以存在普通成员。

四、静态成员

静态成员与类的实例无关,可以通过类对象本身进行访问。静态成员和普通成员一样,也可以通过修饰符来修饰,静态成员的初始化是在代码加载时。静态成员都需要使用static修饰符来修饰,并且在静态代码块中只能访问静态成员。如下:

class A {
  a = 10
  static b = 20
  static print() {
    console.log(this.b)
    console.log(this.a) // error 不存在属性a
  }
}
A.print()

除了常规的静态方法,静态属性,还有一个特殊的静态块,直接用static来定义,类似constructor,可以作为静态成员的初始化代码块。如下:

class A {
  static b = 20
  static print() {
    console.log(this.b)
  }
  static { // 静态块
    this.b = 30
  }
}

五、this指向

javascript是动态类型的语言,其this的指向是根据当前的上下文环境来确定的,因此这对于typescript这种静态类型而言是是十分困惑的。假如存在如下场景:

class A {
  a = 10
  print(){
    console.log(this.a)
  }
}
let a = new A()
let obj = {
  a:20,
  print:a.print
}
obj.print() // 20

此时产生的结果是20,但我们的预期绝大多数都是10,这就是因为javascript在运行时会带来的一些困惑。简单来说,方法内部的this指向取决于调用对象,在上述例子中,调用对象是obj,所以this指向obj。为了解决这个问题,我们可以把print方法更改为箭头函数。如下:

class A {
  a = 10
  print=() => {
    console.log(this.a)
  }
}
let a = new A()
let obj = {
  a:20,
  print:a.print
}
obj.print() // 10

此时能完美的解决this的指向问题,因为箭头函数本身没有this,其方法内部的this指向在定义时就会确定下来。但这种方式会浪费一些内存,虽然这点内存很少,但这对于理解继承的实现是很有必要的,做以下实验:

class A {
  a = 10
  print(){
    console.log(this.a)
  }
}
let a = new A()
let b = new A()
console.log(a.print === b.print) // true

此时a,b2个对象的print成员是方法,这个方法是存在于原型链上的,因此是同一个。那么看看以下情形:

class A {
  a = 10
  print = () => {
    console.log(this.a)
  }
}
let a = new A()
let b = new A()
console.log(a.print === b.print)  // false

print方法更改为箭头函数的形式,那么此时a,b2个对象的print成员不相等了,此时print是作为属性存在的,而没有存在于原型链上。每个实例对象都会存在自己的print成员,因此会浪费内存。

这也印证了class的实现,就是把方法放到原型链上,属性放到实例中

六、this参数与this类型

this相关的不仅只有方法内部的this指向问题。方法上还可以使用this参数与this类型

  • this参数

    this参数是一个特殊参数,用于指定方法内部的this类型。主要是因为javascript在运行时,方法内部的this指向不固定,因此typescript使用this参数用于表明方法内部this的类型。用法如下:

    type B = {
      b:number
    }
    class A {
      a = 10
      print(this:B){
        console.log(this.b)
      }
    }
    
  • this类型

    与this参数相较而言,this类型是一个更加好用以及实用的特性。在class内部的方法声明中,可以将this作为一个类型,其约束为当前class以及其派生类。它是一个动态类型,实际类型取决于调用对象,例子如下:

    class Box {
      contents: string = "";
      set(value: string):this {
        this.contents = value;
        return this;
      }
    }
    class ClearableBox extends Box {
      clear() {
        this.contents = "";
      }
    }
    
    const a = new ClearableBox();
    const b = a.set("hello").clear();
    

    Box作为一个基类,它的set方法返回的是this类型,而不是固定的Box类型或者ClearableBox类型。这就表示this的类型和调用对象类型一致。在上述示例中,这种特性在使用链式调用特别方便,因为不管是调用父类方法,还是自身方法,其类型推断永远都是调用对象的类型。

七、其他

1. 存取器

除了传统修饰符readonly可以用于声明一个只读属性外。还有另一个方法也可以实现属性只读,并且是严格意义上的只读,在运行时也无法修改。那就是使用存取器方法。

  • get

    用于修饰一个属性,本质上是一个方法,当访问被修饰属性时,获取的值是方法返回值

  • set

    用于修饰一个属性,本质上是一个方法,接收一个参数,参数值是赋值操作传入的值

例子如下:

class A {
  #a = 10
  get a(){
    return this.#a
  }
  set a(val){
    this.#a = val
  }
}

let a = new A()
a.a = 30
console.log(a.a)

存取器一般搭配私有属性一起使用。这个特性与C#简直一模一样,不愧是同一个作者的东西。

2. 索引标签

在上述的所有描述中,class内部都有着明确的属性或者方法名,但class还可以用索引方法声明,虽然不推荐,但可以作为了解。例子如下:

class A {
  [index:string]:string
}
let a = new A()
a.param = 'param'

3. 参数属性

class模块内的constructor方法不仅是作为对象实例的构造方法,也可以在参数部分声明对象的实例属性。示例如下:

class Params {
  constructor(
    public readonly x: number,
    protected y: number,
    private z: number
  ) {
    // No body necessary
  }
}
const a = new Params(1, 2, 3);

4. 抽象构造标签

当编写一个工厂方法,参与类型必须满足某一个构造标签时,如果构造标签是抽象类型,此时则会遇到问题,例子如下:

abstract class Base {

}
class Derived extends Base {
  
}
function greet(ctor: typeof Base) {
  const instance = new ctor(); // 提示无法创建抽象类型的实例
  return instance
}
greet(Derived);

开发者本意可能是想创建Derived类型的实例,但typescript只会以为开发者想创建抽象类型Base的实例,因此会提示错误。此时可以改写为以下模式:

abstract class Base {

}
class Derived extends Base {

}
function greet(ctor: new () => Base) {
  const instance = new ctor();
  return instance
}
greet(Derived);

5. "鸭式辩型"比较

typescript的类型比较,是遵循“鸭式辩型”法的。即只要结构满足,那则认为类型是一致的,直接用以下示例来举例:

  • 结构一致

    class Point1 {
      x = 0;
      y = 0;
    }
    
    class Point2 {
      x = 0;
      y = 0;
    }
    
    const p: Point1 = new Point2();
    
  • 结构包含

    class Person {
      name: string;
      age: number;
    }
    
    class Employee {
      name: string;
      age: number;
      salary: number;
    }
    
    // OK
    const p: Person = new Employee();
    
  • 空类型-空类型任何对象都满足

    class Empty {}
    function fn(x: Empty) {
      // can't do anything with 'x', so I won't
    }
    
    // All OK!
    fn(window);
    fn({});
    fn(fn);