前端知库

TypeScript 的 class 中 public、private、protected 和

public 是默认可见性;protected 只能在类和子类里访问;private 只能在声明它的类里访问,但只在类型检查时生效,编译后仍是普通属性,还能用方括号绕过。#private 是 JavaScript 的私有字段,运行时也访问不到。readonly 让属性只能在声明处或构造函数里赋值。参数属性在构造函数参数前加修饰符,自动声明并赋值同名属性。abstract 类不能 new,抽象成员由子类实现;extends 继承实现,implements 只检查类是否符合接口,不改变类的类型。

参考答案

简答

比如写一个 Account 类,账户名谁都能看,余额只给子类改,密码只在类里面用。TypeScript 用三个修饰符表达:public 是默认值,哪里都能访问;protected 只能在这个类和它的子类里访问;private 连子类都不行,只能在声明它的类里访问。

要注意 private 和 JavaScript 的 # 私有字段不一样。Handbook 说明,private 和 protected 只在类型检查时生效,编译后就是普通属性,而且类型检查时也允许用 obj['pin'] 这种方括号写法访问,所以叫“软私有”。#token 是 JavaScript 语言本身的私有字段,编译以后外部依然访问不到,是“硬私有”。需要真正藏起数据时用 #。

还有几个常用写法。readonly 属性只能在声明时或构造函数里赋值。参数属性是在构造函数参数前加 public、private、protected 或 readonly,TypeScript 会自动声明同名属性并把参数赋给它,省去重复的样板代码。abstract 类不能直接 new,里面的抽象方法没有实现,必须由子类实现。

extends 和 implements 的区别是:extends 继承父类的实现,一个类只能继承一个父类;implements 只检查这个类有没有满足接口的要求,可以同时实现多个接口。Handbook 特别提醒,implements 不会改变类的类型,方法参数也不会因此得到接口里的类型,仍要自己写注解。

四种访问控制

下面的代码用 tsc --strict --target es2022 验证过(TypeScript 7.0.2),每个 @ts-expect-error 后面写的是实际报出的错误码。

TypeScript
class Account {
  public owner: string;
  protected balance = 0;
  private pin: string;
  #token = 'secret';
  readonly createdAt: number;

  constructor(owner: string, pin: string) {
    this.owner = owner;
    this.pin = pin;
    this.createdAt = Date.now();
  }

  checkPin(input: string) {
    return input === this.pin && this.#token.length > 0;
  }
}

class Savings extends Account {
  addInterest() {
    this.balance += 1;
    // @ts-expect-error 2341:private 成员子类也不能访问
    this.pin;
  }
}

const a = new Account('Ada', '1234');
// @ts-expect-error 2445:protected 只能在类和子类内部访问
a.balance;
// @ts-expect-error 2341:private 只能在类内部访问
a.pin;
const leaked = a['pin'];
// @ts-expect-error 18013:#token 在类外不可访问
a.#token;
// @ts-expect-error 2540:readonly 只能在构造函数里赋值
a.createdAt = 0;

a.pin 报错,可 a['pin'] 却能通过,这就是 Handbook 说的方括号“逃生口”。Handbook 认为这在写单元测试时方便,代价是 private 并没有真正强制私有。编译成 JavaScript 以后,pin 就是实例上的普通属性,Object.keys(a) 能看到它,console.log(a) 也能打印出来。

#token 不一样。它是 ES2022 的私有字段,MDN 说明在类外部访问会直接是语法错误,Object.keys 也看不到。TypeScript 编译到 ES2021 及以下时,会用 WeakMap 模拟它,效果仍然是外部访问不到。

readonly 只限制赋值的时机:声明处和构造函数里可以赋值,之后就不行了。它和 private 一样只在编译期检查。

参数属性省掉样板代码

TypeScript
class Point {
  constructor(public readonly x: number, private y: number) {}
  sum() {
    return this.x + this.y;
  }
}
const p = new Point(1, 2);
p.x.toFixed();

构造函数参数前加了修饰符,TypeScript 就会自动声明 x、y 两个属性,并在构造时赋值,效果等同于先声明字段、再在构造函数里写 this.x = x。不加修饰符的参数只是普通参数,不会变成属性。

要注意,参数属性不是 JavaScript 语法,编译时会生成赋值代码。开启 erasableSyntaxOnly 后 tsc 会对它报 TS1294,Node.js 只剥离类型直接运行 TypeScript 时也不支持它,详见《import type 和 verbatimModuleSyntax 如何影响模块输出?》。

abstract、extends 和 implements

TypeScript
interface Pingable {
  ping(): void;
}

abstract class Shape {
  abstract area(): number;
  describe() {
    return `面积是 ${this.area()}`;
  }
}

class Square extends Shape implements Pingable {
  constructor(private size: number) {
    super();
  }
  area() {
    return this.size ** 2;
  }
  ping() {}
}

// @ts-expect-error 2511:不能直接 new 抽象类
new Shape();

// @ts-expect-error 2420:缺少 ping
class Ball implements Pingable {
  pong() {}
}

Shape 把公共逻辑 describe 写好了,area 留给子类。Square 用 extends 继承了 describe 的实现,同时必须实现 area,否则会报错。这就是抽象类和接口的主要区别:抽象类可以带实现,接口只描述形状。

implements Pingable 只做检查。Ball 没有 ping,所以报 2420。可它不会给类添加任何东西,也不会影响类里的推断:

TypeScript
interface Checkable {
  check(name: string): boolean;
}
class NameChecker implements Checkable {
  // @ts-expect-error 7006:implements 不会给参数推断类型
  check(s) {
    return s === 'ok';
  }
}

很多人以为 s 会自动得到接口里的 string,实际上它是隐式的 any,在 strict 下报 7006。Handbook 把这一点单独列为注意事项。

常见追问

同一个类的两个实例,能互相访问对方的 private 成员吗?

可以。Handbook 说明 TypeScript 允许跨实例访问 private,比如在 sameAs(other: A) 方法里读 other.x。这和 Java、C# 一样,和 Ruby 不同。

子类能把父类的 protected 成员改成 public 吗?

可以。子类重新声明这个属性而不写修饰符,它就变成 public 了。Handbook 提醒,如果不是有意公开,子类里要记得重复写 protected。反过来,private 成员子类根本看不到,也就不能提升可见性。

抽象类和接口怎么选?

只描述形状、需要多实现时用接口,一个类可以 implements 多个接口,接口本身编译后也不会留下代码。需要共享一部分实现、或者希望用 instanceof 判断时用抽象类,因为抽象类编译后是真实存在的 JavaScript 类。接口和类型别名的区别见《type 和 interface 有哪些区别?》。