[TS手册学习] 04_类

发布时间 2023-12-01 17:43:16作者: feixianxing

TS官方手册:TypeScript: Handbook - The TypeScript Handbook (typescriptlang.org)

类 Class

类的成员

初始化

类的成员属性声明类型:

class Point {
  x: number;
  y: number;
}

类的成员属性初始化,会在实例化的时候完成赋值:

class Point {
  x: number = 0;
  y: number = 0;
}
严格初始化

--strictPropertyInitialization配置项为true的时候,要求成员属性必须初始化,否则报错。

可以在声明成员属性的时候初始化,也可以在构造函数中初始化。

class GoodGreeter {
    name: string;
    constructor() {
        this.name = "hello";
    }
}

如果打算在构造函数以外初始化字段,例如依赖一个外部库来填充类的一部分,则可以使用断言运算符!来声明属性是非空的。

class OKGreeter {
  // 没有初始化,但不会报错
  name!: string;
}
只读 readonly

使用readonly修饰,被readonly修饰的成员只能在构造函数中被赋值(初始化),在其它成员方法中的更新操作会导致错误。

class Greeter {
    readonly name: string = "world";

    constructor(otherName?: string) {
        if (otherName !== undefined) {
            this.name = otherName;
        }
    }

    err() {
        // name属性是只读的,这里会导致报错。
        this.name = "not ok";
    }
}
构造函数
  • 参数列表的类型声明;

  • 参数的默认值;

  • 构造函数重载:

    class Point {
        // Overloads
        constructor(x: number, y: string);
        constructor(s: string);
        constructor(xs: any, y?: any) {
            // TBD
        }
    }
    

构造函数签名与函数签名之间的区别:

  • 构造函数不能使用泛型;
  • 构造函数不能声明返回值类型。
成员方法

成员方法可以像函数一样使用类型标注:参数列表的类型与默认值、返回值类型、泛型、重载......

class Point {
    x = 10;
    y = 10;
    scale(n: number): void {
        this.x *= n;
        this.y *= n;
    }
}

:在成员方法中使用成员属性要通过this,否则可能顺着作用域链找到类外部的变量。

let x: number = 0;
class C {
    x: string = "hello";

    m() {
        // 这里的x是第1行的x,类型为number,不能赋值为string,故报错。
        x = "world";
    }
}
访问器 getter/setter

在 JS 中,如果没有需要做数据拦截的需求,是不需要用访问器的,大可以直接将属性public暴露到外部。

在 TS 中,访问器存在如下规则:

  • 如果有getter但没有setter,那么属性是只读的readonly
  • 如果没有指定setter方法的value参数类型,那么则以getter的返回值类型替代;
  • getter和setter的成员可访问性(public/private/protected)必须一致。
索引签名

可以为类的实例定义索引签名,但是很少用,一般将索引数据转移到别处,例如转而使用一个对象类型或者数组类型的成员。

类的继承

和其它面向对象语言一样,JS 中的类可以从基类中继承成员属性和方法。

implements子句(实现接口)
interface Pingable {
    ping(): void;
}
 
class Sonar implements Pingable {
    ping() {
        console.log("ping!");
    }
}

接口只负责声明成员变量和方法,如果一个类要实现一个接口,则需要实现内部的所有方法。

一个类可以实现多个接口。

注意

  1. 如果接口中声明了函数的类型,在实现该接口的类中仍要声明类型:
interface Checkable {
    check(name: string): boolean;
}
 
class NameChecker implements Checkable {
    check(s) {
        // 这里的 s 会被认为是any类型,any类型没有toLowerCase方法,会报错
        return s.toLowerCase() === "ok";
    }
}
  1. 当一个类实现一个接口时,这个接口中的可选属性(optional property)不会被待到类中。
extends子句(继承基类)
class A extends B{}

其中A被称为子类或派生类,B是父类或基类。

继承一个类将继承它的所有成员属性和方法。

方法重写(overriding methods)

可以使用super获取到父类的方法。

class Base {
    greet() {
        console.log("Hello, world!");
    }
}

class Derived extends Base {
    greet(name?: string) {
        if (name === undefined) {
            super.greet();
        } else {
            console.log(`Hello, ${name.toUpperCase()}`);
        }
    }
}

const d = new Derived();
d.greet();
d.greet("reader");

可以将一个子类的实例赋值给一个父类的实例(实现多态的基础)。

成员可访问性 member visibility

public

缺省值。使用public修饰的成员可以被任意访问。

protected

只有这个类和它的子类的成员可以访问。

子类在修饰继承自父类的成员可访问性时,最好带上protected,否则会默认地变成public,将成员暴露给外部。

class Base {
  protected m = 10;
}
class Derived extends Base {
  // 没有修饰,默认表示public
  m = 15;
}
const d = new Derived();
console.log(d.m); // 暴露到外部了

跨继承访问protected成员

protected的定义就是只有类本身和子类可以访问。但是在某些面向对象的编程语言中可以通过基类的引用,访问到非本身且非子类的protected成员。

这种操作在 Java 中被允许,但是在C#、C++、TS 中是非法操作。

原则是:如果D2不是D1的子类,根据protected的定义这种访问方式就是不合法的。那么基类跨越这种技巧不能很好的解决问题。当在编码的过程中遇到这种无法访问的权限问题时,应更多地思考类之间的结构设计,而不是采用这种取巧的方式。

image-20231201120318147
class Base {
    protected x: number = 1;
}
class Derived1 extends Base {
    protected x: number = 5;
}
class Derived2 extends Base {
    f1(other: Derived2) {
        other.x = 10;
    }
    f2(other: Derived1) {
        // x被protected修饰,只能被Derived1的子类访问,但是Derived2不是它的子类,无权访问,会报错。
        other.x = 10;
    }
}
private

只有类本身可以访问。

与protected不同,protected在子类中可访问,因此可以在子类中进一步开放可访问性(即改为public)。

但是private修饰的成员无法在子类中访问,因为无法进一步开放可访问性。

跨实例访问private成员

不同的实例只要是由一个类创建,那么它们就可以相互访问各自实例上由private修饰的成员。

class A {
    private x = 10;
    public sameAs(other: A) {
        // 不会报错,因为TS支持跨实例访问private成员
        return other.x === this.x;
    }
}

大多数面向对象语言支持这种特性,例如:JavaC#C++SwiftPHPTS 也支持。Ruby不支持。

注意事项
  • 成员可访问性只在TS的类型检查过程中有效,在最终的 JS 运行时下是无效的,在 JS 运行时下,in操作符和其它获取对象属性的方法可以获取到对象的所有属性,不管在 TS 中它们是public还是protected还是private修饰的。

  • private属性支持使用obj.[key]格式访问,使得单元测试更加方便,但是这种访问方式执行的是不严格的private

    class MySafe {
      private secretKey = 12345;
    }
    const s = new MySafe();
    // 由private修饰的成员无法被访问,这里会报错。
    console.log(s.secretKey);
    // 使用字符串索引访问,不严格,不会报错。
    console.log(s["secretKey"]);
    

静态成员

基本特性

静态成员绑定在类对象上,不需要实例化对象就能访问。

静态成员也可以通过publicprotectedprivate修饰可访问性。

静态成员也可以被继承。

静态成员不能取特殊的变量名,例如:namelengthcall等等。

不要使用Function原型上的属性作为静态成员的变量名,会因为冲突而出错。

静态代码块static block

静态代码块中可以访问到类内部的所有成员和类外部的内容,通常静态代码块用来初始化类。

class Foo {
    static #count = 0;
    get count() {
        return Foo.#count;
    }
    static {
        try {
            const lastInstances = loadLastInstances();
            Foo.#count += lastInstances.length;
        }
        catch {}
    }
}

泛型类

class Box<Type> {
    contents: Type;
    constructor(value: Type) {
        this.contents = value;
    }
}
const b = new Box("hello!");

泛型类的静态成员不能引用类型参数。

this 在运行时的指向问题

class MyClass {
    name = "MyClass";
    getName() {
        return this.name;
    }
}
const c = new MyClass();
const obj = {
    name: "obj",
    getName: c.getName,
};

// 这里会输出"MyClass"
console.log(c.getName());
// 这里输出结果是"obj",而不是"MyClass",因为方法是通过obj调用的。
console.log(obj.getName());

类的方法内部的this默认指向类的实例。但是一旦将方法挑出外部,单独调用,就很可能报错。因为函数中的this指向调用该函数的对象,成员方法中的this不一定指向它的实例对象,而是指向实际调用它的对象。

一种解决方法:使用箭头函数。

箭头函数中的this指向取决于定义该箭头函数时所处的上下文,而不是调用时。

class MyClass {
    name = "MyClass";
    getName = () => {
        return this.name;
    };
}
const c = new MyClass();
const g = c.getName;
// 这里会输出"MyClass"
console.log(g());

  • 这种解决方案不需要 TS 也能实现;

  • 这种做法会需要更多内存,因为箭头函数不会被放到原型上,每个实例对象都有相互独立的getName方法;

  • 也因为getName方法没有在原型链上,在这个类的子类中,无法使用super.getName访问到getName方法。

另一种解决方法:指定this的类型

我们希望this指向实例对象,意味着this的类型应该是MyClass而不能是其他,通过这种类型声明可以在出错的时候及时发现。

class MyClass {
    name = "MyClass";
    // 指定this必须是MyClass类型
    getName(this: MyClass) {
        return this.name;
    }
}
const c = new MyClass();
// OK
c.getName();

const g = c.getName;
// Error: 这里的this会指向undefined或者全局对象。
console.log(g());

  • 每个类定义分配一个函数,而不是每个类实例分配一个函数;
  • 可以使用super调用,因为存在于原型链上。

this 类型

在类里存在一种特殊的类型this,表示当前类。

返回值类型为this的情况

class Box {
    contents: string = "";
    // set方法返回了this(这里的this是对象的引用),因此set方法的返回值类型被推断为this(这里的this是类型)
    set(value: string) {
        this.contents = value;
        return this;
    }
}

参数类型为this的情况

class Box {
    content: string = "";
    sameAs(other: this) {
        return other.content === this.content;
    }
}

这种情况下的other:thisother:Box不同,当一个类继承自Box时,子类中的sameAs方法的this类型将指向子类类型而不是Box

使用this进行类型守护(type guards)

可以在类或接口的方法的返回值类型处使用this is Type,并搭配if语句进行类型收束。

class FileSystemObject {
    isFile(): this is FileRep {
        return this instanceof FileRep;
    }
    isDirectory(): this is Directory {
        return this instanceof Directory;
    }
    isNetworked(): this is Networked & this {
        return this.networked;
    }
	constructor(public path: string, private networked: boolean) {}
}
// 这里省略了子类的定义...

// 当需要类型收束时:
const fso: FileSystemObject = new FileRep("foo/bar.txt", "foo");
 
if (fso.isFile()) {
    // 调用isFile方法将返回boolean类型,并且在这个块内,fso的类型会收束为FileRep
    fso.content;
} else if (fso.isDirectory()) {
    fso.children;
} else if (fso.isNetworked()) {
    fso.host;
}

另外一种常用的情景是:移除undefined类型。

class Box<T> {
    value?: T;
    hasValue(): this is { value: T } {
        return this.value !== undefined;
    }
}
 
const box = new Box();
box.value = "Gameboy";

// (property) Box<unknown>.value?: unknown
box.value;
 
if (box.hasValue()) {
    // (property) value: unknown
    box.value;
}

参数属性

由于构造函数的参数列表和成员属性的属性名大多数时候都是一致的:

class Box{
    private width: number = 0;
    private height: number = 0;
    constructor(width: number, height:number){
        this.width = width;
        this.height = height;
    }
}

TS 支持给类构造函数的参数添加修饰,例如publicprotectedprivatereadonly。只需要在参数列表添加修饰就完成初始化操作,不需要写构造函数的函数体:

class Params {
    constructor(
    	public readonly x: number,
     	protected y: number,
     	private z: number
    ) {
        // 不需要函数体
    }
}
const a = new Params(1, 2, 3);
console.log(a.x); // 1
console.log(a.z); // Error: z是私有属性,无法访问

类表达式

类表达式和类的声明十分相似,类表达式可以是匿名的,也可以将其赋值给任意标识符并引用它。

const someClass = class<Type> {
    content: Type;
    constructor(value: Type) {
        this.content = value;
    }
};
const m = new someClass("Hello, world");

类表达式实际上是 JS 就有的语法,TS 只是提供了类型标注、泛型等额外的特性。

获取实例类型

使用InstanceType

class Point {
    createdAt: number;
    x: number;
    y: number;
    constructor(x: number, y: number) {
        this.createdAt = Date.now();
        this.x = x;
        this.y = y;
    }
}
// 获取Point这个类的实例类型
type PointInstance = InstanceType<typeof Point>
 
function moveRight(point: PointInstance) {
    point.x += 5;
}
 
const point = new Point(3, 4);
moveRight(point);
point.x; // => 8

似乎这里可以直接用point:Point替代point:PointInstance,但是在其它没有使用class(语法糖)的场景下,InstanceType有以下作用:

image-20231201171526978

?引用自javascript - typescript的InstanceType怎么用呀? - SegmentFault 思否

抽象类与成员

TS 中的类和成员可以是抽象的。

抽象类与成员的特点:

  • 抽象方法或属性是指尚未提供实现的方法或属性。
  • 这些抽象成员必须存在于抽象类中。
  • 抽象类不能直接实例化。

抽象类必须充当基类,让派生类去实现抽象成员。

当一个类不存在任何抽象成员的时候,就说它是具体类。

abstract class Base {
    abstract getName(): string;

    printName() {
        console.log("Hello, " + this.getName());
    }
}

// 继承
class Derived extends Base {
    // 实现基类的抽象成员
    getName() {
        return "world";
    }
}

如果没有实现基类的抽象成员,则会报错。

类之间的关系

TS 通过类的结构区分不同的类,如果两个类的结构一致,则认为是同一个类。

class Point1 {
    x = 0;
    y = 0;
}
class Point2 {
    x = 0;
    y = 0;
}
// OK
const p: Point1 = new Point2();

只要一个类的字段集合是另一个类的字段集合的子集,就可以实现类似于继承的效果(不需要使用extends关键字)。

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) {
	// x对象没有字段,这里做不了任何操作
}
 
// 但是下面这些对象的字段集合都包含空集,也就是说都不会报错。
fn(window);
fn({});
fn(fn);

永远不要使用空的类。