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

JavaScript子类的继承机制剖析

2021-07-136.3k 阅读

JavaScript 中的继承概念简述

在面向对象编程中,继承是一个重要的概念。它允许创建一个新类(子类),这个新类基于已有的类(父类),并自动拥有父类的属性和方法。通过继承,子类可以复用父类的代码,减少重复代码的编写,同时还能在此基础上添加自己特有的属性和方法,实现功能的扩展。

在 JavaScript 早期,它并没有像传统面向类的编程语言(如 Java、C++ 等)那样直接的类继承语法。JavaScript 是基于原型的语言,它的继承机制围绕着原型对象展开。随着 ES6(ECMAScript 2015)的发布,引入了 class 关键字,使得 JavaScript 的继承语法看起来更像传统面向类的语言,但本质上还是基于原型的继承。

基于原型链的继承(ES5 及之前)

原型对象的基本概念

在 JavaScript 中,每个函数都有一个 prototype 属性,这个属性指向一个对象,我们称之为原型对象。当通过构造函数创建一个实例时,实例的 __proto__ 属性会指向构造函数的 prototype 对象。例如:

function Animal() {
    this.name = 'Animal';
}
Animal.prototype.speak = function() {
    console.log(this.name +'makes a sound.');
};
let animal = new Animal();
console.log(animal.__proto__ === Animal.prototype); // true
animal.speak(); // Animal makes a sound.

这里,Animal 是一个构造函数,Animal.prototype 是它的原型对象。animalAnimal 的实例,animal.__proto__ 指向 Animal.prototype。当调用 animal.speak() 时,JavaScript 会首先在 animal 自身寻找 speak 方法,如果找不到,就会沿着 __proto__ 链,到 Animal.prototype 中寻找,这就是原型链查找机制。

实现继承的经典方式 - 借用构造函数(Constructor Stealing)

借用构造函数的方式,也叫构造函数窃取,主要是利用 callapply 方法在子类构造函数中调用父类构造函数,从而继承父类的属性。例如:

function Animal(name) {
    this.name = name;
}
function Dog(name, breed) {
    Animal.call(this, name);
    this.breed = breed;
}
let dog = new Dog('Buddy', 'Golden Retriever');
console.log(dog.name); // Buddy
console.log(dog.breed); // Golden Retriever

在上述代码中,Dog 构造函数通过 Animal.call(this, name) 调用了 Animal 构造函数,使得 dog 实例拥有了 name 属性。这种方式的优点是可以在子类中传递参数给父类构造函数,并且可以避免父类的属性被所有子类实例共享。但是,它的缺点也很明显,无法继承父类原型上的方法。比如:

Animal.prototype.speak = function() {
    console.log(this.name +'makes a sound.');
};
// dog.speak(); // TypeError: dog.speak is not a function

dog 实例无法调用 speak 方法,因为 Dog 构造函数并没有继承 Animal.prototype 上的方法。

组合继承(Combination Inheritance)

组合继承结合了借用构造函数和原型链的优点。它通过借用构造函数继承父类的属性,通过原型链继承父类原型上的方法。示例如下:

function Animal(name) {
    this.name = name;
}
Animal.prototype.speak = function() {
    console.log(this.name +'makes a sound.');
};
function Dog(name, breed) {
    Animal.call(this, name);
    this.breed = breed;
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
let dog = new Dog('Buddy', 'Golden Retriever');
dog.speak(); // Buddy makes a sound.
console.log(dog.breed); // Golden Retriever

在上述代码中,Dog.prototype = Object.create(Animal.prototype) 这一行代码创建了一个新的对象,这个对象的原型是 Animal.prototype,从而实现了原型链继承。同时,通过 Dog.prototype.constructor = Dog 重新设置了 Dog.prototypeconstructor 属性,使其指向 Dog 构造函数。这样,dog 实例既能继承 Animal 的属性,又能调用 Animal.prototype 上的方法。

然而,组合继承也存在一些问题。在创建子类实例时,父类构造函数会被调用两次。一次是在 Animal.call(this, name) 时,另一次是在 Object.create(Animal.prototype) 创建原型对象时(因为 Object.create 创建的对象会隐式调用父类构造函数)。这会导致一些不必要的开销。

寄生组合继承(Parasitic Combination Inheritance)

寄生组合继承是为了解决组合继承中父类构造函数被调用两次的问题。它的核心思想是通过创建一个中间函数,将父类原型的属性和方法复制到子类原型上,而不是直接将子类原型设置为父类原型的实例。示例代码如下:

function Animal(name) {
    this.name = name;
}
Animal.prototype.speak = function() {
    console.log(this.name +'makes a sound.');
};
function Dog(name, breed) {
    Animal.call(this, name);
    this.breed = breed;
}
function inheritPrototype(subType, superType) {
    let prototype = Object.create(superType.prototype);
    prototype.constructor = subType;
    subType.prototype = prototype;
}
inheritPrototype(Dog, Animal);
let dog = new Dog('Buddy', 'Golden Retriever');
dog.speak(); // Buddy makes a sound.
console.log(dog.breed); // Golden Retriever

在上述代码中,inheritPrototype 函数实现了寄生组合继承。它首先通过 Object.create(superType.prototype) 创建一个新的原型对象,然后设置其 constructor 属性,最后将这个新的原型对象赋值给 subType.prototype。这样,既避免了父类构造函数的重复调用,又实现了原型链继承。

ES6 类继承语法

class 关键字的基本使用

ES6 引入的 class 关键字使得 JavaScript 的继承语法更加简洁和直观。class 实际上是一个语法糖,它背后仍然是基于原型的继承。以下是一个简单的 class 继承示例:

class Animal {
    constructor(name) {
        this.name = name;
    }
    speak() {
        console.log(this.name +'makes a sound.');
    }
}
class Dog extends Animal {
    constructor(name, breed) {
        super(name);
        this.breed = breed;
    }
}
let dog = new Dog('Buddy', 'Golden Retriever');
dog.speak(); // Buddy makes a sound.
console.log(dog.breed); // Golden Retriever

在上述代码中,class Animal 定义了一个父类,class Dog extends Animal 定义了一个子类 Dog,它继承自 Animal。在 Dogconstructor 中,通过 super(name) 调用了父类的 constructor 方法,从而继承了父类的属性。

super 关键字的深入理解

super 关键字在 ES6 类继承中有多种用途。在构造函数中,super 用于调用父类的构造函数。例如:

class Parent {
    constructor(value) {
        this.value = value;
    }
}
class Child extends Parent {
    constructor(value, additionalValue) {
        super(value);
        this.additionalValue = additionalValue;
    }
}
let child = new Child(10, 20);
console.log(child.value); // 10
console.log(child.additionalValue); // 20

这里,super(value) 调用了 Parent 的构造函数,并传递了 value 参数。

super 还可以用于调用父类的方法。例如:

class Animal {
    speak() {
        console.log('Animal makes a sound.');
    }
}
class Dog extends Animal {
    speak() {
        super.speak();
        console.log('Dog barks.');
    }
}
let dog = new Dog();
dog.speak(); 
// Animal makes a sound.
// Dog barks.

Dogspeak 方法中,super.speak() 调用了父类 Animalspeak 方法,然后再输出子类特有的信息。

静态方法的继承

在 ES6 类中,静态方法是通过 static 关键字定义的,这些方法属于类本身,而不是类的实例。静态方法也可以被继承。例如:

class MathUtils {
    static add(a, b) {
        return a + b;
    }
}
class AdvancedMathUtils extends MathUtils {
    static multiply(a, b) {
        return super.add(a, b) * 2;
    }
}
console.log(AdvancedMathUtils.add(2, 3)); // 5
console.log(AdvancedMathUtils.multiply(2, 3)); // 10

在上述代码中,AdvancedMathUtils 继承了 MathUtils 的静态方法 add,并且在自己的静态方法 multiply 中通过 super.add(a, b) 调用了父类的静态方法。

子类继承中的属性遮蔽(Property Shadowing)

属性遮蔽的概念

属性遮蔽是指当子类定义了与父类同名的属性或方法时,子类的属性或方法会覆盖父类的属性或方法。例如:

class Animal {
    name = 'Animal';
    speak() {
        console.log(this.name +'makes a sound.');
    }
}
class Dog extends Animal {
    name = 'Dog';
    speak() {
        console.log(this.name +'barks.');
    }
}
let dog = new Dog();
dog.speak(); // Dog barks.

在上述代码中,Dog 类定义了与 Animal 类同名的 name 属性和 speak 方法,从而遮蔽了父类的属性和方法。

访问被遮蔽的父类属性

有时候,我们可能需要在子类中访问被遮蔽的父类属性。在 ES6 类中,可以通过 super 关键字来访问父类的属性。例如:

class Animal {
    name = 'Animal';
    speak() {
        console.log(this.name +'makes a sound.');
    }
}
class Dog extends Animal {
    name = 'Dog';
    speak() {
        console.log(super.name +'is the base type, and'+ this.name +'barks.');
    }
}
let dog = new Dog();
dog.speak(); 
// Animal is the base type, and Dog barks.

这里,通过 super.name 访问了父类的 name 属性。

继承中的原型链关系

ES6 类继承的原型链结构

在 ES6 类继承中,子类的原型链结构与基于原型链继承的方式类似,但语法更简洁。当使用 class Child extends Parent 时,Child.prototype 的原型是 Parent.prototype。例如:

class Parent {}
class Child extends Parent {}
console.log(Child.prototype.__proto__ === Parent.prototype); // true

这表明 Child 类通过原型链继承了 Parent 类的属性和方法。

原型链对性能的影响

原型链的长度会影响属性查找的性能。当访问一个对象的属性时,JavaScript 会沿着原型链逐级查找,直到找到该属性或者到达原型链的顶端(null)。如果原型链过长,属性查找的时间会增加,从而影响性能。例如:

function Base() {}
function Intermediate() {}
function Derived() {}
Intermediate.prototype = Object.create(Base.prototype);
Derived.prototype = Object.create(Intermediate.prototype);
let derived = new Derived();
// 查找属性时,会沿着 Derived.prototype -> Intermediate.prototype -> Base.prototype 这条原型链查找

在实际编程中,应尽量避免创建过长的原型链,以提高性能。

多重继承与混入(Mixins)

多重继承的概念与问题

多重继承是指一个类可以从多个父类继承属性和方法。在一些传统编程语言(如 C++)中支持多重继承,但它也带来了一些问题,比如菱形继承问题(也叫钻石问题)。在 JavaScript 中,由于其基于原型的继承机制,原生并不支持多重继承。例如,如果有两个父类 AB 都定义了同名的方法,当一个子类同时继承 AB 时,就会出现命名冲突。

混入(Mixins)的实现方式

混入是一种在 JavaScript 中模拟多重继承的技术。它通过将多个对象的属性和方法合并到一个目标对象中,从而实现类似多重继承的效果。以下是一个简单的混入实现示例:

const mixin1 = {
    method1() {
        console.log('Method 1 from mixin1');
    }
};
const mixin2 = {
    method2() {
        console.log('Method 2 from mixin2');
    }
};
function mix(target,...sources) {
    sources.forEach(source => {
        Object.keys(source).forEach(key => {
            target[key] = source[key];
        });
    });
    return target;
}
class MyClass {}
mix(MyClass.prototype, mixin1, mixin2);
let myObject = new MyClass();
myObject.method1(); // Method 1 from mixin1
myObject.method2(); // Method 2 from mixin2

在上述代码中,mix 函数将 mixin1mixin2 的属性和方法合并到了 MyClass.prototype 中,使得 MyClass 的实例可以调用这些方法,实现了类似多重继承的效果。

继承与 this 指针

继承中 this 指针的指向问题

在继承关系中,this 指针的指向可能会引起混淆。this 指针在函数调用时确定,它指向调用该函数的对象。在子类方法中调用父类方法时,需要注意 this 指针的指向。例如:

class Animal {
    constructor(name) {
        this.name = name;
    }
    speak() {
        console.log(this.name +'makes a sound.');
    }
}
class Dog extends Animal {
    constructor(name, breed) {
        super(name);
        this.breed = breed;
    }
    speakAndBreed() {
        this.speak();
        console.log(this.breed +'is the breed.');
    }
}
let dog = new Dog('Buddy', 'Golden Retriever');
let speakFunction = dog.speak.bind(dog);
setTimeout(speakFunction, 1000); 
// 1 秒后输出:Buddy makes a sound.
dog.speakAndBreed(); 
// 输出:Buddy makes a sound. Golden Retriever is the breed.

DogspeakAndBreed 方法中,this.speak() 调用了父类的 speak 方法,此时 this 指向 dog 实例,所以能正确输出 name 属性。而在 setTimeout(speakFunction, 1000) 中,通过 bind 方法将 speak 方法的 this 绑定到 dog 实例,确保在定时器回调中 this 指向正确。

使用箭头函数时 this 指针的特殊性

在 ES6 中,箭头函数没有自己的 this 指针,它的 this 指针继承自外层作用域。在继承中使用箭头函数时需要特别注意。例如:

class Animal {
    constructor(name) {
        this.name = name;
    }
    speak() {
        setTimeout(() => {
            console.log(this.name +'makes a sound after delay.');
        }, 1000);
    }
}
let animal = new Animal('Lion');
animal.speak(); 
// 1 秒后输出:Lion makes a sound after delay.

在上述代码中,setTimeout 中的箭头函数的 this 继承自 speak 方法的作用域,也就是 animal 实例,所以能正确输出 name 属性。如果这里使用普通函数,this 指向会是 window(在浏览器环境下)或 global(在 Node.js 环境下),导致输出错误。

继承在实际项目中的应用场景

代码复用与模块化开发

在实际项目中,继承常用于代码复用。例如,在一个游戏开发项目中,可能有一个 GameObject 类,包含了通用的属性和方法,如位置、大小、渲染等。然后,不同类型的游戏对象,如 PlayerEnemyItem 等可以继承自 GameObject,复用其通用代码,同时添加各自特有的属性和方法。

class GameObject {
    constructor(x, y, width, height) {
        this.x = x;
        this.y = y;
        this.width = width;
        this.height = height;
    }
    render(ctx) {
        ctx.rect(this.x, this.y, this.width, this.height);
        ctx.stroke();
    }
}
class Player extends GameObject {
    constructor(x, y, width, height, health) {
        super(x, y, width, height);
        this.health = health;
    }
    move(dx, dy) {
        this.x += dx;
        this.y += dy;
    }
}

通过继承,Player 类复用了 GameObject 的代码,减少了重复编写。

面向对象设计模式中的应用

继承在面向对象设计模式中也有广泛应用。例如,在策略模式中,不同的策略类可以继承自一个抽象的策略基类。以一个支付系统为例:

class PaymentStrategy {
    pay(amount) {
        throw new Error('This method must be implemented by subclasses.');
    }
}
class CreditCardPayment extends PaymentStrategy {
    constructor(cardNumber, expiration, cvv) {
        this.cardNumber = cardNumber;
        this.expiration = expiration;
        this.cvv = cvv;
    }
    pay(amount) {
        console.log(`Paying ${amount} with credit card ${this.cardNumber}`);
    }
}
class PayPalPayment extends PaymentStrategy {
    constructor(email) {
        this.email = email;
    }
    pay(amount) {
        console.log(`Paying ${amount} with PayPal account ${this.email}`);
    }
}

这里,CreditCardPaymentPayPalPayment 继承自 PaymentStrategy,实现了不同的支付策略。

总结

JavaScript 的子类继承机制从早期基于原型链的方式,到 ES6 引入的 class 语法糖,不断发展和完善。理解继承机制的本质,包括原型链、super 关键字的使用、属性遮蔽等概念,对于编写高效、可维护的 JavaScript 代码至关重要。同时,在实际项目中,合理运用继承实现代码复用和遵循设计模式,能够提升项目的质量和开发效率。无论是传统的基于原型链继承,还是 ES6 的类继承语法,都为开发者提供了强大的工具来构建复杂的面向对象应用程序。在实际应用中,需要根据具体的需求和场景,选择合适的继承方式,并注意避免继承带来的一些潜在问题,如原型链过长导致的性能问题、this 指针指向混淆等。通过深入理解和熟练运用 JavaScript 的继承机制,开发者可以更好地驾驭这门语言,创造出优秀的软件产品。