面向对象 class 与继承

用更优雅的方式组织代码结构

🎯 学习目标

学完本章,你将能够:

  • 能够使用 class 关键字定义类,包括构造函数、实例方法和静态方法
  • 能够使用 extends 和 super 实现继承,理解原型链关系
  • 能够使用私有属性(#)封装内部状态,防止外部直接访问
  • 能够使用 getter/setter 控制属性的访问和修改
  • 能够解释多态的概念,使用方法重写实现不同子类的行为

一段让人头疼的代码

用构造函数写面向对象,代码长什么样?

// 传统构造函数写法
function Person(name, age) {
    this.name = name;
    this.age = age;
}
Person.prototype.greet = function() {
    return `Hi, I'm ${this.name}`;
};
Person.species = "Homo sapiens"; // 静态属性

function Student(name, age, grade) {
    Person.call(this, name, age);
    this.grade = grade;
}
Student.prototype = Object.create(Person.prototype);
Student.prototype.constructor = Student;
Student.prototype.study = function() {
    return `${this.name} is studying`;
};

光是让 Student 继承 Person 就要写 4 行样板代码。prototype、call、Object.create……每个都对初学者不友好。

ES6 的 class 语法就是为了解决这个问题。但别误会——class 不是新机制,只是语法糖,底层依然是原型链。

Part 1: class 基础

先学会用 class 写类,再深入理解它和原型链的关系

开场那段用构造函数写的代码,光是继承就要四行样板代码。class 语法把这些全部藏起来了——constructor 定义初始化逻辑,方法直接写在花括号里,看起来像 Java 又像 Python。但别忘了,底层还是原型链。这节从基本语法开始,把 class 和原型的关系彻底搞清楚。

class 基本语法

对比构造函数,一眼看懂 class

// class 写法
class Person {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
    greet() {
        return `Hi, I'm ${this.name}`;
    }
}

let person = new Person("张三", 25);
console.log(person.greet()); // "Hi, I'm 张三"
console.log(typeof Person);  // "function"

关键事实:

  • typeof Person 是 "function"——class 本质就是函数
  • constructor 等同于构造函数本体
  • 方法自动挂到 Person.prototype 上
  • class 声明不会提升(hoisting)

class 与原型链

语法糖的底层真相

图:class 写法与原型写法的等价关系
// 证明:class 就是语法糖
class Person { /* ... */ }
console.log(typeof Person); // "function"
console.log(Person === Person.prototype.constructor); // true

实例属性

类字段让属性声明更清晰

class User {
    // 类字段(ES2022)
    role = "user";
    createdAt = new Date();

    constructor(name, email) {
        this.name = name;
        this.email = email;
    }

    getProfile() {
        return `${this.name} (${this.email})`;
    }
}

let user = new User("张三", "zhang@test.com");
console.log(user.getProfile()); // "张三 (zhang@test.com)"
console.log(user.role);         // "user"

Part 2: 静态成员

不属于实例,属于类本身的属性和方法

new Person() 创建的实例有 name、greet(),但如果有些东西应该是所有实例共享的,而不是每个实例各存一份呢?比如工具方法、配置常量。静态成员就挂载在类本身上,不需要 new 就能调用。这节看看 static 怎么用,以及 ES2022 新增的静态初始化块。

静态方法

不需要 new 就能调用的方法

class MathUtils {
    static PI = 3.14159;

    static add(a, b) { return a + b; }
    static multiply(a, b) { return a * b; }
}
console.log(MathUtils.PI);        // 3.14159
console.log(MathUtils.add(1, 2)); // 3

// 静态方法中的 this 指向类本身
class Animal {
    static create(name) {
        return new this(name); // this === Animal
    }
    constructor(name) { this.name = name; }
}
let dog = Animal.create("Dog");
注意:静态方法只能通过类名调用,实例访问不到。new MathUtils().add(1, 2) 会报 TypeError。

静态初始化块

类加载时自动执行一段逻辑

class Config {
    static settings;
    static {
        // 类加载时自动执行
        Config.settings = {
            env: process.env.NODE_ENV || "development",
            port: 3000
        };
    }
}
console.log(Config.settings.port); // 3000

静态初始化块(ES2022)相当于类的"启动脚本",在类定义被评估时执行一次。

Part 3: 继承

继承让子类复用父类的代码,同时可以扩展或覆盖行为

开场那四行样板代码还记得吗?用 class 的话,一行 extends 就搞定了。子类自动拥有父类的所有能力,还能用自己的版本覆盖。但 super() 的调用时机有个大坑——在 this 之前必须先调 super,否则直接报错。这节把继承的写法、原型链的关系、方法重写一次讲透。

extends 与 super

一行继承,告别原型链样板代码

class Animal {
    constructor(name) { this.name = name; }
    speak() { return `${this.name} makes a sound`; }
}

class Dog extends Animal {
    constructor(name, breed) {
        super(name); // 必须在使用 this 之前调用
        this.breed = breed;
    }
    speak() {
        return `${this.name} barks!`;
    }
    parentSpeak() {
        return super.speak(); // 调用父类方法
    }
}

let dog = new Dog("Buddy", "Golden");
console.log(dog.speak());        // "Buddy barks!"
console.log(dog.parentSpeak());  // "Buddy makes a sound"
console.log(dog instanceof Animal); // true

继承的原型链

extends 背后发生了什么

图:继承原型链 dog 实例的完整查找路径
// 验证原型链
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype);
// true

// 等价的原型写法
// Dog.prototype = Object.create(Animal.prototype);
// Dog.prototype.constructor = Dog;

方法重写

子类用同名方法覆盖父类

class Bird extends Animal {
    speak() {
        return `${this.name} chirps!`;
    }
    fly() {
        return `${this.name} is flying`;
    }
}

let bird = new Bird("Tweety");
console.log(bird.speak()); // "Tweety chirps!"(重写了)
console.log(bird.fly());   // "Tweety is flying"(新增的)

当子类和父类有同名方法时,实例调用的是子类的版本。如果想调用父类版本,用 super.method()。

Part 4: 封装

ES2022 的 # 私有字段终于让 JS 有了真正的私有性

写一个 BankAccount 类,余额字段不想让外部直接改,怎么办?以前只能靠约定(命名加下划线),外部还是能访问。ES2022 的 # 私有字段从语法层面锁死了——外部访问直接报错,不是"君子协定"了。再配合 getter/setter,你就能做到"数据藏起来,接口露出来"的经典封装。

私有属性 #

加个 #,外部彻底不可见

class BankAccount {
    #balance = 0; // 私有字段

    constructor(owner, initialBalance = 0) {
        this.owner = owner;
        this.#balance = initialBalance;
    }

    #validate(amount) { // 私有方法
        if (amount <= 0) throw new Error("金额必须大于0");
    }

    deposit(amount) {
        this.#validate(amount);
        this.#balance += amount;
    }

    withdraw(amount) {
        this.#validate(amount);
        if (amount > this.#balance) throw new Error("余额不足");
        this.#balance -= amount;
    }

    get balance() { return this.#balance; }
}

let acc = new BankAccount("张三", 1000);
acc.deposit(500);
console.log(acc.balance); // 1500
// acc.#balance; // SyntaxError!

getter/setter

用属性的方式调用方法

class Temperature {
    #celsius;
    constructor(celsius) { this.#celsius = celsius; }

    get fahrenheit() { return this.#celsius * 9/5 + 32; }
    set fahrenheit(f) { this.#celsius = (f - 32) * 5/9; }
    get celsius() { return this.#celsius; }
}

let temp = new Temperature(100);
console.log(temp.fahrenheit); // 212(看起来像属性,实际调了 get)
temp.fahrenheit = 32;         // 实际调了 set
console.log(temp.celsius);   // 0

getter/setter 的好处:外部代码不知道内部实现。你可以把 #celsius 换成 #kelvin,只要 get/set 接口不变,外部代码一行都不用改。

Part 5: 多态与 SOLID

面向对象不只是写 class,更重要的是设计思想

class、继承、封装这些是"工具"。但什么时候该用继承?什么时候该拆类?怎么让代码改起来不牵一发动全身?多态让你用统一接口处理不同类型,SOLID 五条原则帮你做出更好的设计决策。这些不是理论空谈,而是每天写代码都会遇到的选择。

多态

同一个方法,不同的行为

class Shape {
    area() { throw new Error("子类必须实现 area()"); }
    describe() { return `面积: ${this.area().toFixed(2)}`; }
}

class Circle extends Shape {
    constructor(r) { super(); this.r = r; }
    area() { return Math.PI * this.r ** 2; }
}

class Rectangle extends Shape {
    constructor(w, h) { super(); this.w = w; this.h = h; }
    area() { return this.w * this.h; }
}

// 同一个接口,不同行为——这就是多态
let shapes = [new Circle(5), new Rectangle(4, 6)];
shapes.forEach(s => console.log(s.describe()));
// "面积: 78.54"
// "面积: 24.00"

SOLID 简介

五个设计原则,写出可维护的代码

字母原则一句话解释
S单一职责一个类只做一件事
O开闭原则对扩展开放,对修改关闭
L里氏替换子类可以替代父类使用
I接口隔离不要强迫实现不需要的接口
D依赖倒置依赖抽象而非具体实现
// S - 单一职责:拆分不同职责
class User {
    constructor(name) { this.name = name; }
}
class UserPersistence {
    save(user) { /* 保存到数据库 */ }
    load(id) { /* 从数据库读取 */ }
}

常见 class 错误

class 中constructor不能省略 super()。 如果子类有 constructor,必须先调用 super() 再使用 this。

问题一:忘记 super()

// 错误
class Dog extends Animal {
    constructor(name, breed) {
        this.breed = breed; // ReferenceError!
        super(name);        // super 必须在 this 之前
    }
}

// 正确
class Dog extends Animal {
    constructor(name, breed) {
        super(name);
        this.breed = breed;
    }
}

问题二:class 不会提升

// 错误:class 声明不会像 function 那样提升
let p = new Person("张三"); // ReferenceError!
class Person { /* ... */ }

// 正确:先定义再使用
class Person { /* ... */ }
let p = new Person("张三");

Mixin 混入

不支持多重继承?用 Mixin 实现复用

JS 只支持单继承(一个 extends),但可以通过 Mixin 模式把多个独立功能"混入"到类中,实现灵活的代码复用。

// 定义两个 Mixin
const Serializable = (Base) => class extends Base {
    toJSON() {
        return JSON.stringify(this, null, 2);
    }
    static fromJSON(json) {
        const data = JSON.parse(json);
        return new this(...Object.values(data));
    }
};

const Loggable = (Base) => class extends Base {
    log(action) {
        const time = new Date().toISOString();
        console.log(`[${time}] ${this.constructor.name}: ${action}`);
    }
};

// 组合使用
class User extends Loggable(Serializable(Object)) {
    constructor(name, email) {
        super();
        this.name = name;
        this.email = email;
    }
}

let user = new User("张三", "zhang@test.com");
user.log("用户注册");         // [2026-05-14T...] User: 用户注册
console.log(user.toJSON());  // {"name":"张三","email":"zhang@test.com"}
注意:Mixin 函数的参数是基类,返回新类。多个 Mixin 嵌套调用时要注意顺序——外层 Mixin 的方法会覆盖内层同名方法。也可以用 Object.assign(Target.prototype, mixin1, mixin2) 方式混入,但无法在 constructor 中调用混入的 super。

new.target

检测是否被 new 调用的元属性

new.target 是一个元属性,在普通函数调用时为 undefined,在 new 调用时指向实际被调用的构造函数。可以用它实现"抽象类"模式。

// 抽象类:不能直接实例化,必须继承后使用
class Shape {
    constructor() {
        if (new.target === Shape) {
            throw new Error("Shape 是抽象类,不能直接实例化");
        }
    }

    area() {
        throw new Error("子类必须实现 area()");
    }

    describe() {
        return `${this.constructor.name} 的面积: ${this.area().toFixed(2)}`;
    }
}

class Circle extends Shape {
    constructor(radius) {
        super(); // 此时 new.target === Circle,不会报错
        this.radius = radius;
    }
    area() { return Math.PI * this.radius ** 2; }
}

// new Shape();       // Error: Shape 是抽象类,不能直接实例化
let c = new Circle(5);
console.log(c.describe()); // "Circle 的面积: 78.54"
注意:new.target 在子类构造函数中通过 super() 调用父类 constructor 时,new.target 仍然指向子类(如 Circle),而非父类(Shape)。这正是实现抽象类模式的关键。

试一试

练习一

任务:创建一个 Stack 类(栈),实现 push、pop、peek、isEmpty 和 size 方法。用 #items 私有字段存储数据。

// 提示:栈是后进先出(LIFO)
// const s = new Stack();
// s.push(1); s.push(2); s.push(3);
// s.pop();    // 3
// s.peek();   // 2
// 你的代码:

参考答案

class Stack {
    #items = [];
    push(value) { this.#items.push(value); }
    pop() { return this.#items.pop(); }
    peek() { return this.#items[this.#items.length - 1]; }
    isEmpty() { return this.#items.length === 0; }
    get size() { return this.#items.length; }
}

练习二

任务:创建 Animal 基类和 Cat、Dog 子类。每个子类重写 speak() 方法,并添加各自特有的方法。

// 提示:Cat 有 purr(),Dog 有 fetch()
// 你的代码:

参考答案

class Animal {
    constructor(name) { this.name = name; }
    speak() { return `${this.name} makes a sound`; }
}

class Cat extends Animal {
    speak() { return `${this.name} meows!`; }
    purr() { return `${this.name} is purring`; }
}

class Dog extends Animal {
    speak() { return `${this.name} barks!`; }
    fetch() { return `${this.name} fetches the ball`; }
}

练习三(挑战)

任务:实现一个 EventEmitter 类,支持 on(event, listener)、off(event, listener)、emit(event, ...args)、once(event, listener) 四个方法。要求用 #listeners 私有字段存储监听器。once 注册的监听器只触发一次后自动移除。

// 提示:#listeners 是一个 Map,key 是事件名,value 是监听器数组
// const emitter = new EventEmitter();
// emitter.on("login", (user) => console.log(user + " 登录了"));
// emitter.emit("login", "张三"); // "张三 登录了"
// 你的代码:

参考答案

class EventEmitter {
    #listeners = new Map();

    on(event, listener) {
        if (!this.#listeners.has(event)) {
            this.#listeners.set(event, []);
        }
        this.#listeners.get(event).push(listener);
        return this; // 支持链式调用
    }

    off(event, listener) {
        const list = this.#listeners.get(event);
        if (list) {
            const idx = list.indexOf(listener);
            if (idx !== -1) list.splice(idx, 1);
        }
        return this;
    }

    emit(event, ...args) {
        const list = this.#listeners.get(event);
        if (list) {
            // 复制一份,防止遍历中修改原数组
            [...list].forEach(fn => fn(...args));
        }
        return this;
    }

    once(event, listener) {
        const wrapper = (...args) => {
            listener(...args);
            this.off(event, wrapper);
        };
        return this.on(event, wrapper);
    }
}

// 测试
const emitter = new EventEmitter();
emitter.on("msg", (text) => console.log("收到:", text));
emitter.once("init", () => console.log("仅触发一次"));

emitter.emit("init"); // "仅触发一次"
emitter.emit("init"); // 无输出(已自动移除)
emitter.emit("msg", "你好"); // "收到: 你好"

📝 要点总结

本章核心知识点

class 语法

构造函数、方法、静态成员的声明

继承

extends + super 实现类之间的继承

私有属性

# 前缀实现真正的私有成员

getter/setter

控制属性的读取和赋值逻辑

多态

子类重写父类方法,实现不同行为

下一章

class 让我们更好地组织代码。但当代码量越来越大,文件越来越长时,怎么管理?下一章学习 模块化——把代码拆分成独立、可复用的模块。

1 / X