本节目标:掌握
extends与super的用法与字段初始化顺序,学会用override关键字与noImplicitOverride守住重写契约,理解抽象类如何把「流程固定、步骤开放」的结构表达出来,并用里氏替换原则判断一次继承是否合理。读完本节,你能设计出可扩展的骨架代码,也知道什么时候该放弃继承改用组合。
6.2 继承、抽象类与多态
上一节我们写了一个个独立的类,每个类自给自足。但真实系统里,大量类型共享同一套行为,只是细节不同:所有「支付方式」都要生成订单号、都要记账,只有扣款那一步各不相同。继承就是为这种「共性 + 差异」结构准备的语言机制。TypeScript 的继承完全沿用 JavaScript 的 extends,额外提供了 override 检查与 abstract 关键字,把「哪些是刻意重写的、哪些是必须实现的」写进类型里。
6.2.1 extends 与 super
先看一个最小例子:
class Animal {
constructor(public name: string) {}
move(distance: number = 0): void {
console.log(`${this.name} 移动了 ${distance} 米`);
}
}
class Dog extends Animal {
bark(): void {
console.log(`${this.name} 汪!`);
}
}
const d = new Dog("旺财");
d.bark(); // 旺财 汪!
d.move(10); // 旺财 移动了 10 米
Dog 自动获得 Animal 的 name 字段和 move 方法。extends 在类型层面建立的是「子类型」关系:Dog 可以赋给 Animal,反之不行。
子类有构造函数时,必须先调 super():
class Cat extends Animal {
constructor(name: string, public indoor: boolean) {
super(name); // 必须在访问 this 之前调用
// 若把 super 放后面,或忘了写:error TS2377 / TS2554
}
move(distance: number = 0): void {
console.log("猫咪踮脚走");
super.move(distance); // 复用父类实现
}
}
三条硬规则:
- 派生类的构造函数里,访问
this之前必须调用super(),否则报error TS17009: 'super' must be called before accessing 'this' in the constructor of a derived class. - 派生类若不写构造函数,编译器会自动生成一个转发全部参数的隐式构造函数。
- 派生类若写了构造函数,就必须自己调
super(),编译器不会替你补。漏调会报error TS2377: Constructors for derived classes must contain a 'super' call.
super.move(distance) 表示「调用父类版本的方法」,这是复用而非替换。没有它,父类逻辑就彻底丢了。
6.2.2 方法重写与 override 关键字
子类定义与父类同名的方法就叫重写(override)。默认情况下 TypeScript 允许你随手重写,这带来一个隐患:父类改了方法名,子类的「重写」就悄悄变成了「新增一个没人调用的方法」。
TypeScript 4.3 引入了 override 关键字与 noImplicitOverride 选项来堵这个洞:
{
"compilerOptions": {
"noImplicitOverride": true
}
}
开启后,凡是重写父类成员的,必须加 override:
class Animal {
move(distance: number = 0): void {
console.log(`移动 ${distance} 米`);
}
}
class Dog extends Animal {
override move(distance: number = 0): void {
console.log("小跑");
super.move(distance);
}
}
两个方向的错误都会被抓住:
class Bird extends Animal {
move(distance = 0): void {} // error TS4114: This member must have an 'override' modifier because it overrides a member in the base class 'Animal'.
}
class Fish extends Animal {
override swim(): void {} // error TS4113: This member cannot have an 'override' modifier because it is not declared in the base class 'Animal'.
}
建议:新项目一律开启 noImplicitOverride。 它把「我是有意重写」和「我手滑写了个重名方法」区分开来,是重构时最有价值的一道保险。注意 override 只对成员方法/属性有效,不能修饰 static 成员(静态成员不参与多态)。
6.2.3 字段初始化顺序:一个真实的坑
派生类的字段初始化发生在 super() 调用返回之后。这意味着父类构造函数里如果调用了会被子类重写的方法,读到的可能是「还没初始化」的子类字段:
class Base {
constructor() {
this.setup(); // 危险:此时子类字段还没赋值
}
setup(): void {
console.log("Base setup");
}
}
class Derived extends Base {
value = 42; // 这一行在 super() 之后才执行
override setup(): void {
console.log(`value = ${this.value}`);
}
}
new Derived();
// 实际输出:value = undefined
// 而不是很多人以为的 value = 42
原因很直白:new Derived() 的执行顺序是「先跑 Base 的构造函数(里面调了被子类重写的 setup)→ 再回来给 value 赋 42」。这是 JavaScript 的既有语义,TypeScript 只是忠实地把它反映在类型里。
规避方式有两条:
- 不要在构造函数里调用可被重写的方法。这是最根本的一条,很多语言规范都把它写进 lint 规则。
- 实在需要在构造时初始化,就把逻辑放到显式的
init()里,由调用方在对象构造完成后触发。
顺带一提,参数属性也有同样的时序问题:constructor(public value = 42) 展开的赋值同样在 super() 之后。
6.2.4 抽象类与抽象方法
有时候父类只描述「要做什么」,无法给出「怎么做」。比如「数据导出器」知道流程是「取数据 → 格式化 → 写出」,但格式化方式完全取决于子类。这时用 abstract:
abstract class Exporter {
// 抽象方法:只有签名,没有实现,子类必须实现
abstract format(rows: Record<string, unknown>[]): string;
// 具体方法:流程固定,调用抽象方法作为「钩子」
export(rows: Record<string, unknown>[]): void {
const payload = this.format(rows);
this.write(payload);
}
protected write(payload: string): void {
console.log(`写出 ${payload.length} 个字符`);
}
}
两个关键点:
- 抽象类不能直接实例化:
new Exporter()报error TS2511: Cannot create an instance of an abstract class. - 抽象成员不能有实现,子类必须提供实现,否则报
error TS2515: Non-abstract class 'CsvExporter' does not implement inherited abstract member format from class 'Exporter'.
子类这样写:
class CsvExporter extends Exporter {
override format(rows: Record<string, unknown>[]): string {
const headers = Object.keys(rows[0] ?? {});
const lines = rows.map((r) => headers.map((h) => String(r[h] ?? "")).join(","));
return [headers.join(","), ...lines].join("\n");
}
}
class JsonExporter extends Exporter {
override format(rows: Record<string, unknown>[]): string {
return JSON.stringify(rows, null, 2);
}
}
const rows = [{ id: 1, name: "小明" }, { id: 2, name: "小红" }];
new CsvExporter().export(rows);
// 写出 26 个字符
new JsonExporter().export(rows);
// 写出 82 个字符
抽象类还能同时声明抽象属性:
abstract class Job {
abstract readonly name: string;
abstract run(): Promise<void>;
}
注意 abstract 只能出现在 abstract class 里:普通类里写抽象方法会报 error TS1244: Abstract methods can only appear within an abstract class.,写抽象属性则报 error TS1253: Abstract properties can only appear within an abstract class.。另外抽象类可以有构造函数,也可以有私有成员——这两点是与接口(interface)最实际的区别,下一节会系统对比。
6.2.5 多态与里氏替换
多态的意思是:调用方只依赖父类型,运行时由真实子类型决定行为。
function exportAll(exporters: Exporter[], rows: Record<string, unknown>[]): void {
for (const e of exporters) {
e.export(rows); // 这里不关心是 Csv 还是 Json
}
}
exportAll([new CsvExporter(), new JsonExporter()], rows);
// 写出 26 个字符
// 写出 82 个字符
exportAll 的参数类型是 Exporter[],却能装下任何子类实例。这正是继承在工程上的主要价值:把「变化点」封在子类里,让调用方只面对稳定接口。
多态能成立的前提是里氏替换原则(Liskov Substitution Principle):子类实例必须能替换父类实例而不破坏调用方预期。具体到 TypeScript,编译器帮你守住了签名层面的一半:
class Good extends Exporter {
// 参数变宽、返回变窄是安全的
override format(rows: readonly Record<string, unknown>[]): string {
return rows.length ? "ok" : "";
}
}
- 参数类型可以变宽(父类收
Record,子类收object)——这叫逆变,安全。 - 返回类型可以变窄(父类返
string,子类返字面量"ok")——这叫协变,安全。 - 反过来会报错:子类返回
string | null而父类声明string,报error TS2416: Property 'format' in type 'Good' is not assignable to the same property in base type 'Exporter'.
但编译器管不了语义层面:如果 format 在父类的契约是「不抛异常」,子类却抛了,编译照样通过而调用方会炸。里氏替换的后半段要靠人来守。判断一次继承是否合理的经典自检是:子类能否说「我是一个更具体的父类」。CsvExporter 是 Exporter(说得通);Stack 继承 Array(说不太通——栈不该有 shift),后者应该用组合。
6.2.6 模板方法:抽象类的典型用法
上面 Exporter 用的正是模板方法模式:父类把算法骨架写死,把可变步骤留成抽象方法或可重写的钩子。再看一个更贴近业务的例子——重试策略:
abstract class RetryableTask<T> {
protected abstract execute(): Promise<T>;
protected shouldRetry(error: unknown, attempt: number): boolean {
return attempt < 3 && !(error instanceof RangeError);
}
async run(): Promise<T> {
let lastError: unknown;
for (let attempt = 1; ; attempt++) {
try {
return await this.execute();
} catch (error) {
lastError = error;
if (!this.shouldRetry(error, attempt)) {
throw error;
}
await new Promise((r) => setTimeout(r, 2 ** attempt * 100));
}
}
}
}
class FetchUser extends RetryableTask<{ id: number }> {
constructor(private readonly id: number) {
super();
}
protected async execute() {
const res = await fetch(`/api/users/${this.id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as { id: number };
}
}
注意这里抽象类还带了泛型参数 T,execute 的返回类型由子类在 extends 时确定——泛型与继承的配合在 泛型约束(extends)
里会展开。这种「骨架 + 钩子」的写法在框架代码里非常常见,设计模式层面的更多变体可以参考 设计模式
与 TypeScript 设计模式实践
。
6.2.7 继承的代价与「组合优先」
继承是强耦合:子类知道父类的实现细节,父类一改,所有子类都可能受影响。业界经验总结成一句话——优先使用组合,而不是继承。三条实用判据:
| 场景 | 建议 |
|---|---|
| 确实是「is-a」关系,且子类复用父类大部分实现 | 用继承 |
| 只是「has-a」或「uses-a」 | 用组合(持有对方实例) |
| 想复用多个来源的行为 | 用 mixin(下一节) |
| 继承层级超过三层 | 警惕,考虑重构 |
同一份 Logger 能力,用组合的写法:
interface Logger {
info(msg: string): void;
}
class Service {
constructor(private readonly logger: Logger) {} // 组合:注入而非继承
handle(): void {
this.logger.info("handling");
}
}
Service 不关心 Logger 是谁,测试时塞一个假的进去即可——这比继承一个 BaseService 灵活得多。
6.2.8 继承与接口的关系(预告)
到这里你可能已经发现:抽象类像是「半成品类」,接口像是「纯契约」。两者的分界是本节和下一节之间的桥:
- 抽象类可以有实现、构造函数、私有成员,但一个类只能
extends一个父类。 - 接口只能声明签名(TS 里接口不能有实现),但一个类可以
implements多个接口。
所以真实工程里的常见组合是「抽象类管骨架,接口管契约」。implements 的语法、类实现接口时的检查规则、以及如何用 mixin 绕过单继承限制,正是下一节 接口实现与 mixin
的内容。
小结
extends建立子类型关系,派生类构造函数里访问this前必须super();不写构造函数时编译器自动补一个转发版。- 开
noImplicitOverride后,重写父类成员必须加override:漏加报 TS4114,多加重写不存在成员报 TS4113。新项目建议一律开启。 - 派生类字段在
super()返回后才初始化,父类构造函数里调用可重写方法会读到undefined——不要在构造函数里调用可被重写的方法。 abstract类不能实例化(TS2511),抽象成员必须由子类实现(TS2515),abstract不能出现在非抽象类里(抽象方法 TS1244 / 抽象属性 TS1253)。- 多态让调用方只依赖父类型;编译器保证签名层面的协变/逆变安全,语义层面的里氏替换要靠人判断。
- 模板方法模式是抽象类最典型的用法:父类固定流程,子类只填空。
- 继承是强耦合,组合优先;跨多来源复用行为要靠下一节的 mixin。
- 下一节讲
implements、接口继承与 mixin,看 TypeScript 如何用「一个类实现多个契约」补上单继承的短板。
阅读导航:上一节:6.1 类、访问修饰符与参数属性 · 下一节:6.3 接口实现与 mixin 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。