先看以下百科对(面向对象继承)的解释!
通过以上精炼实用的解释,我们可以了解到继承的基本作用和功能!即可以使得子类具有父类的属性和方法或者重新定义、追加属性和方法等。
那么,我们带着以下问题来一探JS的继承概念:
JS的继承到底有多少中实现方式呢?
JS实现继承的几种方式
第一种:原型链继承
原型链继承是比较常见的继承方式之一,其中涉及的构造函数、原型和实例,三者之间存在着一定的关系,即每一个构造函数都有一个原型对象,原型对象又包含一个指向构造函数的指针,而实例则包含一个原型对象的指针。
优点:写法方便简洁,容易理解。
缺点:对象实例共享所有继承的属性和方法。传教子类型实例的时候,不能传递参数,因为这个对象是一次性创建的(没办法定制化)。
上代码:
function Parent1() {
this.name = 'parent1'
this.play = [1, 2, 3]
}
function Child1() {
this.type = 'child2'
}
Child1.prototype = new Parent1()
console.log(new Child1())
上面的代码看似没有问题,虽然父类的方法和属性都能够访问,但其实有一个潜在的问题,我再举个例子来说明这个问题。
var s1 = new Child2()
var s2 = new Child2()
s1.play.push(4)
console.log(s1.play,s2.play)
明明我只改变了 s1 的 play 属性,为什么 s2 也跟着变了呢?原因很简单,因为两个实例使用的是同一个原型对象。它们的内存空间是共享的,当一个发生变化的时候,另外一个也随之进行了变化,这就是使用原型链继承方式的一个缺点。
那么要解决这个问题的话,我们就得再看看其他的继承方式,下面我们看看能解决原型属性共享问题的第二种方法。
第二种:构造函数继承(借助 call)
function Parent1() {
this.name = 'parent1'
}
Parent1.prototype.getName = function () {
return this.name
}
function Child() {
Parent1.call(this)
this.type = 'child1'
}
let child = new Child1()
console.log(child) // 没问题
console.log(child.getName()) // 会报错
执行上面的这段代码,可以得到这样的结果。
可以看到最后打印的 child 在控制台显示,除了 Child1 的属性 type 之外,也继承了 Parent1 的属性 name。这样写的时候子类虽然能够拿到父类的属性值,解决了第一种继承方式的弊端,但问题是,父类原型对象中一旦存在父类之前自己定义的方法,那么子类将无法继承这些方法。这种情况的控制台执行结果会报错。
因此,从上面的结果就可以看到构造函数实现继承的优缺点。
优点:解决了原型链实现继承的不能传参的问题和父类的原型共享的问题。
缺点:借用构造函数的缺点是方法都在构造函数中定义,因此无法实现函数复用。在父类型的原型中定义的方法,对子类型而言也是不可见的,结果所有类型都只能使用构造函数模式。
上面的两种继承方式各有优缺点,那么结合二者的优点,于是就产生了下面这种组合的继承方式。
第三种:组合继承(前两种组合)
优点: 解决了原型链继承和借用构造函数继承造成的影响。
缺点: 无论在什么情况下,都会调用两次超类型构造函数:一次是在创建子类型原型的时候,另一次是在子类型构造函数内部
这种方式结合了前两种继承方式的优缺点,结合起来的继承,代码如下。
function Parent3() {
this.name = 'Parent3'
this.play = [1,2,3]
}
Parent3.prototype.getName = function () {
return this.name
}
function Child3() {
// 第二次调用Parent3()
Parent3.call(this)
this.type = 'child3'
}
// 第一次调用Parent()
Child3.prototype = new Parent3()
// 手动挂上构造器,指向自己的构造函数
Child3.prototype.constructor = Child3
var s3 = new Child3()
var s4 = new Child3()
s3.play.push(4)
console.log(s3.play,s4.play); // 不互相影响
console.log(s3.getName()); // 正常输出'parent3'
console.log(s4.getName()); // 正常输出'parent3'
执行上面的代码,可以看到控制台的输出结果,之前方法一和方法二的问题都得以解决。
但是这里又增加了一个新问题:通过注释我们可以看到 Parent3 执行了两次,第一次是改变Child3 的 prototype 的时候,第二次是通过 call 方法调用 Parent3 的时候,那么 Parent3 多构造一次就多进行了一次性能开销,这是我们不愿看到的。
那么是否有更好的办法解决这个问题呢?请你再往下学习,下面的第六种继承方式可以更好地解决这里的问题。
第四种:原型式继承
这里不得不提到的就是 ES5 里面的 Object.create 方法,这个方法接收两个参数:一是用作新对象原型的对象、二是为新对象定义额外属性的对象(可选参数)。
方法一:
借用构造函数在一个函数A内部创建一个临时性的构造函数,然后将传入的对象作为这个构造函数的原型,最后返回这个临时类型的一个新实例。
本质上,函数A是对传入的对象执行了一次浅复制。
function createObject(obj) {
function Fun() {}
Fun.prototype = obj
return new Fun()
}
let person = {
name: 'mjy',
age: 18,
hoby: ['唱', '跳'],
showName() {
console.log('my name is:', this.name)
}
}
let child1 = createObject(person)
child1.name = 'xxxy'
child1.hoby.push('rap')
let child2 = createObject(person)
console.log(child1)
console.log(child2)
console.log(person.hoby) // ['唱', '跳', 'rap']
方法二:
Object.create() 是把现有对象的属性,挂到新建对象的原型上,新建对象为空对象
ECMAScript 5通过增加Object.create()方法将原型式继承的概念规范化了。这个方法接收两个参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个可选)。在只有一个参数时,Object.create()与这里的函数A方法效果相同。
let person = {
name: 'mjy',
age: 19,
hoby: ['唱', '跳'],
showName() {
console.log('my name is: ', this.name)
}
}
let child1 = Object.create(person)
child1.name = 'xxt'
child1.hoby.push('rap')
let child2 = Object.create(person)
console.log(child1)
console.log(child2)
console.log(person.hoby) // ['唱', '跳', 'rap']
那么关于这种继承方式的缺点也很明显
优点是:不需要单独创建构造函数。
缺点是:属性中包含的引用值始终会在相关对象间共享,子类实例不能向父类传参
接下来我们看一下在这个继承基础上进行优化之后的另一种继承方式——寄生式继承。
第五种:寄生式继承
使用原型式继承可以获得一份目标对象的浅拷贝,然后利用这个浅拷贝的能力再进行增强,添加一些方法,这样的继承方式就叫作寄生式继承。
寄生式继承的思路与(寄生) 原型式继承 和 工厂模式 似, 即创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后再像真的是它做了所有工作一样返回对象。
虽然其优缺点和原型式继承一样,但是对于普通对象的继承方式来说,寄生式继承相比于原型式继承,还是在父类基础上添加了更多的方法。那么我们看一下代码是怎么实现。
function objectCopy(obj) {
function Fun() { };
Fun.prototype = obj;
return new Fun();
}
function createAnother(obj) {
let clone = objectCopy(obj);
clone.showName = function () {
console.log('my name is:', this.name);
};
return clone;
}
let person = {
name: "mjy",
age: 18,
hoby: ['唱', '跳']
}
let child1 = createAnother(person);
child1.hoby.push("rap");
console.log(child1.hoby); // ['唱', '跳', 'rap']
child1.showName(); // my name is: mjy
let child2 = createAnother(person);
console.log(child2.hoby); // ['唱', '跳', 'rap']
优点:写法简单,不需要单独创建构造函数。
缺点:通过寄生式继承给对象添加函数会导致函数难以重用。使用寄生式继承来为对象添加函数, 会由于不能做到函数复用而降低效率;这一点与构造函数模式类似。
我在上面第三种组合继承方式中提到了一些弊端,即两次调用父类的构造函数造成浪费,下面要介绍的寄生组合继承就可以解决这个问题。
第六种:寄生组合式继承
结合第四种中提及的继承方式,解决普通对象的继承问题的 Object.create 方法,我们在前面这几种继承方式的优缺点基础上进行改造,得出了寄生组合式的继承方式,这也是所有继承方式里面相对最优的继承方式,代码如下。
function clone(parent, child) {
//这里改用Object.create就可以减少组合继承中多进行一次构造的过程
child.prototype = Object.create(parent.prototype);
child.prototype.constructor = child;
}
function Parent6() {
this.name = 'parent6';
this.play = [1, 2, 3];
}
Parent6.prototype.getName = function () {
return this.name;
}
function Child6() {
Parent6.call(this);
this.friends = 'child5';
}
clone(Parent6, Child6);
Child6.prototype.getFriends = function () {
return this.friends;
}
let person6 = new Child6();
console.log(person6);
console.log(person6.getName());
console.log(person6.getFriends());
通过这段代码可以看出来,这种寄生组合式继承方式,基本可以解决前几种继承方式的缺点,较好地实现了继承想要的结果,同时也减少了构造次数,减少了性能的开销,我们来看一下上面这一段代码的执行结果。
可以看到 person6 打印出来的结果,属性都得到了继承,方法也没问题,可以输出预期的结果。
整体看下来,这六种继承方式中,寄生组合式继承是这六种里面最优的继承方式。另外,ES6 还提供了继承的关键字 extends,我们再看下 extends 的底层实现继承的逻辑。
总结
下面我将JavaScript的继承方式做了个总结的脑图,方便记忆!
综上,我们可以看到不同的继承方式有不同的优缺点,我们需要深入了解各种方式的优缺点,这样才能在日常开发中,选择最适合当前场景的继承方式。
走过路过的老铁,伸出您宝贵的小手,您的每一次点击都能帮我加个鸡腿!