📢 大家好,我是小丞同学,本文主要写 JavaScript 中的 7 种继承方式,深入理解每种方式所存在的问题同时新的方式是如何解决问题的
📢 非常感谢你的阅读,不对的地方欢迎指正 🙏
📢
愿你生活明朗,万物可爱
💌 前言
在上一篇文章中我们讲解了原型链的机制以及原型相关的一些属性,而与原型链息息相关的就是继承了,为什么这么说呢?
在《JavaScript 高级程序设计》上,有这么一句话
“实现继承是 ECMAScript
唯一支持的继承方式,且这主要通过原型链来实现。”
可想而知,原型链在继承中起着至关重要的主要
在全文开始之前,不妨先来看看本文纲要
💟息息相关的 6 种继承方式
在 ES6 到来之前,基于 ES5 实现的继承,在每一代中都优化了上一代带来的问题,这也是 JavaScript 语言中值得我们学习的一点,遇到问题,解决问题,不断优化,接下来我们来看看它们是如何一步步实现的
🍏 一、原型链继承
1. 基本思想
原型链继承的基本思想是通过原型来继承多个引用类型的属性和方法
实现的基本思路是利用构造函数实例化对象,通过 new
关键字,将构造函数的实例对象作为子类函数的原型对象
2. 实现方法
// 定义父类函数
function Father() {
// 定义父类属性
this.name = 'father'
}
// 给父类的原型添加方法
Father.prototype.say = function () {
console.log('我是爸爸');
}
// 创建子类函数
function Son() {}
// 实现继承
Son.prototype = new Father()
// 打印参考
console.log(Son.prototype) // Father {name: "father"}
我们来解释一下上面的代码,首先定义了一个父函数和子函数,添加了一些属性和方法
而实现继承的关键在于 Son.prototype = new Father()
。那它怎么理解呢
首先我们需要了解一下 new
操作符的执行过程
- 创建一个空对象
- 继承函数原型,将这个新对象的
__proto__
属性赋值为构造函数的原型对象 - 构造函数内部的
this
指向新对象 - 执行函数体
- 返回这个新对象
明白了 new
的过程后,我们可以知道当我们在 new Father()
操作时,这一步将 Father
构造函数的原型对象打包给了 Father
的实例对象,也就是 father.__proto__ = Father.prototype
,换到这里也就是 Son.prototype.__proto__ = Father.prototype
,这样一来也就是将父类的实例对象作为了子类的原型,这也一来就在子类与父类实现了连接
关键性代码:son.prototype = new Father()
3. 存在的问题
通过上面的分析,整体上感觉不出什么问题,但是我们来看一下这个例子
function Father() {
// 定义父类属性为引用数据类型
this.a = [1, 2, 3, 4]
}
我们将上面的代码中 a
的值改成引用数据类型,我们知道对于引用数据类型只会保存对它的引用,也就是内存地址。
我们先创建两个继承这个父类的子类 son1 ,son2
let son1 = new Son()
let son2 = new Son()
接着我们想向 son1
中的 a
数组添加一个值 5 ,我们会这么操作
son1.a.push(5)
不用多想son1
肯定成功添加了,但是我们再打印一下此时的son2
,我们会发现它的 a
数组也被改变了
而这就是原型链继承方式带来的引用数据类型被子类共享的问题
4. 优点与不足
优点:
- 父类的方法可以复用
- 操作简单
缺点
- 对于引用数据类型数据会被子类共享,也就是改一个其他都会改
- 创建子类实例时,无法向父类构造函数传参,不够灵活。
🍉 二、盗用构造函数继承
为了解决原型链继承方式带来的引用值无法共享的问题,从而兴起了一种“盗用构造函数继承”的方式
1. 基本思想
为了想要实现引用值共享的问题,我们就不能给子类直接使用原型对象上的引用值。
因此,可以在子类构造函数中调用父类构造函数。
我们从一段简易的代码入手
function Son() {
this.a = [1, 2, 3, 4]
}
如果我们将子类的代码改写成这样,会发生什么呢?
当我们通过 Son
构造函数实例化实例对象时,每个实例对象中变量 a
都是独立的,属于自身的,当我们修改一个时,不会影响另一个的值
这也就是盗用构造函数继承的原理
2. 实现方法
function Father() {
this.a = [1, 2, 3, 4]
}
function Son() {
Father.call(this)
}
let son1 = new Son()
let son2 = new Son()
son1.a.push(5)
console.log(son1, son2)
我们可以看到,在上面的实现方式中,并没有直接采用 this.a...
而是采用了 Father.call(this)
如果直接采用 this.a
的话,这还叫做继承吗?是吧
那么采用 Father.call(this)
又是什么道理呢?
我们原先直接将 this.a
直接的写在了子类函数里面,这和直接在子类中调用 Father
方法是类似的,唯一的差别就是 this
指向问题
如果直接的在子类中调用 Father()
,那么它的 this
将指向 window
,这样就无法将数据绑定到实例身上,因此我们需要改变 this
的指向,指向当前的子类构造函数
这样一来就能将数据绑定到了每个实例对象身上
同时由于我们的关键语句采用的是 call
,因此我们可以给父类构造函数传递参数,实现传递参数
3. 存在的问题
从上面的实现代码中,相信大家都能看出来,我有意的忽略了原型的操作,没有在父类构造函数的原型上添加方法,而这个就是这种方法存在的问题
Father.prototype.say = function () {
console.log(111);
}
无法在子类上找到 say
方法
4. 优点与不足
优点:
- 解决了无法共享引用值的问题
- 能够传递参数
缺点:
- 只能继承父类的实例属性和方法,不能继承父类的原型属性和方法
- 父类方法无法复用。每次实例化子类,都要执行父类函数。重新声明父类所定义的方法,无法复用。
🍊 三、组合继承
在前面两种方法中,都存在着一定的缺陷,所以很少会将它们单独使用。为此一种新的继承方式就诞生了:组合继承(伪经典继承),组合继承结合了原型链与盗用构造函数继承的方式,将两者的优点结合在一起。
1. 基本思想
通过原型链继承方式继承父类原型上的属性和方法,再使用盗用构造函数的方式继承实例上的属性
这样,实现了把方法定义在原型上以实现复用,又保证了让每个实例都有自己的属性
2. 实现方法
将两种方法和并在一起
function Father() {
this.a = [1, 2, 3, 4]
}
Father.prototype.say = function () {
console.log(111);
}
function Son() {
Father.call(this)
}
Son.prototype = new Father()
let son1 = new Son()
let son2 = new Son()
其实只是在盗用构造函数的基础上添加了原型链继承的关键性代码
Son.prototype = new Father()
在上面的代码中,通过盗用构造函数的方法继承了父类实例上的属性 a
,通过原型链的方式,继承了父类的原型对象
关于具体过程也只是两个的结合,可以翻翻前面的解释
3. 存在的问题
首先我们来打印一下 son1和son2
输出了这样的结果,我们发现在它的原型对象上也有一个属性 a
,但是这个似乎是初始值,我们来想一想这是为什么?
我们将 Father
的实例绑定在了 Son
的原型上,但是我们又通过盗用构造函数的方法
将 Father
自身的属性手动添加到了 Son
的身上,因此在 Son
实例化出来的对象上,会有一个 a
属性,原型上也会有一个 a
属性
那这样会造成什么问题呢?
回答这个问题之前,我们先来数数调用了几次 Father
构造函数,
- 在
new
的时候 - 在
call
的时候
因此一方面会有一定的性能问题,还有一方面就是会出现 2 个属性
4. 优点和不足
优点:
- 解决原型链继承中属性被共享的问题
- 解决借用构造函数解决不能继承父类原型对象的问题
缺点:
- 调用了两次的父类函数,有性能问题
- 由于两次调用,会造成实例和原型上有相同的属性或方法
🍋 四、原型式继承
我似乎找不到这种继承方式的存在意义,不知道它解决了组合模式的什么问题?
1. 基本思想
原型式继承实现的思路是:直接将对象赋值给构造函数的原型
2. 实现方法
function object(obj) {
function F(){};
// 将对象赋值给构造函数的原型
F.prototype = obj;
// 返回 new 期间创建的新对象
return new F();
}
这个 object
函数会创建一个临时构造函数,将传入的对象赋值给构造函数的原型,然后返回这个临时构造函数的实例
那我们怎么用呢
let student = {name:'xxx'}
let another = object(student)
我们需要先准备好一个父类对象,也就是要被继承的对象,然后作为参数传入object
函数,返回的对象就是一个以这个父类对象为原型对象的对象
3. 存在的问题
其实它存在的问题和原型链继承的问题相同,属性和方法被共享
let student = {name:['ljc']}
let one = object(student)
let two = object(student)
one.name.push('aaa')
two.name.push('bbb')
我们分别给one,two
对象中的name
数组添加属性,再来打印一下one.name
这样又造成了被共享的问题
4. 优点和不足
优点:
- 兼容性好,简单
- 不需要单独创建构造函数
缺点:
- 多个实例共享被继承的属性,存在被篡改的情况
- 不能传递参数
ES5
中增加的Object.create()
的方法,能够代替上面的object
方法。也为原型式继承提供了规范
🍌 五、寄生式继承
1. 基本思想
创建一个仅用于封装继承过程的函数,该函数在内部已某种方式来增强对象,最后返回对象。
也就是在原型式继承的基础上进行增强对象。
2. 实现方法
function createAnother(original) {
let clone = object(original); // 继承一个对象 返回新函数
clone.sayHi = function () {
console.log('hi');
};
return clone; // 返回这个对象
}
在这段代码中,似乎只是在原有对象的基础上,给对象添加了一个方法,并封装成了一个函数,供我们直接使用
3. 优点和不足
优点:
- 只需要关注对象本身,不在乎类型和构造函数的场景
缺点:
- 函数难以重用
- 多个实例共享被继承的属性,存在被篡改的情况
- 无法传递参数
🍑 六、寄生式组合继承
组合继承仍然着效率的问题,最主要的问题是,父类构造函数始终会被调用 2 次
1. 基本思想
结合组合继承和寄生式继承结合来实现减少对父类的调用次数,从而达到目的
2. 实现方法
在组合继承的方法中我们 call
了一次,又 new
了一次,导致调用了2次父类,而在寄生式继承中,我们可以调用 API 来实现继承父类的原型
我们将两者结合在一起
不再采用 new
关键字来给改变原型
function Father() {
this.a = [1, 2, 3, 4]
}
Father.prototype.say = function () {
console.log(111);
}
function Son() {
Father.call(this)
}
Son.prototype = Object.create(Father)
let son1 = new Son()
let son2 = new Son()
采用 Object.create
来重写子类的原型,这样就减少了对父类的调用
这时我们在控制台打印 son1
会发现问题解决了
3. 存在的问题
在这种方法中,同样存在着一些问题,当我们的子类原型上有方法时
会因为原型被重写而丢失了这些方法
我们在代码最上方添加上一个 sayHi
方法
Son.prototype.sayHi = function() {
console.log('Hi')
}
要想解决这个问题,其实可以在原型被重写之后再添加子类原型的方法
4. 优点和不足
优点:
基本上是最佳的继承方案了,当然还有圣杯继承
只调用了父类构造函数一次,节约了性能。
避免生成了不必要的属性
缺点:
- 子类原型被重写
以上就是介绍的ES5中的6种继承方式
ES6 中的继承🎭
由于 ES6 之前的继承过于复杂,代码太多,再 ES6 中引入了一种新的继承方式 extends
继承
采用 extends
关键字来实现继承
class Father {}
class Son extends Father {
constructor() {
super()
}
}
这样就实现了子类继承父类,这里的关键是需要在子类的 constructor
中添加一个 super
关键字
需要注意的是
子类中constructor
方法中必须引用super
方法,否则新建实例会报错,这是因为子类自己的 this
对象,必须先通过父类构造函数完成塑性,得到父类的属性和方法
然后再加上子类自己的属性和方法
如果没有 super
方法,子类就没有 this
对象,就会报错
关于 class 的东西还有很多,这里就不多说了
参考文献
《JavaScript 高级程序设计》
以上就是关于 JS 实现继承的 7 种方法了,当然还会有一些其他的继承方法,圣杯模式继承,拷贝继承等等一大堆就不多说了
以上就是本文的全部内容了,希望你能喜欢💛,有什么问题可以评论区留言噢~