Javascript中实现继承的几种方式
在ES6到来之前,JS中一直没有类的概念。而类本身又是一种非常符合人类思考方式的抽象模型,所以虽然JS不支持类,但依然可以使用功能强大的原型机制来模拟类的各种行为。经过多年实践总结,开发者们逐渐形成了一些利用原型链实现类和继承的方式,通过比较这些实现方式的优缺点,我们也能更好地理解原型链机制。正是因为这些实践经验,才孕育出了今天给我们带来无限便利的ES6中类的机制。接下来让我们分析一下各种实现方式的特点吧!
朴素的原型链继承
function SuperType() {
this.name = "Han";
this.friends = ["A", "B"];
}
SuperType.prototype.getName = function() {
return this.name;
};
function SubType() {
this.age = 18;
}
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.getAge = function() {
return this.age;
};
让我们声明两个SubType
的实例试一下
var p1 = new SubType();
var p2 = new SubType();
p1.getName(); // -> 'Han'
p1.getName(); // -> 'Han'
p1.getAge(); // -> 18
p1.name = "Xiaohan";
p1.getName(); // -> 'Xiaohan'
p2.getName(); // -> 'Han'
看起来是符合预期的对吧,子类继承了父类上定义的属性和方法,并且修改子类实例的属性时,不同实例间不会发生干扰。真的是这样吗?
p1.friends.push("C");
p2.friends; // -> ['A', 'B', 'C']
等等,改了p1.friends
之后,为什么另一个实例p2
也被影响了?刚刚改p1.name
的时候明明是正常的!让我们冷静一下,回头看看最开始继承的实现。由于SubType.prototype = new SuperType()
,父类的属性实际上是声明在子类的原型(即__proto__
,以下统一称为原型)上的,因此p1.name
实际上是p1.__proto__.name
,所以父类属性实际上被所有子类实例共享。我们知道操作对象属性时,内部操作[[Get]]
和[[Set]]
的逻辑时不一样的,[[Get]]
会沿着原型链向上查找,而[[Set]]
则不会,这就会造成属性遮蔽现象。因此当我们执行p1.name = 'Xiaohan'
时,触发[[Set]]
操作,此时name
属性就被定义在p1
上了,之后在取该属性的值时,由于p1
上已经有该属性了,因此不会继续沿原型链向上查找,所以赋值行为在不同子类实例间看起来是正常的。如果不是赋值操作,那可就要小心了,比如p1.friends.push('C')
,这并不是一个赋值操作,而且直接操作了p1.friends
属性,按照我们刚刚说的,本质上操作的是p1.__proto__.friends
,而这个属性是被所有实例共享的!所以很自然的,p2.friends
也被影响了。
所以,总结下原型链继承的问题:
- 父类属性会被所有子类实例共享,如果操作不慎(比如上面例子中的对数组进行
push
操作等),会影响所有实例,造成诡异的问题 - 父类属性在声明时就被写死了,不能由子类定义,使用上不够灵活
经典继承(借用构造函数)
function SuperType(name) {
this.name = name;
this.friends = ["A", "B"];
this.getName = function() {
return this.name;
};
}
function SubType() {
SuperType.call(this, "Han");
this.age = 18;
this.getAge = function() {
return this.age;
};
}
在经典继承中,子类借用了父类的构造函数,将父类声明的属性和方法通过“借用”的方式声明在自己身上,从而既达到了继承的目的,又避免了原型链继承带来的子类实例共享父类属性的问题。但通过观察该实现,我们不难发现父类的所属性和方法在每个子类实例上都有一份儿,虽然实例间不会相互影响了,但本该得到复用的父类和子类方法,并没有真正被复用,造成了极大的资源浪费。
组合继承(伪经典继承)
function SuperType(name) {
this.name = name;
this.friends = ["A", "B"];
}
SuperType.prototype.getName = function() {
return this.name;
};
function SubType() {
SuperType.call(this, "Han");
this.age = 18;
}
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.getAge = function() {
return this.age;
};
顾名思义,组合继承结合了原型链继承和经典继承的做法,既能复用父类和子类方法,又能通过借用构造函数的方式,将父类属性定义在每个子类实例上,达到不相互干扰的目的。那么组合继承可以称得上完美的继承实现了吗?
并不是,让我们对照实现代码仔细分析一下。子类内部SuperType.call(this, "Han")
和SubType.prototype = new SuperType()
造成的结果是父类构造函数被调用了两次,该操作造成的结果是父类属性同时存在于子类实例和子类实例的原型上!因为上面提到的属性遮蔽现象,所以这种冗余对于开发者来说是感知不到的,但冗余毕竟是事实存在的,虽然不影响结果,但依然是一种资源浪费。
原型式继承
var person = {
name: "Han",
getName: function() {
return this.name;
}
};
var p1 = Object.create(person);
p1.name = "Xiaohan";
p1.getName(); // -> Xiaohan
原型式继承由知名大神Douglas Crockford提出,借助Object.create
一脚踢开Function,直接借用原型链实现对象与对象之间的继承关系。这种继承的实现方法,思路简单直接,不会带来new来new去
引起的各种“坑”,但由于出现比较晚,所以用的人相对来说也没那么多。YDKJS的作者Kyle Simpson倒是对这种模式赞赏有加,在其书中用了很大篇幅介绍它的优点和灵活性。
寄生式继承
function createAnotherPerson(original) {
var clone = Object.create(original);
clone.sayName = function() {
console.log("name is", this.name);
};
return clone;
}
var person = { name: "Han" };
var han = createAnotherPerson(person);
han.name = "xiaohan";
han.sayName();
寄生式继承可以理解为位于原型式继承的扩展,它使用了工厂函数来创建实例,并为每个实例添加方法,增加了方法的可复用性。
组合寄生式继承
function inheritPrototype(subType, superType) {
subType.prototype = Object.create(superType.prototype);
subType.prototype.constructor = subType;
}
function SuperType(name) {
this.name = name;
this.friends = ["A", "B"];
}
SuperType.prototype.getName = function() {
return this.name;
};
function SubType() {
SuperType.call(this, "Han");
this.age = 18;
}
inheritPrototype(SubType, SuperType);
SubType.prototype.constructor = SubType;
SubType.prototype.getAge = function() {
return this.age;
};
在初看《红宝书》时,对继承这一块儿的内容安排很困惑,前面都是在用函数实现继承,怎么突然插入了原型式继承和寄生式继承呢?看到组合寄生式继承时,我释然了。在Object.create
出现之前,开发者们一直使用new
来构建原型链,这多多少少有些晦涩难懂,而Object.create
简单直接的逻辑,无疑大大简化了构建原型链的难度,那能不能用Object.create
来解决一下上面提到的几种继承实现的问题呢?
答案是肯定的,让我们的目光回到组合继承。我们提到组合继承的缺点是父类构造函数被调用两次,造成了势必被遮蔽的父类属性出现在了子类实例的原型链上,这是一种资源浪费。那有没有办法避免这种资源浪费呢?我们既要构造SubType.prototype
和SuperType.prototype
的原型链关系,又要避免SuperType
的冗余调用,防止资源浪费。显然,这时候该Object.create
登场了。观察上面的代码,inheritPrototype
就达到了我们的目的:既构造了原型链关系,又避免了这个过程中SuperType
的调用。至此,继承的实现算是达到了比较完善的程度。
我们知道,ES6的类只是语法糖,其背后依然是基于原型链实现的,其实现方式和组合寄生式继承大致相同,但又有细微差别,未来有时间会总结下ES6的类是如何实现的。