MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript面向对象编程:类与继承

2021-06-126.4k 阅读

JavaScript面向对象编程:类与继承

面向对象编程基础概念

在深入探讨JavaScript的类与继承之前,先回顾一下面向对象编程(OOP)的基本概念。OOP是一种编程范式,它将程序组织成相互协作的对象,每个对象都代表现实世界中的某个实体,并且具有自己的状态(属性)和行为(方法)。

封装

封装是OOP的核心特性之一。它意味着将数据(属性)和操作这些数据的方法(行为)组合在一个单元中,这个单元就是对象。通过封装,对象的内部实现细节对外部是隐藏的,外部只能通过对象暴露出来的方法来访问和修改对象的状态。这样做的好处是提高了代码的安全性和可维护性,因为外部代码无法直接访问对象的内部数据,从而避免了意外的修改和错误。

例如,假设我们有一个 Person 对象,它有 nameage 属性,以及 sayHello 方法:

let person = {
    name: 'John',
    age: 30,
    sayHello: function() {
        console.log(`Hello, my name is ${this.name} and I'm ${this.age} years old.`);
    }
};
person.sayHello(); 

在这个例子中,nameage 属性以及 sayHello 方法被封装在 person 对象中。外部代码只能通过调用 sayHello 方法来获取对象的信息,而不能直接访问 nameage 属性(当然,在JavaScript中,默认情况下属性是可以直接访问的,但通过约定和一些技术手段可以实现类似封装的效果)。

继承

继承是OOP的另一个重要特性。它允许一个对象从另一个对象获取属性和方法。通过继承,我们可以创建一个新的对象(子类),这个子类继承了另一个对象(父类)的属性和方法,并且还可以添加自己的属性和方法,或者重写父类的方法。继承有助于代码的复用,避免了重复编写相似的代码。

例如,如果我们有一个 Animal 类,它有 name 属性和 speak 方法,然后我们创建一个 Dog 类继承自 Animal 类,Dog 类就会自动拥有 name 属性和 speak 方法,并且还可以有自己特有的方法,比如 bark

// 定义Animal类
function Animal(name) {
    this.name = name;
}
Animal.prototype.speak = function() {
    console.log(`${this.name} makes a sound.`);
};
// 定义Dog类继承自Animal类
function Dog(name, breed) {
    Animal.call(this, name);
    this.breed = breed;
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
Dog.prototype.bark = function() {
    console.log(`${this.name} barks.`);
};
let myDog = new Dog('Buddy', 'Golden Retriever');
myDog.speak(); 
myDog.bark(); 

多态

多态是指同一个方法在不同的对象上有不同的表现形式。在继承体系中,子类可以重写父类的方法,以提供适合自身的实现。这样,当通过父类的引用调用这个方法时,实际执行的是子类重写后的方法。多态增加了代码的灵活性和可扩展性。

继续以上面的 AnimalDog 类为例,假设我们还有一个 Cat 类也继承自 Animal 类,并且 Cat 类和 Dog 类都重写了 speak 方法:

// 定义Cat类继承自Animal类
function Cat(name, color) {
    Animal.call(this, name);
    this.color = color;
}
Cat.prototype = Object.create(Animal.prototype);
Cat.prototype.constructor = Cat;
Cat.prototype.speak = function() {
    console.log(`${this.name} meows.`);
};
let myCat = new Cat('Whiskers', 'Gray');
let myDog = new Dog('Buddy', 'Golden Retriever');
// 多态体现
let animals = [myDog, myCat];
animals.forEach(animal => {
    animal.speak(); 
});

在这个例子中,myDog.speak()myCat.speak() 表现出了不同的行为,尽管它们都继承自 Animal 类并且调用的是同一个 speak 方法名。这就是多态的体现。

JavaScript中的类

在ES6之前,JavaScript没有像其他面向对象编程语言那样的原生类语法。开发者通常使用构造函数和原型链来模拟类的行为。然而,ES6引入了 class 关键字,为JavaScript带来了更简洁、更直观的类语法。

类的定义

使用 class 关键字定义一个类非常简单。下面是一个简单的 Person 类的定义:

class Person {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
    sayHello() {
        console.log(`Hello, my name is ${this.name} and I'm ${this.age} years old.`);
    }
}

在这个例子中,class Person 定义了一个名为 Person 的类。类的构造函数 constructor 用于初始化对象的属性,当使用 new 关键字创建类的实例时,构造函数会被调用。sayHello 方法定义了 Person 对象的行为。

创建类的实例

一旦定义了类,就可以使用 new 关键字来创建类的实例:

let john = new Person('John', 30);
john.sayHello(); 

这里,new Person('John', 30) 创建了一个 Person 类的实例,并将其赋值给 john 变量。然后可以调用 johnsayHello 方法。

类的属性和方法

类的属性在构造函数中通过 this 关键字进行定义和初始化。而类的方法直接在类的定义体中定义。除了实例方法,类还可以有静态方法和静态属性。

静态方法

静态方法是属于类本身而不是类的实例的方法。在方法定义前加上 static 关键字即可定义静态方法。静态方法通常用于执行与类相关但不依赖于特定实例的操作。

class MathUtils {
    static add(a, b) {
        return a + b;
    }
    static multiply(a, b) {
        return a * b;
    }
}
let result1 = MathUtils.add(2, 3); 
let result2 = MathUtils.multiply(4, 5); 

在这个例子中,addmultiplyMathUtils 类的静态方法,可以直接通过类名调用,而不需要创建类的实例。

静态属性

静态属性是属于类本身的属性,而不是类的实例的属性。在ES2022之前,JavaScript没有直接定义静态属性的语法,但可以通过 Object.defineProperty 来实现。从ES2022开始,可以在类的定义体中直接定义静态属性。

class MyClass {
    static myStaticProperty = 'This is a static property';
}
console.log(MyClass.myStaticProperty); 

这里,myStaticPropertyMyClass 类的静态属性,可以通过类名直接访问。

JavaScript中的继承

JavaScript通过 class 关键字和 extends 关键字来实现继承。子类可以继承父类的属性和方法,并且可以重写或扩展它们。

继承的基本语法

下面是一个简单的继承示例,定义一个 Student 类继承自 Person 类:

class Person {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
    sayHello() {
        console.log(`Hello, my name is ${this.name} and I'm ${this.age} years old.`);
    }
}
class Student extends Person {
    constructor(name, age, grade) {
        super(name, age);
        this.grade = grade;
    }
    study() {
        console.log(`${this.name} is studying in grade ${this.grade}.`);
    }
}
let tom = new Student('Tom', 15, 9);
tom.sayHello(); 
tom.study(); 

在这个例子中,class Student extends Person 表示 Student 类继承自 Person 类。Student 类的构造函数首先调用 super(name, age),这是为了调用父类 Person 的构造函数,以初始化从父类继承的属性 nameage。然后 Student 类定义了自己特有的属性 grade 和方法 study

重写父类方法

子类可以重写父类的方法,以提供适合自身的实现。例如,我们可以让 Student 类重写 sayHello 方法:

class Student extends Person {
    constructor(name, age, grade) {
        super(name, age);
        this.grade = grade;
    }
    study() {
        console.log(`${this.name} is studying in grade ${this.grade}.`);
    }
    sayHello() {
        console.log(`Hello, I'm ${this.name}, a student in grade ${this.grade}.`);
    }
}
let tom = new Student('Tom', 15, 9);
tom.sayHello(); 

这里,Student 类重写了 sayHello 方法,当调用 tom.sayHello() 时,执行的是 Student 类中重写后的 sayHello 方法。

访问父类方法

在子类重写的方法中,有时需要访问父类的方法。可以使用 super 关键字来调用父类的方法。例如,我们可以在 Student 类重写的 sayHello 方法中先调用父类的 sayHello 方法,然后再添加一些额外的输出:

class Student extends Person {
    constructor(name, age, grade) {
        super(name, age);
        this.grade = grade;
    }
    study() {
        console.log(`${this.name} is studying in grade ${this.grade}.`);
    }
    sayHello() {
        super.sayHello();
        console.log(`I'm a student in grade ${this.grade}.`);
    }
}
let tom = new Student('Tom', 15, 9);
tom.sayHello(); 

在这个例子中,super.sayHello() 调用了父类 PersonsayHello 方法,然后 Student 类的 sayHello 方法再输出一些额外的信息。

继承背后的原理:原型链

虽然ES6的 class 语法使继承看起来很直观,但理解其背后的原型链原理对于深入掌握JavaScript的面向对象编程非常重要。

原型对象

在JavaScript中,每个函数都有一个 prototype 属性,这个属性指向一个对象,称为原型对象。当使用构造函数创建一个新对象时,新对象的 __proto__ 属性会指向构造函数的原型对象。原型对象的作用是为对象提供共享的属性和方法。

例如,我们用构造函数的方式创建一个 Person 对象:

function Person(name, age) {
    this.name = name;
    this.age = age;
}
Person.prototype.sayHello = function() {
    console.log(`Hello, my name is ${this.name} and I'm ${this.age} years old.`);
};
let john = new Person('John', 30);
john.sayHello(); 

这里,Person.prototypePerson 构造函数的原型对象,sayHello 方法被定义在这个原型对象上。当 john 对象调用 sayHello 方法时,JavaScript会首先在 john 对象本身查找是否有 sayHello 方法,如果没有,就会沿着 __proto__ 链查找,也就是查找 Person.prototype 对象,最终找到并执行 sayHello 方法。

原型链

当一个对象继承另一个对象时,就形成了原型链。以 Student 类继承 Person 类为例,Student 类的原型对象(Student.prototype)会通过 Object.create 方法创建,并将其 __proto__ 属性指向 Person 类的原型对象(Person.prototype)。这样,当 Student 类的实例调用一个方法时,如果在 Student.prototype 中没有找到,就会沿着原型链向上查找,直到找到该方法或者到达原型链的顶端(Object.prototype)。

在ES6的 class 语法背后,实际上就是通过原型链来实现继承的。例如:

class Person {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
    sayHello() {
        console.log(`Hello, my name is ${this.name} and I'm ${this.age} years old.`);
    }
}
class Student extends Person {
    constructor(name, age, grade) {
        super(name, age);
        this.grade = grade;
    }
    study() {
        console.log(`${this.name} is studying in grade ${this.grade}.`);
    }
}
let tom = new Student('Tom', 15, 9);

tom 对象的原型链如下:

  1. tom.__proto__ === Student.prototype
  2. Student.prototype.__proto__ === Person.prototype
  3. Person.prototype.__proto__ === Object.prototype
  4. Object.prototype.__proto__ === null

tom 调用 sayHello 方法时,首先在 Student.prototype 中查找,没有找到,然后在 Person.prototype 中找到并执行。

理解原型链的重要性

理解原型链有助于我们更好地理解JavaScript中对象的属性和方法查找机制,以及继承的实现原理。这对于编写高效、可维护的代码非常重要。例如,在重写父类方法时,了解原型链可以帮助我们正确地调用父类方法,避免出现意外的行为。同时,在性能优化方面,也可以根据原型链的特性来合理设计对象的属性和方法,减少不必要的查找开销。

继承中的一些注意事项

在使用JavaScript的类与继承时,有一些需要注意的地方。

构造函数中的 this 指向

在构造函数中,this 指向新创建的对象实例。但是,在使用继承时,需要注意 super 调用的位置。如果在调用 super 之前使用 this,会导致错误,因为此时 this 还没有被正确初始化。

class Parent {
    constructor() {
        this.value = 'parent value';
    }
}
class Child extends Parent {
    constructor() {
        // 错误:在调用super()之前使用this
        console.log(this.value); 
        super();
    }
}

正确的做法是先调用 super,然后再对 this 进行操作:

class Child extends Parent {
    constructor() {
        super();
        this.newValue = 'child value';
    }
}

多重继承

JavaScript本身不支持传统的多重继承,即一个类不能同时继承多个父类。这是因为多重继承会带来一些复杂的问题,比如菱形继承问题(一个子类从多个父类继承相同的属性或方法,导致命名冲突和歧义)。然而,可以通过一些技术手段来模拟多重继承,比如混入(mixin)模式。

继承链的深度

虽然理论上可以创建很深的继承链,但在实际开发中,过深的继承链会使代码难以理解和维护。随着继承链的加深,属性和方法的查找路径变长,性能也会受到影响。同时,修改父类的方法或属性可能会对整个继承链产生意想不到的影响。因此,在设计继承体系时,应该尽量保持继承链的简洁和适度。

类与继承在实际项目中的应用

在实际的JavaScript项目中,类与继承被广泛应用于各种场景。

代码复用

通过继承,可以将一些通用的属性和方法提取到父类中,子类继承父类并根据自身需求进行扩展,从而避免了重复编写相似的代码。例如,在一个游戏开发项目中,可能有一个 GameObject 类,包含通用的属性如位置、大小等,以及通用的方法如渲染、移动等。然后各种具体的游戏对象类,如 PlayerEnemyBullet 等都可以继承自 GameObject 类,并添加各自特有的属性和方法。

模块封装

类可以作为一种很好的模块封装方式。将相关的属性和方法封装在类中,通过实例化类来使用这些功能,提高了代码的模块化和可维护性。例如,在一个Web应用中,可以将用户认证相关的功能封装在一个 AuthService 类中,包括登录、注册、验证等方法,其他部分的代码通过创建 AuthService 类的实例来使用这些功能。

设计模式实现

许多设计模式都依赖于类与继承的特性来实现。例如,工厂模式可以通过类来创建对象,策略模式可以通过继承和重写方法来实现不同的算法策略。在实际项目中,合理应用设计模式可以提高代码的可扩展性和可维护性。

总结与展望

JavaScript的类与继承为开发者提供了强大的面向对象编程能力。通过 class 关键字和 extends 关键字,我们可以方便地定义类和实现继承,提高代码的复用性、可维护性和可扩展性。同时,理解原型链等底层原理,有助于我们更好地掌握和运用类与继承的特性。

随着JavaScript的不断发展,面向对象编程的特性也在不断完善和优化。未来,我们可以期待更多的语法糖和功能增强,使JavaScript在面向对象编程方面更加成熟和强大,为开发者带来更好的编程体验和更高的开发效率。在实际项目中,我们应该根据具体需求合理运用类与继承,结合其他编程范式和设计模式,打造高质量的JavaScript应用。