JavaScript中的原型继承与组合继承
JavaScript 中的原型继承
在 JavaScript 中,原型继承是其实现继承的核心机制之一。理解原型继承,需要先深入了解 JavaScript 中的原型对象。
原型对象(Prototype Object)
每一个 JavaScript 函数都有一个 prototype
属性,这个属性指向一个对象,该对象就是所谓的原型对象。当通过构造函数创建实例时,实例对象会通过内部的 [[Prototype]]
(在现代 JavaScript 中可以通过 __proto__
访问,但 __proto__
并非标准属性,不推荐在生产代码中使用)链接到构造函数的原型对象。
例如:
function Person(name) {
this.name = name;
}
// Person 函数有一个 prototype 属性
console.log(Person.prototype);
上述代码中,Person
是一个构造函数,它具有 prototype
属性,该属性指向一个对象,这个对象默认有一个 constructor
属性,指向构造函数本身,即 Person.prototype.constructor === Person // true
。
原型链(Prototype Chain)
当访问一个对象的属性时,如果该对象本身没有这个属性,JavaScript 会沿着原型链向上查找。原型链是由对象的 [[Prototype]]
链接形成的。最终,原型链会指向 Object.prototype
,如果在 Object.prototype
中也找不到属性,就会返回 undefined
。
function Animal() {
this.species = 'animal';
}
function Dog(name) {
this.name = name;
}
// 设置 Dog 的原型为 Animal 的实例
Dog.prototype = new Animal();
let myDog = new Dog('Buddy');
// 查找 species 属性,myDog 本身没有 species 属性,
// 会沿着原型链在 Dog.prototype(也就是 Animal 的实例)中找到
console.log(myDog.species);
在上述代码中,myDog
是 Dog
的实例,Dog
的原型是 Animal
的实例。当访问 myDog.species
时,由于 myDog
本身没有 species
属性,JavaScript 会在 myDog.__proto__
(即 Dog.prototype
)中查找,找到了 species
属性并返回其值。
原型继承的本质
从本质上讲,原型继承是基于对象之间的委托关系。一个对象可以委托其原型对象来处理它无法处理的属性和方法请求。这种委托关系构成了原型链,使得对象可以共享属性和方法,从而实现类似继承的效果。
例如,所有的数组对象都共享 Array.prototype
上定义的方法,如 push
、pop
等。当我们创建一个数组 let arr = []
,arr
本身并没有这些方法的具体实现,但通过原型链,它可以访问到 Array.prototype
上的方法,因为 arr.__proto__ === Array.prototype
。
组合继承
虽然原型继承是 JavaScript 实现继承的基础,但它也存在一些问题。组合继承是一种更为实用的继承方式,它结合了原型链和构造函数的优点。
组合继承的原理
组合继承通过调用父类构造函数来初始化子类实例的属性,然后通过设置原型链来实现方法的继承。
function Animal(name) {
this.name = name;
this.species = 'animal';
}
Animal.prototype.speak = function() {
console.log(this.name +'makes a sound.');
};
function Dog(name, breed) {
// 调用父类构造函数,初始化父类属性
Animal.call(this, name);
this.breed = breed;
}
// 设置 Dog 的原型为 Animal 的实例,实现方法继承
Dog.prototype = new Animal();
// 修正 constructor 属性,使其指向 Dog 构造函数
Dog.prototype.constructor = Dog;
Dog.prototype.bark = function() {
console.log(this.name +'barks.');
};
let myDog = new Dog('Buddy', 'Golden Retriever');
myDog.speak();
myDog.bark();
在上述代码中:
- 在
Dog
构造函数内部,通过Animal.call(this, name)
调用了Animal
构造函数,这样myDog
就拥有了name
和species
属性,这一步确保了每个Dog
实例都有自己独立的属性副本。 - 通过
Dog.prototype = new Animal()
,将Dog
的原型设置为Animal
的实例,使得Dog
实例可以访问Animal.prototype
上的方法,如speak
方法。 - 由于
Dog.prototype = new Animal()
会导致Dog.prototype.constructor
指向Animal
,所以需要手动修正Dog.prototype.constructor = Dog
,以保证constructor
属性的正确性。
组合继承的优点
- 属性独立:每个子类实例都有自己独立的属性副本,避免了原型继承中属性共享带来的问题。例如,如果在原型上定义一个数组属性,在原型继承中所有实例都会共享这个数组,一个实例对数组的修改会影响其他实例;而在组合继承中,每个实例都有自己独立的属性,不会相互干扰。
- 方法共享:子类实例可以共享父类原型上的方法,提高了代码的复用性。所有
Dog
实例都可以调用Animal.prototype
上的speak
方法,而不需要在每个Dog
实例中重复定义。
组合继承的缺点
组合继承虽然解决了原型继承的一些问题,但它也并非完美无缺。在组合继承中,父类构造函数会被调用两次。一次是在 Dog.prototype = new Animal()
创建原型对象时,另一次是在 Animal.call(this, name)
初始化子类实例时。这会导致一些不必要的开销,特别是当父类构造函数执行一些复杂操作时。
例如,如果 Animal
构造函数会创建一些大型的数据结构或者执行一些耗时的操作,那么在创建 Dog
的原型对象和 Dog
实例时,这些操作都会被执行两次,降低了性能。
寄生组合继承
为了克服组合继承中父类构造函数被调用两次的问题,出现了寄生组合继承。
寄生组合继承的原理
寄生组合继承的核心思想是通过创建一个临时构造函数,用这个临时构造函数的原型指向父类的原型,然后用这个临时构造函数的实例作为子类的原型。这样既避免了父类构造函数的重复调用,又保持了原型链的完整性。
function inheritPrototype(subType, superType) {
let prototype = Object.create(superType.prototype);
prototype.constructor = subType;
subType.prototype = prototype;
}
function Animal(name) {
this.name = name;
this.species = 'animal';
}
Animal.prototype.speak = function() {
console.log(this.name +'makes a sound.');
};
function Dog(name, breed) {
Animal.call(this, name);
this.breed = breed;
}
inheritPrototype(Dog, Animal);
Dog.prototype.bark = function() {
console.log(this.name +'barks.');
};
let myDog = new Dog('Buddy', 'Golden Retriever');
myDog.speak();
myDog.bark();
在上述代码中:
inheritPrototype
函数是寄生组合继承的关键。它通过Object.create(superType.prototype)
创建一个新的对象,这个对象的原型指向superType.prototype
。Object.create
方法创建一个新对象,使用现有的对象来提供新创建对象的[[Prototype]]
。- 然后设置新对象的
constructor
属性指向subType
,最后将subType.prototype
设置为这个新对象。 - 在
Dog
构造函数中,依然通过Animal.call(this, name)
来初始化实例属性,这样就实现了属性的独立和方法的正确继承,同时避免了父类构造函数的重复调用。
寄生组合继承的优点
- 高效性:避免了组合继承中父类构造函数的重复调用,提高了性能。特别是在父类构造函数执行复杂操作时,寄生组合继承的优势更加明显。
- 原型链完整性:保持了原型链的完整性,使得 instanceof 操作符和 isPrototypeOf 方法能够正常工作。例如,
myDog instanceof Dog // true
,myDog instanceof Animal // true
,因为myDog
的原型链是正确构建的。
寄生组合继承在现代 JavaScript 中的应用
在现代 JavaScript 中,class
语法糖的底层实现其实就借鉴了寄生组合继承的思想。虽然 class
语法看起来更像是传统面向类语言中的类继承,但实际上它在 JavaScript 引擎内部依然是基于原型和构造函数来实现的。
class Animal {
constructor(name) {
this.name = name;
this.species = 'animal';
}
speak() {
console.log(this.name +'makes a sound.');
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name);
this.breed = breed;
}
bark() {
console.log(this.name +'barks.');
}
}
let myDog = new Dog('Buddy', 'Golden Retriever');
myDog.speak();
myDog.bark();
在上述代码中,class Dog extends Animal
表示 Dog
类继承自 Animal
类。super(name)
相当于在 Dog
构造函数内部调用 Animal
构造函数来初始化属性。class
语法糖使得 JavaScript 的继承更加直观和易于理解,但本质上还是基于原型和构造函数的机制,类似于寄生组合继承。
原型继承与组合继承的实际应用场景
- 原型继承的应用场景
- 简单对象复用:当需要创建多个具有相似属性和方法的简单对象,且这些对象不需要有独立的属性副本时,原型继承是一个不错的选择。例如,创建一些工具函数对象,这些对象只提供一些方法,没有自身的状态属性。
在这个例子中,function MathUtils() {} MathUtils.prototype.add = function(a, b) { return a + b; }; MathUtils.prototype.multiply = function(a, b) { return a * b; }; let mathUtils1 = new MathUtils(); let mathUtils2 = new MathUtils(); console.log(mathUtils1.add(2, 3)); console.log(mathUtils2.multiply(4, 5));
MathUtils
的实例不需要有自己独立的属性,通过原型继承可以方便地复用add
和multiply
方法。 - 组合继承的应用场景
- 模拟传统类继承结构:当需要模拟传统面向对象语言中的类继承结构,并且子类实例需要有自己独立的属性副本,同时又要共享父类的方法时,组合继承非常适用。例如,在开发游戏角色系统时,有一个
Character
父类,包含一些通用的属性如name
、health
等,以及通用的方法如move
。然后有Warrior
、Mage
等子类继承自Character
,每个子类有自己独特的属性和方法,同时共享Character
的属性和方法。
在这个场景中,每个function Character(name, health) { this.name = name; this.health = health; } Character.prototype.move = function() { console.log(this.name +'is moving.'); }; function Warrior(name, health, weapon) { Character.call(this, name, health); this.weapon = weapon; } Warrior.prototype.attack = function() { console.log(this.name +'attacks with'+ this.weapon); }; let myWarrior = new Warrior('Aragorn', 100, 'Sword'); myWarrior.move(); myWarrior.attack();
Warrior
实例有自己独立的name
、health
和weapon
属性,同时可以共享Character.prototype
上的move
方法。 - 模拟传统类继承结构:当需要模拟传统面向对象语言中的类继承结构,并且子类实例需要有自己独立的属性副本,同时又要共享父类的方法时,组合继承非常适用。例如,在开发游戏角色系统时,有一个
- 寄生组合继承的应用场景
- 性能敏感的继承场景:在性能要求较高的应用中,特别是当父类构造函数执行复杂操作时,寄生组合继承是首选。例如,在开发大型前端框架时,框架内部可能有很多继承关系,并且一些基类的构造函数可能会进行大量的初始化工作,如创建 DOM 元素、绑定事件等。使用寄生组合继承可以避免父类构造函数的重复执行,提高框架的性能。
深入理解 JavaScript 继承机制的要点
-
原型对象的动态性:JavaScript 的原型对象是动态的。这意味着在运行时可以向原型对象添加新的属性和方法,所有基于该原型的实例都会立即获得这些新成员。
function Person(name) { this.name = name; } let person1 = new Person('Alice'); Person.prototype.sayHello = function() { console.log('Hello, I\'m'+ this.name); }; person1.sayHello();
在上述代码中,先创建了
person1
实例,然后向Person.prototype
添加了sayHello
方法,person1
依然可以调用这个新添加的方法。 -
constructor 属性的重要性:
constructor
属性虽然在实际使用中可能不常直接操作,但它对于保持原型链的完整性和正确识别对象的构造函数非常重要。在手动设置原型对象时,一定要确保constructor
属性指向正确的构造函数,如在组合继承和寄生组合继承中修正constructor
属性的操作。 -
原型链查找的性能:虽然原型链查找机制为 JavaScript 带来了强大的继承能力,但它也有一定的性能开销。当沿着原型链查找一个属性时,JavaScript 引擎需要遍历原型链上的每个对象,直到找到该属性或者到达原型链的顶端。因此,在设计对象结构时,尽量避免过深的原型链,以提高属性查找的性能。
-
不同继承方式的选择:根据实际需求选择合适的继承方式至关重要。如果只需要简单的对象复用,原型继承可能就足够了;如果需要模拟传统类继承结构且对性能要求不是特别高,组合继承是个不错的选择;而在性能敏感的场景下,寄生组合继承则更为合适。同时,要理解现代 JavaScript 中
class
语法糖背后的继承原理,以便更好地运用继承机制进行开发。
总结
JavaScript 的原型继承和组合继承是其实现继承的重要机制。原型继承基于原型对象和原型链,实现了对象之间的属性和方法共享;组合继承结合了原型链和构造函数,解决了原型继承中属性共享的问题,但存在父类构造函数重复调用的缺点;寄生组合继承则在保持原型链完整性的同时,避免了父类构造函数的重复调用,提高了性能。
在实际开发中,深入理解这些继承机制,根据具体场景选择合适的继承方式,对于编写高效、可维护的 JavaScript 代码至关重要。同时,也要注意原型对象的动态性、constructor
属性的正确性以及原型链查找的性能等要点,以充分发挥 JavaScript 继承机制的优势。