TypeScript 类的继承(extends)

假如我们现在有一个 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 单继承的物理限制,而且逻辑非常清晰:手机就是由各种零件拼装(组合)而成的。在实际的业务开发中,大部分看起来需要 “多继承” 的场景,都可以通过这种搭积木一样的 “组合” 方式来优雅解决。

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号