假如我们现在有一个 User(普通用户)的角色,包含姓名和年龄。随着业务的扩张,我们需要增加一个 Admin(管理员)的角色。管理员不仅拥有普通用户的所有基础属性,还要拥有额外的 “权限级别” 属性。
如果我们不使用继承,就只能把 User 里的代码复制粘贴一遍到 Admin 里。这在大型项目中是非常危险的(严重违反了 DRY 原则)。为了实现代码的完美复用,我们需要使用面向对象的核心特性:类的继承(Inheritance)。
什么是类的继承?
类的继承允许我们基于一个已有的类(父类)来创建一个新的类(子类)。子类会自动继承(也就是白嫖)父类中所有允许访问的属性和方法。
在 TypeScript 中,我们使用 extends 关键字来实现类的继承。
语法:
class 子类 extends 父类 {
// 子类独有的属性和方法
}示例 1:使用类的继承
// 定义父类
class User {
name: string;
constructor(userName: string) {
this.name = userName;
}
login(): void {
console.log(`${this.name} 登录了系统`);
}
}
// 定义子类,并继承 User
class Admin extends User {
// 子类独有的方法
deleteArticle(): void {
console.log(`${this.name} 删除了一篇违规文章`);
}
}
// 实例化子类
const adminUser = new Admin("Jack");
// 调用父类的方法
adminUser.login();
// 调用自己的方法
adminUser.deleteArticle();运行结果如下。
Jack 登录了系统
Jack 删除了一篇违规文章分析:
在上面的例子中,Admin 类内部看起来很空,甚至连 constructor() 都没有写。但由于我们使用 extends 使得它继承了 User,因此它会把 User 的 name 属性、构造函数以及 login 方法等全盘接收了。
深入理解 super() 函数
在上面的示例中,Admin 类直接默认使用了父类 User 的构造函数。但在实际业务中,子类往往需要拥有自己独有的属性(比如管理员的权限字段)。
这就引出了 TypeScript 类继承中最容易踩坑的一条铁律:如果子类有自己的 constructor() 构造函数,那么在构造函数里必须首先调用 super()。
super 的字面意思是 “超级的、上级的”,在类里面它代表的就是 “父类”。super() 的作用就是调用父类的构造函数,帮我们把继承来的那部分基础属性先初始化好。
示例 2:使用 super()
class User {
name: string;
constructor(userName: string) {
this.name = userName;
}
}
class Admin extends User {
role: string; // 子类独有属性
constructor(userName: string, role: string) {
// 1. 必须先调用父类的构造函数,并传入父类需要的参数
super(userName);
// 2. 然后才能使用 this 去初始化子类自己的属性
this.role = role;
}
printInfo(): void {
console.log(`管理员:${this.name},权限:${this.role}`);
}
}
const currentAdmin = new Admin("Jack", "超级管理员");
currentAdmin.printInfo();运行结果如下。
管理员:Jack,权限:超级管理员分析:
小伙伴们要记住这个顺序:先拜码头(调用 super() 初始化父类基建),再立山头(使用 this 初始化子类专属特性)。如果顺序反了,或者忘了写 super(),那么 TypeScript 会立刻报错拦截。
TypeScript 类方法的重写
在 TypeScript 中,子类不仅可以继承父类的方法,还可以 “造反”——也就是重写(覆盖)它。当子类中定义了与父类同名的方法时,子类的实例在调用该方法时,会优先执行子类自己写的逻辑。
示例 3:方法的重写
// 父类:普通顾客
class Customer {
// 普通顾客原价购买
calculatePrice(price: number): number {
return price;
}
}
// 子类:VIP 顾客
class VipCustomer extends Customer {
// 重写父类的计价方法
calculatePrice(price: number): number {
// VIP 顾客打 8 折
return price * 0.8;
}
// 甚至可以在其他方法中,通过 super 去调用父类原始的方法
printOriginalPrice(price: number): void {
console.log("商品原价是:", super.calculatePrice(price));
}
}
const normalUser = new Customer();
console.log("普通顾客价格:", normalUser.calculatePrice(100));
const vipUser = new VipCustomer();
console.log("VIP 顾客价格:", vipUser.calculatePrice(100));
vipUser.printOriginalPrice(100);运行结果如下。
普通顾客价格:100
VIP 顾客价格:80
商品原价是:100分析:
在这个例子中,子类 VipCustomer 重新定义了和父类一模一样的 calculatePrice() 方法。当我们通过 VIP 实例去调用这个方法时,TypeScript 会优先执行子类 “重写” 后的打折逻辑,而不是父类的原价逻辑。这就叫作 “方法的重写(Override)”。
但非常有意思的是,即使子类 “造反” 重写了该方法,父类原始的方法并没有丢!如果在子类内部由于某些业务需求,依然想要调用父类那个最原始的方法,我们只需要使用 super.方法名()(如例子中的 super.calculatePrice(price))即可。
这种 “对内保留父类底座、对外展示子类个性” 的设计,极大地增强了代码的可扩展性。
此外,虽然上面的代码可以完美运行,但在多人协作的大型项目中,还存在一个隐蔽的 bug 温床。
假设有一天,负责维护父类 Customer 的同事,把 calculatePrice() 这个方法名改成了 computePrice()。此时,你写的子类 VipCustomer 里的 calculatePrice() 就不再是 “重写”了,而是变成了一个子类独有的普通方法。最可怕的是,TypeScript 默认不会给你任何报错提示!然后你的 VIP 计价逻辑会在不知不觉中彻底失效。
为了杜绝这种隐患,TypeScript 4.3 引入了专门的 override 关键字。在企业级开发中,我们强烈建议在重写方法时带上它:
class VipCustomer extends Customer {
// 显式声明:我是在重写父类的方法
override calculatePrice(price: number): number {
return price * 0.8;
}
}加了 override 之后,就相当于给这个方法上了一把 “锁”。一旦父类里没有 calculatePrice 这个方法(比如被同事改名或删除了),TypeScript 编译器立马报错拦截。
TypeScript 实现类的多继承
需要清楚的是,在 TypeScript 的类继承体系中,我们必须严格遵循 “单继承” 原则。也就是说,一个子类只能 extends 一个父类。TypeScript 是绝对不允许使用下面这种代码的:
class Child extends Father, Mother既然不能直接 extends 多个父类,如果我们在真实业务中确实需要融合多个独立类的功能,此时应该怎么办呢?
在面向对象编程(OOP)中,有一句非常著名的设计名言:“多用组合,少用继承”。所谓的组合,就是把其他类的实例,当成自己类里面的一个属性来使用。它代表的是一种 “拥有(has-a)” 的关系,而不是继承中 “是(is-a)” 的关系。
示例 4:使用 “组合” 代替多继承
// 功能类 A:相机
class Camera {
takePhoto(): void {
console.log("拍了一张照片");
}
}
// 功能类 B:音乐播放器
class MusicPlayer {
play(): void {
console.log("正在播放音乐");
}
}
// 主类:智能手机
class SmartPhone {
name: string;
// 1. 声明属性,类型就是上面的独立类
camera: Camera;
player: MusicPlayer;
constructor(name: string) {
this.name = name;
// 2. 在构造函数中实例化这些功能组件
this.camera = new Camera();
this.player = new MusicPlayer();
}
// 手机自己的特色方法
takeSelfie(): void {
console.log(`${this.name} 打开了摄像头:`);
// 3. 内部调用组件的方法
this.camera.takePhoto();
}
listenMusic(): void {
console.log(`${this.name} 打开了网易云:`);
this.player.play();
}
}
const myPhone = new SmartPhone("Xiaomi 17 Prox");
myPhone.takeSelfie();
myPhone.listenMusic();运行结果如下。
Xiaomi 17 Prox 打开了摄像头:
拍了一张照片
Xiaomi 17 Prox 打开了网易云:
正在播放音乐分析:
在这个例子中,智能手机(SmartPhone 类)既要实现拍照功能,又要实现听歌功能。这里我们并没有强行去继承(extends)相机和播放器,而是让智能手机内部 “拥有” 了一个相机的对象和一个播放器的对象。
这种写法可以完美避开 TypeScript 单继承的物理限制,而且逻辑非常清晰:手机就是由各种零件拼装(组合)而成的。在实际的业务开发中,大部分看起来需要 “多继承” 的场景,都可以通过这种搭积木一样的 “组合” 方式来优雅解决。
