原文连接:JavaScript中的几种继承方式及优缺点,你知道多少呢?
继承也是前端里面的重要的一个知识点,在实际工作中或者面试中也会经常的遇到,那么通过这篇文章我们详细的了解一下继承的几种方式以及各种继承方式的优缺点!
在《JavaScript高级程序设计》一书中主要介绍了6种继承方式以及他们的优缺点,这6种分别为原型链继承、借用构造函数继承、组合继承、原型式继承、寄生式继承以及寄生组合式继承。本文除了介绍这6种继承方式外,还会介绍ES6中的class类继承。
原型链的基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。假如我们让原型对象等于另一个类型的实例,此时的原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数的指针。假如另一个原型又是另一个类型的实例,那么上述的关系依然成立,如此层层递进,就构成了实例与原型的链条。
代码如下:
function Parent() {
this.name = "疾风剑豪";
}
Parent.prototype.getParentName = function() {
console.log(this.name)
}
function Child() {
this.age = 18;
}
// 重点来啦!!!
// 继承了Parent
// 我们将Child的prototype对象指向一个Parent的实例。
// 它相当于完全删除了prototype 对象原先的值,然后赋予一个新值。
Child.prototype = new Parent();
console.log(Child.prototype.constructor == Parent);
//true
Child.prototype.getChildName = function() {
console.log(this.name)
}
Child.prototype.constructor = Child;
var child1 = new Child();
console.log(`名字:${child1.name},年龄:${child1.age}`);
// 名字:疾风剑豪,年龄:18
child1.getParentName();
// 疾风剑豪
child1.getChildName();
// 疾风剑豪
以上代码定义了两个类型:Parent和Child。每个类型分别有一个属性和方法。它们主要的区别是Child继承了Parent,而继承是通过创建Parent的实例,并将该实例赋给Child.prototype实现的。实现的本质是重写原型对象,代之以一个新类型的实例。换句话说,原来存在于Parent的实例中的所有属性和方法,现在也存在于Child.prototype中了。在确立了继承关系之后,我们给Child.prototype添加了一个方法,这样就在继承了Parent的属性和方法的基础上又添加了一个新方法。
任何一个prototype对象都有一个constructor属性指向它的构造函数。如果没有Child.prototype = new Parent()这一行,Child.prototype.constructor 是指向 Child 的,加了这一行以后,Child.prototype.constructor指向Parent。
更重要的是,每一个实例也有一个constructor属性,默认调用prototype对象的constructor 属性。console.log(child1.constructor == Child.prototype.constructor)输出为 true。因此,在运行 Child.prototype = new Parent(); 这一行之后,child1.constructor 也指向 Parent!这显然会导致继承链的紊乱(child1明明是用构造函数Child生成的),因此我们必须手动纠正,将Child.prototype 对象的constructor 值改为Child。这就是Child.prototype.constructor = Child 的意思。
注意:
1、子类型有时候需要覆盖父类型中的某个方法,或者需要添加父类型中不存在的某个方法。但不管怎样,给原型添加方法的代码一定要放在替换原型的语句之后。
看下面代码:
function Parent() {
this.name = "疾风剑豪";
}
Parent.prototype.getParentName = function() {
console.log(this.name)
}
function Child() {
this.age = 18;
}
// 继承了Parent
Child.prototype = new Parent();
// 给Child添加一个新方法
Child.prototype.getChildName = function() {
console.log(this.name)
}
// 重写父类型中的方法
Child.prototype.getParentName = function() {
console.log(this.age)
}
var child1 = new Child();
child1.getParentName();
// 18
var parent1 = new Parent();
parent1.getParentName();
// 疾风剑豪
我们看到,第一个方法getChildName()被添加到了Child中,第二个方法getParentName()是原型中已存在的一个方法,但重写这个方法将会屏蔽原来的那个方法。换句话说,当通过Child实例调用getParentName()时,调用的就是这个重新定义的方法但是通过Parent的实例调用getParentName()时,还会继续调用原来的那个方法。因此,这里需要格外的注意,必须在用Parent的实例替换原型之后,在定义这两个方法。
2、在通过原型链实现继承时,不能使用对象字面量创建原型方法。因为这样会重写原型链。
看下面代码:
function Parent() {
this.name = "疾风剑豪";
}
Parent.prototype.getParentName = function() {
console.log(this.name)
}
function Child() {
this.age = 18;
}
// 继承了Parent
Child.prototype = new Parent();
// 使用对象字面量添加新方法,会导致上一行代码无效
Child.prototype = {
getChildName: function() {
console.log(this.name)
},
getChildAge: function() {
console.log(this.age)
}
}
var child1 = new Child();
child1.getParentName();
// Uncaught TypeError: child1.getParentName is not a function
我们看到,把Parent的实例赋值给原型,紧接着又将原型替换成一个对象字面量,由于现在的原型包含的是一个Object的实例,而非Parent的实例,因此,我们设想中的原型链已经被切断,Parent和Child之间已经没有任何关系了。
原型链的缺点
虽然原型链很强大,可以用它来实现继承,但它也存在一些问题。
1、每个实例对引用类型属性的修改都会被其它的实例共享。
看如下代码:
function Parent() {
this.colors = ['red', 'yellow', 'blue'];
}
function Child() {
}
// 继承了Parent
Child.prototype = new Parent();
var child1 = new Child();
child1.colors.push('green');
console.log(child1.colors);
// ['red', 'yellow', 'blue', 'green']
var child2 = new Child();
console.log(child2.colors);
// ['red', 'yellow', 'blue', 'green']
上面例子中的Parent构造函数定义了一个colors属性,该属性包含一个数组(引用类型值)。Parent的每个实例都会有各自包含自己数组的colors属性。当Child通过原型链继承了Parent之后,Child.prototype就变成了Parent的一个实例,因此它也拥有了一个它自己的colors属性,Child的所有实例都会共享这一个colors属性,通过上面例子childs.colors也反映出来了。
2、在创建子类型的实例时,不能向父类型的构造函数中传递参数。或者说,是没办法在不影响所有对象实例的情况下,给父类型的构造函数传递参数。
借用构造函数的基本思想就是在子类型构造函数的内部调用父类型构造函数。函数只不过是在特定环境中执行代码的对象,因此通过使用apply()和call()方法也可以在新创建的对象上执行构造函数。
看如下代码:
function Color() {
this.colors = ['red', 'yellow', 'green'];
}
function Blue() {
// 继承了Color
Color.call(this);
}
var blue1 = new Blue();
blue1.colors.push('blue');
console.log(blue1.colors);
// ['red', 'yellow', 'green', 'blue']
var blue2 = new Blue();
blue2.colors.push('black');
console.log(blue2.colors);
// ['red', 'yellow', 'green', 'black']
上面代码中Color.call(this)这一行代码“借调”了父类型的构造函数。通过使用call()(或apply()也可以是实现),我们实际是在新建的构造函数Blue实例的环境下调用了Color构造函数。Blue的每一个实例就都会具有自己的colors属性的副本了。
优点:
1、借用构造函数可以在子类型构造函数中向父类型构造函数传递参数。
看如下代码:
function Fruit(name, price) {
this.name = name;
this.price = price;
}
function Banana(name, price) {
// 继承了Fruit,同时还传递了参数
Fruit.call(this, name, price);
// 实例属性
this.sort = '芭蕉科';
}
var fruit1 = new Banana('香蕉', 8);
console.log(`${fruit1.name}属于${fruit1.sort},价格是${fruit1.price}元`);
// 香蕉属于芭蕉科,价格是8元
上面代码Fruit接收两个参数name和price,这两个参数分别赋值给两个属性。在Banana构造函数内部调用Fruit构造函数时,实际上给Banana的实例设置了name和price属性。为了确保Fruit构造函数不会重写子类型的属性,可以在调用父类型构造函数后,再在子类型中添加定义的属性或方法。
2、解决了每个实例对引用类型属性的修改都会被其他的实例共享的问题。
我们可以看到,在Color构造函数中定义的colors属性值为[‘red’, ‘yellow’, ‘green’],在child1实例中添加了blue元素,在child2实例中添加了black元素,child1实例添加的元素并未对child2实例产生影响。解决了每个实例对引用类型属性的修改都会被其他的实例共享的问题。
缺点:
1、借用构造函数也无法避免构造函数模式存在的问题,方法都在构造函数中定义,无法实现函数复用。每个实例都会拷贝一份,所以每次创建一个实例也就会重新生成一个方法,方法也是一个对象,相当于实例化了一个对象,这样会造成不必要的内存开销。
2、在父类型的原型上定义的方法,对于子类型是不能继承的,结果所有类型都只能使用构造函数模式。
看下面代码:
// 父类
function Person(name) {
this.name = name;
this.run = function() {
console.log('跑步可以锻炼身体');
}
}
// 父类型的原型中定义的方法
Person.prototype.study = function() {
console.log('好好学习,天天向上');
}
// 子类
function Student(name, score) {
Person.call(this, name);
this.score = score;
}
// 实例1
var student1 = new Student('小明', 99);
// 实例2
var student2 = new Student('小红', 98);
// 缺点1:说明student1和student2的run方法独立,不是共享的,方法不能复用
console.log(student1.run === student2.run)
// false
// 缺点2:说明父类型的原型中定义的方法,对子类型是不可见的
student1.study();
// 报错:Uncaught TypeError: student1.study is not a function
组合继承指的是将原型链和借用构造函数组合到一起使用,结合了两者的优点和长处的一种继承模式。其基本思想是使用原型链实现对原型属性和方法的继承,在通过借用构造函数实现对实例属性的继承。既通过在原型上定义方法实现了函数的复用,又可以保证每个实例都有它自己的属性。
看下面代码:
function Person(name) {
this.name = name;
this.colors = ['red', 'yellow', 'green'];
}
Person.prototype.getName = function() {
console.log(this.name)
}
function Student(name, score) {
// 继承属性
Person.call(this, name);
this.score = score;
}
// 继承方法
Student.prototype = new Person();
// 在运行Student.prototype = new Person();之后,student1.constructor也指向Person!
// 这显然会导致继承链的紊乱,student1明明是用构造函数Student生成的,
// 因此我们必须手动纠正,将Student.prototype对象的constructor值改为Student。
Student.prototype.constructor = Student;
Student.prototype.study = function() {
console.log(this.score)
}
var student1 = new Student('疾风剑豪', 10);
student1.colors.push('blue');
console.log(student1.colors);
// ['red', 'yellow', 'green', 'blue']
student1.getName(); // 疾风剑豪
student1.study(); // 12
var student2 = new Student('熔岩巨兽', 12);
student2.colors.push('pink');
console.log(student2.colors);
// ['red', 'yellow', 'green', 'pink']
student2.getName(); // 熔岩巨兽
student2.study(); // 12
如上代码,Person构造函数中定义了两个属性分别为name和colors,Person的原型中定义了一个名为getName()的方法。Student构造函数在调用Person构造函数时传入了name参数,接着又定义了一个自己的score属性。然后,将Person的实例赋值给了Student的原型,最后又在Student的原型上定义了一个study()方法。这样就可以让每一个Student的实例既拥有了自己的属性score和继承的colors属性,又可以使用相同的方法。
组合继承解决了原型链和借用构函数继承的缺陷,结合了两者的优点,成为了js中常用的继承模式。
缺点:
1、当然,组合继承也并不是完美的,其最大的问题就是无论什么情况下,都会调用两次父类型构造函数,第一次是创建子类型原型的时候,即Student.prototype
= new Person(),第二次是在子类型构造函数的内部,即Person.call(this, name)。因此,子类型最终会包含父类型对象的全部实例属性,我们不得不在调用子类型构造函数时重写这些属性。
原型式继承是由道格拉斯·柯珞克德福于2006在《JavaScript中的原型式继承》一文中提出的。他的思想是借助原型可以基于已有的对象创建新对象,同时还不必因此创建自定义类型。为达到目的,他给出了如下函数。
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
在object内部先创建一个临时性的F构造函数,然后将传入的对象赋值给这个构造函数的原型,最后返回了这个临时类型的一个新实例。本质上就是对传入其中的对象进行了一次浅拷贝。
原型式继承要求你必须有一个对象可以作为另一个对象的基础。有这样的一个对象可以把它传递给object()函数,然后再根据具体的需求对得到的对象加以修改即可。
ECMAScript5通过新增Object.create()方法规范化了原型式继承。此方法接收两个参数,分别是用作新对象原型的对象和(此参数可选)一个为新对象定义额外属性的对象。
Object.create()在传入一个参数时,和object()方法行为相同。
var summoner = {
name: '提莫',
friends: ['菲兹', '崔丝塔娜']
}
var summoner1 = Object.create(summoner);
summoner1.name = '兰博';
summoner1.friends.push('库奇');
var summoner2 = Object.create(summoner);
summoner2.name = '璐璐';
summoner2.friends.push('波比');
console.log(summoner.name);
// 提莫
console.log(summoner.friends);
// ['菲兹', '崔丝塔娜', '库奇', '波比']
Object.create()在传入两个参数时,第二个参数和Object.defineProperties()方法的第二个参数格式相同,该传入对象的自有可枚举属性(即其自身定义的属性,而不是其原型链上的枚举属性)将为新创建的对象添加指定的属性值和对应的属性描述符。以这种方式指定的任何属性都会覆盖原型对象上的同名属性。
var summoner = {
name: '提莫',
friends: ['菲兹', '崔丝塔娜']
}
var summoner1 = Object.create(summoner, {
name: {
value: '璐璐'
}
})
console.log(summoner1.name);
// 璐璐
缺点:
1、包含引用类型值的属性始终都会共享相应的值,和使用原型链继承模式一样。
寄生式继承是和原型式继承紧密相关的一种思路,也是由上文中的原型式继承的提出者推广的一种继承模式。它的思路是创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后在返回增强之后的对象。
function createAnother(original) {
// 通过调用函数创建一个新对象
var clone = object(original);
// 以某种方式来增强这个对象
clone.play = function() {
console.log('快乐玩耍!')
};
// 返回这个对象
return clone;
}
var person = {
name: '提莫',
friends: ['璐璐', '纳尔', '凯南']
}
var person1 = createAnother(person);
person1.play();
// 快乐玩耍!
createAnother()函数接受了一个参数,将这个参数作为新对象基础的对象。然后把这个参数传递给object()函数,将返回的结果赋值给clone。再为clone对象添加一个play()新方法,最后返回这个增强后的对象。
基于person返回的新对象person1不仅具有了person的所有属性和方法,而且还有了自己的play()方法。
前面所提到的object()函数并不是必需的,只要任何能返回形象的函数都适用于此继承模式。
缺点:
1、和借用构造函数模式类似,寄生式继承为对象添加函数,做不到函数的复用而降低了效率。
寄生组合式继承通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。基本思路是,不必为了指定子类型的原型而调用父类型的构造函数,我们需要是父类型原型的一个副本。本质上,就是使用寄生式继承来继承父类型的原型,然后再将结果指定给子类型的原型。
function inheritPrototype(Child, Parent) {
// 创建父类型原型的一个副本,把副本赋值给子类型的原型
// 创建对象
var prototype = object(Parent.prototype);
// 增强对象
prototype.constructor = Child;
// 指定对象
Child.prototype = prototype;
}
上面的inheritPrototype()函数实现了寄生组合式继承的最简单形式。此函数接收两个参数:子类型构造函数和父类型构造函数。在函数内部,先创建一个父类型原型的副本,然后为创建的副本添加constructor属性,弥补因为重新原型而失去默认的constructor属性。最后,将新创建的对象赋值给子类型的原型。这样,我们就可以调用inheritPrototype()函数的语句,去替换前面例子中为子类型原型复制的语句了。
看如下代码:
function Person(name) {
this.name = name;
this.friends = ['小明', '小红', '小芳'];
}
Person.prototype.getName = function() {
console.log(this.name)
}
function Student(name, score) {
Person.call(this, name);
this.score = score;
}
inheritPrototype(Student, Person);
Student.prototype.getScore = function() {
console.log(this.score)
}
上面代码的高效率是因为它只调用了一次Person父类型构造函数,并且能够避免在Student.prototype上面创建不必要多余的属性,同时还能保持原型链不变。
JavaScript主要通过原型链实现继承。原型链的构建是通过将一个类型的实例赋值给另一个构造函数的原型实现的。这样,子类型就可以访问父类型的所有属性和方法。原型链的问题是对象实例共享所有继承的属性和方法,因此不适合单独使用,解决这个问题是借用构造函数,即在子类型构造函数的内部调用父类型构造函数,这样就可以实现每个实例都私有的属性,同时还能保证只使用构造函数模式来定义类型。
gongzhonghao:前端胡说
原文连接:JavaScript中的几种继承方式及优缺点,你知道多少呢?