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 后面写的是实际报出的错误码。
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 一样只在编译期检查。
参数属性省掉样板代码
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
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。可它不会给类添加任何东西,也不会影响类里的推断:
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 有哪些区别?》。