本文深入解析JavaScript中的继承机制,从原型链的基本概念出发,分析其存在的引用类型共享与构造函数覆盖问题。随后介绍经典继承、组合式继承的优缺点,最终详解最理想的寄生组合式继承实现方式,帮助开发者掌握JavaScript面向对象编程的核心技术。
JavaScript原型与对象关系
前文阐述了原型概念,明确了JavaScript中构造函数、原型对象与实例三者间的关系:每个构造函数拥有一个原型对象,原型对象内含构造函数的引用,而实例则持有指向原型对象的指针。
JavaScript并非传统的面向对象语言,而是基于对象的语言。对于习惯其他OO语言的开发者而言,初期可能因缺乏“类”的概念而感到不适,因为JS中不严格区分“类”与“实例”,也不存在明确的“父类”或“子类”划分。那么,JS中的对象是如何关联起来的呢? 幸运的是,JavaScript在设计初期便提供了“继承”机制。在深入理解“继承”之前,我们首先需要掌握原型链的概念。
原型链机制解析
原型链
已知所有原型都包含一个指向构造函数的指针。如果我们将SubClass的原型对象设置为另一个类型的实例(即new SuperClass()),会发生什么?此时,SubClass的原型对象将包含一个指向SuperClass原型的指针,而SuperClass原型中也包含指向SuperClass构造函数的指针……如此层层递进,便构成了原型链。

具体代码如下:
function SuperClass(){
this.name = "women"
}
SuperClass.prototype.sayWhat = function(){
return this.name + ":i`m a girl!";
}
function SubClass(){
this.subname = "your sister";
}
SubClass.prototype = new SuperClass();
SubClass.prototype.subSayWhat = function(){
return this.subname + ":i`m a beautiful girl";
}
var sub = new SubClass();
console.log(sub.sayWhat());//women:i`m a girl!
原型链继承的局限
使用原型链实现继承
从上述代码可以看出,SubClass继承了SuperClass的属性和方法。该继承机制通过将SuperClass的实例赋值给SubClass的原型对象来实现,这使得SubClass的原型对象被SuperClass的一个实例所覆盖,从而获得了其全部属性和方法,并保留了指向SuperClass原型对象的指针。
在使用原型链实现继承时,需注意以下几点:
注意继承后constructor属性的变化。此处sub的constructor指向SuperClass,因为SubClass的原型指向了SuperClass的原型。在理解原型链时,不可忽视末端默认的Object对象,这也是所有对象都能使用toString等内置方法的原因。
通过原型链实现继承时,禁止使用字面量方式定义原型方法,因为这会重写原型对象(前文已提及):
function SuperClass(){
this.name = "women"
}
SuperClass.prototype.sayWhat = function(){
return this.name + ":i`m a girl!";
}
function SubClass(){
this.subname = "your sister";
}
SubClass.prototype = new SuperClass();
SubClass.prototype = {//此处原型对象被覆盖,因为无法继承SuperClass属性和方法
subSayWhat:function(){
return this.subname + ":i`m a beautiful girl";
}
}
var sub = new SubClass();
console.log(sub.sayWhat());//TypeError: undefined is not a function
实例共享问题。在前文讲解原型和构造函数时,曾介绍过包含引用类型属性的原型会被所有实例共享。同样,通过继承获得的原型也会共享“父类”原型中的引用类型属性。当我们通过原型继承修改了“父类”的引用类型属性后,所有继承自该原型的实例都会受到影响,这不仅浪费资源,也是我们不希望看到的现象:
function SuperClass(){
this.name = "women";
this.bra = ["a","b"];
}
function SubClass(){
this.subname = "your sister";
}
SubClass.prototype = new SuperClass();
var sub1 = new SubClass();
sub1.name = "man";
sub1.bra.push("c");
console.log(sub1.name);//man
console.log(sub1.bra);//["a","b","c"]
var sub2 = new SubClass();
console.log(sub1.name);//woman
console.log(sub2.bra);//["a","b","c"]
注意:此处向数组中添加一个元素,所有继承自SuperClass的实例都会受到影响;但如果修改name属性,则不会影响其他实例,这是因为数组属于引用类型,而name属于基本类型。 如何解决实例共享的问题?我们继续往下看……
经典继承与构造函数借用
经典继承(constructor stealing)
正如前文所述,很少单独使用原型来定义对象;在实际开发中,我们也很少单独使用原型链。为了解决引用类型的共享问题,JavaScript开发者引入了经典继承模式(亦称借用构造函数继承)。其实现很简单:在子类型构造函数中调用超类型的构造函数。我们需要借助JavaScript提供的call()或apply()函数,示例如下:
function SuperClass() {
this.name = "women";
this.bra = ["a", "b"];
}
function SubClass() {
this.subname = "your sister";
//将SuperClass的作用域赋予当前构造函数,实现继承
SuperClass.call(this);
}
var sub1 = new SubClass();
sub1.bra.push("c");
console.log(sub1.bra);//["a","b","c"]
var sub2 = new SubClass();
console.log(sub2.bra);//["a","b"]
SuperClass.call(this); 这句话的意思是在SubClass的实例(上下文)环境中调用了SuperClass构造函数的初始化工作,这样每个实例都会拥有自己的一份bra属性副本,互不干扰。 然而,这种实现方式仍非完美。既然引入了构造函数,我们就面临前文提到的构造函数存在的问题:如果在构造函数中定义方法,那么每个实例都会拥有一份单独的Function引用。我们的目的是共用该方法,而且我们在超类型原型中定义的方法,在子类型实例中无法调用:
function SuperClass() {
this.name = "women";
this.bra = ["a", "b"];
}
SuperClass.prototype.sayWhat = function(){
console.log("hello");
}
function SubClass() {
this.subname = "your sister";
SuperClass.call(this);
}
var sub1 = new SubClass();
console.log(sub1.sayWhat());//TypeError: undefined is not a function
如果你阅读过前文关于原型对象和构造函数的内容,想必你已经知道解决这个问题的答案了,那就是沿用前文的套路,使用“组合拳”!
组合式继承方案
组合式继承
组合式继承结合了原型链和构造函数的优势,发挥各自特长,实现继承的一种方式。简单来说,就是使用原型链继承属性和方法,使用借用构造函数来实现实例属性的继承。这样既解决了实例属性共享的问题,也让超类型的属性和方法得到继承:
function SuperClass() {
this.name = "women";
this.bra = ["a", "b"];
}
SuperClass.prototype.sayWhat = function(){
console.log("hello");
}
function SubClass() {
this.subname = "your sister";
SuperClass.call(this); //第二次调用SuperClass
}
SubClass.prototype = new SuperClass(); //第一次调用SuperClass
var sub1 = new SubClass();
console.log(sub1.sayWhat());//hello
组合继承是实际开发中最常用的继承实现方式,至此已能满足实际开发需求。然而,人类对完美的追求永无止境,必然有人对该模式“吹毛求疵”:你这个模式调用了两次超类型的构造函数耶!两次耶……你造吗,这放大一百倍是多大的性能损失吗? 最有力的反驳莫过于拿出解决方案。好在开发者找到了解决这个问题的最优方案:
寄生组合式继承:最优解
寄生组合式继承
在介绍这种继承方式前,我们先了解寄生构造函数的概念。寄生构造函数类似于前文提到的工厂模式,其思想是定义一个公共函数,专门用于处理对象的创建,创建完成后返回该对象。这个函数很像构造函数,但构造函数是没有返回值的:
function Gf(name,bra){
var obj = new Object();
obj.name = name;
obj.bra = bra;
obj.sayWhat = function(){
console.log(this.name);
}
return obj;
}
var gf1 = new Gf("bingbing","c++");
console.log(gf1.sayWhat());//bingbing
寄生式继承的实现与寄生式构造函数类似,创建一个不依赖于具体类型的“工厂”函数,专门处理对象的继承过程,然后返回继承后的对象实例。幸运的是,这不需要我们自己实现,道哥(道格拉斯)早已为我们提供了一种实现方式:
function object(obj) {
function F() {}
F.prototype = obj;
return new F();
}
var superClass = {
name:"bingbing",
bra:"c++"
}
var subClass = object(superClass);
console.log(subClass.name);//bingbing
在公共函数中提供了一个简单的构造函数,然后将传入对象的实例赋予构造函数的原型对象,最后返回该构造函数的实例。很简单,但疗效很好,不是吗?这种方式被后人称为“原型式继承”,而寄生式继承正是在原型式基础上,通过增强对象的自定义属性实现的:
function buildObj(obj){
var o = object(obj);
o.sayWhat = function(){
console.log("hello");
}
return o;
}
var superClass = {
name:"bingbing",
bra:"c++"
}
var gf = buildObj(superClass);
gf.sayWhat();//hello
寄生式继承方式同样面临着原型中函数复用的问题,于是,人们又开始拼起了积木,诞生了——寄生组合式继承,目的是解决在指定子类型原型时调用父类型构造函数的问题,同时,达到函数的最大化复用。基于以上基础实现方式如下:
//参数为两个构造函数
function inheritObj(sub,sup){
//实现实例继承,获取超类型的一个副本
var proto = object(sup.prototype);
//重新指定proto实例的constructor属性
proto.constructor = sub;
//将创建的对象赋值给子类型的原型
sub.prototype = proto;
}
function SuperClass() {
this.name = "women";
this.bra = ["a", "b"];
}
SuperClass.prototype.sayWhat = function() {
console.log("hello");
}
function SubClass() {
this.subname = "your sister";
SuperClass.call(this);
}
inheritObj(SubClass,SuperClass);
var sub1 = new SubClass();
console.log(sub1.sayWhat()); //hello
该实现方式避免了超类型的两次调用,同时也省掉了SubClass.prototype上不必要的属性,同时还保持了原型链。到此,继承之旅真正结束,这种实现方式成为了最理想的继承实现方式!人们对JavaScript的继承争议仍在继续,有人提倡OO,有人反对在JavaScript中做多余的努力去实现OO的特性。管他呢,至少我们又深入了解了些!