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

JavaScript中的子类继承机制

2021-03-204.4k 阅读

JavaScript中的原型与继承基础

在JavaScript中,理解原型和继承机制是掌握面向对象编程(OOP)概念的关键。JavaScript不像传统的面向对象语言(如Java、C++)那样使用类来实现继承,而是基于原型链的机制来实现对象之间的属性和方法共享。

原型对象

每个函数在JavaScript中都有一个prototype属性,这个属性是一个对象,被称为原型对象。当使用构造函数创建新对象时,新对象会通过内部的[[Prototype]]属性(在ES6之前无法直接访问,ES6引入了__proto__属性,虽不推荐但可访问,标准方式是使用Object.getPrototypeOf())链接到构造函数的原型对象。

function Person(name) {
    this.name = name;
}
Person.prototype.sayHello = function() {
    console.log(`Hello, I'm ${this.name}`);
};
let person1 = new Person('John');
console.log(Object.getPrototypeOf(person1) === Person.prototype); // true
person1.sayHello(); // Hello, I'm John

在上述代码中,Person是一个构造函数,它的prototype对象有一个sayHello方法。当person1通过new Person('John')创建时,person1[[Prototype]]指向Person.prototype,所以person1可以访问到sayHello方法。

原型链

当访问一个对象的属性或方法时,如果该对象本身没有这个属性或方法,JavaScript会沿着原型链向上查找。原型链的顶端是Object.prototype,如果在Object.prototype中也找不到相应的属性或方法,那么访问会返回undefined

function Animal() {
    this.species = 'animal';
}
Animal.prototype.move = function() {
    console.log('I can move');
};
function Dog(name) {
    this.name = name;
    Animal.call(this);
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
Dog.prototype.bark = function() {
    console.log('Woof!');
};
let myDog = new Dog('Buddy');
myDog.move(); // I can move
myDog.bark(); // Woof!
console.log(myDog.species); // animal

在这段代码中,Dog继承自AnimalDog.prototype通过Object.create(Animal.prototype)创建,这使得Dog的实例可以沿着原型链访问到Animal.prototype上的属性和方法。同时,Dog.prototype.constructor被重新指向Dog,以确保构造函数的正确性。

传统的JavaScript子类继承实现

在ES6之前,JavaScript开发者主要通过手动设置原型链来实现子类继承。这种方式虽然有效,但相对繁琐且容易出错。

借用构造函数(Constructor Stealing)

借用构造函数是一种在子类构造函数中调用父类构造函数的技术,目的是继承父类的实例属性。

function Vehicle(make, model) {
    this.make = make;
    this.model = model;
}
function Car(make, model, year) {
    Vehicle.call(this, make, model);
    this.year = year;
}
let myCar = new Car('Toyota', 'Corolla', 2020);
console.log(myCar.make); // Toyota
console.log(myCar.model); // Corolla
console.log(myCar.year); // 2020

在上述代码中,Car构造函数通过Vehicle.call(this, make, model)调用了Vehicle构造函数,这样myCar就拥有了makemodel属性,就好像这些属性是直接在Car构造函数中定义的一样。

组合继承(Combination Inheritance)

组合继承结合了借用构造函数和原型链继承的优点。它通过借用构造函数来继承实例属性,通过原型链来继承原型属性和方法。

function Shape(color) {
    this.color = color;
}
Shape.prototype.getColor = function() {
    return this.color;
};
function Rectangle(width, height, color) {
    Shape.call(this, color);
    this.width = width;
    this.height = height;
}
Rectangle.prototype = Object.create(Shape.prototype);
Rectangle.prototype.constructor = Rectangle;
Rectangle.prototype.getArea = function() {
    return this.width * this.height;
};
let myRectangle = new Rectangle(5, 10, 'blue');
console.log(myRectangle.getColor()); // blue
console.log(myRectangle.getArea()); // 50

在这段代码中,Rectangle继承自Shape。通过Shape.call(this, color)继承了Shape的实例属性color,通过Rectangle.prototype = Object.create(Shape.prototype)设置原型链,使得Rectangle的实例可以访问Shape.prototype上的getColor方法,同时Rectangle.prototype上也定义了自己的getArea方法。

寄生组合继承(Parasitic Combination Inheritance)

寄生组合继承是一种优化的组合继承方式,它解决了组合继承中存在的一些问题,比如在原型链上不必要地多次调用父类构造函数。

function Parent(name) {
    this.name = name;
}
Parent.prototype.sayName = function() {
    console.log(`My name is ${this.name}`);
};
function Child(name, age) {
    Parent.call(this, name);
    this.age = age;
}
function inheritPrototype(child, parent) {
    let prototype = Object.create(parent.prototype);
    prototype.constructor = child;
    child.prototype = prototype;
}
inheritPrototype(Child, Parent);
Child.prototype.sayAge = function() {
    console.log(`I'm ${this.age} years old`);
};
let myChild = new Child('Alice', 30);
myChild.sayName(); // My name is Alice
myChild.sayAge(); // I'm 30 years old

在这段代码中,inheritPrototype函数通过Object.create(parent.prototype)创建了一个新的原型对象,这个对象的constructor被设置为Child,然后将这个新的原型对象赋值给Child.prototype。这样既避免了在原型链上多次调用父类构造函数,又实现了正确的继承关系。

ES6类继承

ES6引入了class关键字,使得JavaScript中的继承语法更加简洁和直观,更符合传统面向对象语言的习惯。然而,需要明确的是,class在JavaScript中仍然是基于原型链的语法糖。

基本语法

class Animal {
    constructor(species) {
        this.species = species;
    }
    move() {
        console.log('I can move');
    }
}
class Dog extends Animal {
    constructor(name, species) {
        super(species);
        this.name = name;
    }
    bark() {
        console.log('Woof!');
    }
}
let myDog = new Dog('Buddy', 'canine');
myDog.move(); // I can move
myDog.bark(); // Woof!
console.log(myDog.species); // canine

在上述代码中,Dog类通过extends关键字继承自Animal类。super(species)调用了父类的构造函数,这样myDog就拥有了species属性。Dog类还定义了自己的bark方法。

重写方法

子类可以重写父类的方法,以实现不同的行为。

class Shape {
    constructor(color) {
        this.color = color;
    }
    getColor() {
        return this.color;
    }
}
class Rectangle extends Shape {
    constructor(width, height, color) {
        super(color);
        this.width = width;
        this.height = height;
    }
    getColor() {
        return `The color of the rectangle is ${super.getColor()}`;
    }
    getArea() {
        return this.width * this.height;
    }
}
let myRectangle = new Rectangle(5, 10, 'blue');
console.log(myRectangle.getColor()); // The color of the rectangle is blue
console.log(myRectangle.getArea()); // 50

在这段代码中,Rectangle类重写了Shape类的getColor方法。在RectanglegetColor方法中,通过super.getColor()调用了父类的getColor方法,并对返回值进行了扩展。

访问父类静态方法

静态方法也可以被继承。子类可以通过super关键字访问父类的静态方法。

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

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

继承中的this指向问题

在JavaScript的继承中,this的指向是一个容易混淆的问题。它取决于函数的调用方式。

构造函数中的this

在构造函数中,this指向新创建的对象。

function Person(name) {
    this.name = name;
    console.log(this);
}
let person1 = new Person('John');
// {name: 'John'}

在上述代码中,当new Person('John')执行时,this指向新创建的person1对象。

原型方法中的this

在原型方法中,this指向调用该方法的对象。

function Person(name) {
    this.name = name;
}
Person.prototype.sayHello = function() {
    console.log(`Hello, I'm ${this.name}`);
};
let person1 = new Person('John');
person1.sayHello(); // Hello, I'm John

在这段代码中,sayHello方法是在person1对象上调用的,所以this指向person1

箭头函数中的this

箭头函数没有自己的this,它的this取决于定义时的上下文。这在继承中可能会导致一些意外情况。

class Parent {
    constructor() {
        this.value = 'parent value';
        this.printValue = () => {
            console.log(this.value);
        };
    }
}
class Child extends Parent {
    constructor() {
        super();
        this.value = 'child value';
    }
}
let child = new Child();
child.printValue(); // parent value

在上述代码中,printValue是一个箭头函数,它在Parent的构造函数中定义。此时this指向Parent的实例。当Child继承自Parent并创建实例child时,child.printValue()仍然打印parent value,因为箭头函数的this不会因为继承而改变。

多重继承与混入(Mixin)

在JavaScript中,虽然不支持传统的多重继承(一个类继承自多个父类),但可以通过混入(Mixin)模式来实现类似的功能。

混入模式

混入模式是将一个或多个对象的属性和方法复制到另一个对象中。

let mixin1 = {
    method1: function() {
        console.log('Method 1 from mixin1');
    }
};
let mixin2 = {
    method2: function() {
        console.log('Method 2 from mixin2');
    }
};
function MyClass() {}
Object.assign(MyClass.prototype, mixin1, mixin2);
let myObject = new MyClass();
myObject.method1(); // Method 1 from mixin1
myObject.method2(); // Method 2 from mixin2

在上述代码中,Object.assign(MyClass.prototype, mixin1, mixin2)mixin1mixin2的属性和方法复制到了MyClass.prototype中,使得MyClass的实例myObject可以访问这些方法。

多重继承的模拟

通过多次使用混入,可以模拟多重继承的效果。

let AnimalMixin = {
    move: function() {
        console.log('I can move');
    }
};
let FlyMixin = {
    fly: function() {
        console.log('I can fly');
    }
};
let BirdClass = function() {};
Object.assign(BirdClass.prototype, AnimalMixin, FlyMixin);
let myBird = new BirdClass();
myBird.move(); // I can move
myBird.fly(); // I can fly

在这段代码中,BirdClass通过混入AnimalMixinFlyMixin,获得了movefly方法,模拟了从多个“父类”继承的效果。

继承中的性能考虑

在使用继承时,性能是一个需要考虑的因素。不同的继承方式对性能有不同的影响。

原型链查找的性能

原型链查找是一个线性的过程,随着原型链的增长,查找属性和方法的时间会增加。因此,尽量保持原型链的简短可以提高性能。

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

在上述代码中,Derived的原型链相对较长,如果频繁查找属性,可能会影响性能。

构造函数调用的性能

在组合继承和寄生组合继承中,构造函数的调用次数会影响性能。寄生组合继承通过优化构造函数的调用次数,相比组合继承在性能上有一定优势。

// 组合继承
function Parent1(name) {
    this.name = name;
}
Parent1.prototype.sayName = function() {
    console.log(`My name is ${this.name}`);
};
function Child1(name, age) {
    Parent1.call(this, name);
    this.age = age;
}
Child1.prototype = new Parent1();
Child1.prototype.constructor = Child1;
// 寄生组合继承
function Parent2(name) {
    this.name = name;
}
Parent2.prototype.sayName = function() {
    console.log(`My name is ${this.name}`);
};
function Child2(name, age) {
    Parent2.call(this, name);
    this.age = age;
}
function inheritPrototype(child, parent) {
    let prototype = Object.create(parent.prototype);
    prototype.constructor = child;
    child.prototype = prototype;
}
inheritPrototype(Child2, Parent2);

在上述代码中,组合继承中Child1.prototype = new Parent1();会额外调用一次Parent1的构造函数,而寄生组合继承通过Object.create(parent.prototype)避免了这种不必要的调用,从而提高了性能。

总结

JavaScript中的子类继承机制是其面向对象编程的核心部分。从早期基于原型链的手动实现,到ES6简洁的class语法,开发者有多种方式来实现对象之间的属性和方法继承。理解不同继承方式的原理、this指向问题、多重继承的模拟以及性能考虑,对于编写高效、可维护的JavaScript代码至关重要。无论是传统的继承方式还是ES6的class继承,都是基于原型链的底层机制,掌握这一点有助于深入理解JavaScript的继承体系,从而更好地应用于实际开发中。