恶补 JavaScript 基础之继承的多种方式及其优缺点

前排提示:本文内容比较偏概念性的东西,所以基本上看起来像是笔记,我加了一些我自己的理解,希望可以让内容更加易读。本文与原型和原型链有较大的关联,所以建议先阅读JavaScript原型与原型链

我们知道JavaScript实际上是面向过程的一门语言,所以其实并不存在继承、类等,但是我们可以去模拟出继承。

原型链继承


原型链继承的基本思想是:利用原 型让一个引用类型继承另一个引用类型的属性和方法。但是怎么实现呢,我们来举例说明。

假设我们有 AB 两个构造函数,我们的任务就是要让 B 的实例可以使用 A 中的属性和方法。如果我们使用构造函数 B 去创建了一个实例 b ,我们去访问 b 上的属性的时候,解释器会先看实例 b 上面是否有该方法,如果没有的话就会顺着原型链去他的原型对象(B.prototype)上查找。我们要达到的效果是:如果在 b 实例上没有找到的话就去 A 的实例上面查找

经过上一篇原型和原型链的理解,我们很容易的想到如果让 B 的原型指向 A 的实例的话是不是就行了呢。

  1. function A () {
  2. this.age = 18
  3. }
  4. function B () {
  5. this.name = 'sixty'
  6. }
  7. B.prototype = new A()
  8. var b = new B()
  9. console.log(b.name) // sixty
  10. console.log(b.age) // 18
  11. console.log(b.phone) // undefined
  12. A.prototype.phone = 110
  13. console.log(b.phone) // 110

以上代码表明,我们可以用这种方式达到继承的效果。当实例b访问某个属性的时候,会依次产找:实例b -> 实例A -> 原型A

优点

  1. 使用起来非常简单,只用一行代码就可以实现
  2. 在父类的原型上增加属性子类都可以访问到,而且不会影响到它继承的对象(例子中的A)

缺点

  1. 当为子类增加属性和方法的过程必须写在B.prototype = new A()这行代码之后
  2. 无法实现多继承
  3. 所有的属性都是共享的,某一个子类更改了一个继承自父类的引用类型的数据的话会影响到所有继承了改父类的子类
  4. 在创建子类的时候没有办法传递参数给父类

稍微解释一下第三个缺点:

  1. function A () {
  2. this.teachers = ['sixty', 'xxx']
  3. }
  4. function B () {
  5. this.name = 'sixty'
  6. }
  7. B.prototype = new A()
  8. var b1 = new B()
  9. b1.teachers.push('linux'))
  10. console.log(b1.teachers) // ['sixty', 'xxx', 'linux']
  11. var b2 = new B()
  12. console.log(b2.teachers) // ['sixty', 'xxx', 'linux']

可以看到我通过b1去改变了父类的teachers数组,但是导致b2的实例的teachers也发生了改变,这是一个比较致命的问题,所以原型链继承在开发者基本不会使用。

借用构造函数继承


为了解决原型中包含引用类型值所带来的问题,开发人员开始使用一种叫做借用构造函数 (constructor stealing)的方式(有时候也叫做伪造对象或经典继承)来实现继承。

这种方式也相当简单,即在子类的构造函数的内部调用父类的构造函数。别忘了,函数只不过是在特定环境中执行代码的对象, 因此通过使用 apply()call()方法也可以在(将来)新创建的对象上执行构造函数,如下所示:

  1. function A () {
  2. this.teachers = ['sixty', 'xxx']
  3. }
  4. function B () {
  5. A.call(this) // 继承了A
  6. }
  7. var b1 = new B()
  8. b1.teachers.push('linux'))
  9. console.log(b1.teachers) // ['sixty', 'xxx', 'linux']
  10. var b2 = new B()
  11. console.log(b2.teachers) // ['sixty', 'xxx']

通过使用 call()方法(或 apply()方法 也可以),我们实际上是在(未来将要)新创建的B实例的环境下调用了A构造函数。 这样一来,就会在新B对象上执行 A()函数中定义的所有对象初始化代码。结果, B的每个实例就都会具有自己的 teachers 属性的副本了。

相对于原型链而言,借用构造函数有一个很大的优势,即可以在子类型构造函数中向超类型构造函数传递参数。看下面这个例子:

  1. function A (name) {
  2. this.name = name
  3. }
  4. function B () {
  5. A.call(this, 'sixty') // 继承了A,同时还传递了参数
  6. this.age = 18 // 实例属性
  7. }
  8. var b = new B()
  9. console.log(b1.name) // sixty
  10. console.log(b2.age) // 18

优点

  1. 可以实现多继承(call或者apply多个构造函数)
  2. 解决了引用类型共享的问题
  3. 可以传递参数

缺点

  1. 在子类里面调用父类的构造函数相当于把父类的构造函数里面的内容复制到了子类里面,所有的东西都写在构造函数里面何谈复用。
  2. 只能继承构造函数里面的方法。正常情况我们一般会把普通属性(不会经常改变的属性)放在构造函数里,把会经常变化的,或者经常添加的放在原型上面,这就导致这种继承方式无法继承到父类实例上的方法。

组合继承


组合继承中的组合二字是要组合谁呢?就是上文我们提到的原型链继承的借用构造函数继承,如果把这二者结合起来就可以实现父类原型上方法的复用,又能保证每个实例都有自己的属性,听起来还是挺完美的,但是依然有问题,所以它也被称作伪经典继承
下面来看一个例子:

  1. function A (name) {
  2. this.name = name
  3. this.teachers = ['sixty', 'xxx']
  4. }
  5. A.prototype.sayName = function () {
  6. alert(this.name)
  7. }
  8. function B (name, age) {
  9. A.call(this, name) // 在这里继承属性
  10. this.age = age // B自己的属性
  11. }
  12. B.prototype = new A() // 在这里继承方法
  13. B.prototype.constructor = B // B.prototype.constructor指向已经变成A了
  14. B.prototype.sayAge = function () {
  15. alert(this.age)
  16. }
  17. var b1 = new B("sixty", 18)
  18. b1.teachers.push("linux")
  19. console.log(b1.teachers)// ['sixty', 'xxx', 'linux']
  20. b1.sayName() // sixty
  21. b1.sayAge() // 18
  22. var b2 = new B("tom", 20)
  23. console.log(b2.teachers)// ['sixty', 'xxx']
  24. b2.sayName() // tom
  25. b2.sayAge() // 20

可以看到实例b1,b2的属性是独立的,包括引用类型的(例子里面的teachers),它们相当于通过代用父类的构造函数新创建了一份属性。但是它们又可以共用A原型上的方法,这基本上比较完美了。

这里再解释一下B.prototype.constructor = B这句的作用:因为上一行代码其实已经将B.prototype.constructor指向A了,这里不懂的可以看上一篇JavaScript原型与原型链,所以在这里把它的指向改回B。

优点

  1. 拥有原型链继承和借用构造函数继承两者的优点

缺点

  1. 父类的构造函数在继承的中被调用了两次。
  2. 子类创建出来的实例的属性在实例和原型中各存在了一份,浪费了内存空间。

对于第1点,第一次调用是在子类的构造函数中我们使用call调用了一次,第二次调用是在我们设置原型的时候B.prototype = new A()如果说A这个构造函数比较大的话或者比较耗性能的话,程序的整体性能就有所下降了。

对于第2点,我们看下面代码

  1. function A () {
  2. this.name = 'sixty'
  3. }
  4. function B () {
  5. A.apply(this, argument) // 在这里使用的apply,这里其实也有一份name属性了
  6. }
  7. B.prototype = new A() // 在这里B的原型上也有了一份name属性
  8. B.prototype.constructor = B
  9. var instance = new B() // 此时实例上有了name属性,来自于B的构造函数

我们在父类(A)的构造函数中有个属性是name,那么当我们用子类(B)创建一个实例的时候,实例上就有了name属性,而在子类的原型(B.prototype)上也有一个属性name,这两个属性名字是一样的,这会导致子类的原型上个这个name永远也不会被实例访问到。

也许你会说实例的name会不会是去访问B.prototype上面的name啊?

这怎么可能呢,兄弟!上文已经说了在B的构造函数里面使用call,继承过来的东西实际上可以理解为复制了一份到B的构造函数,而B实例化后当然也是让实例自身拥有了构造函数里的属性啊,不信你看:

继承的多种方式及其优缺点 - 图1

所以,这样说的话,确实这个name属性存在了两份哦。

原型式继承


原型式继承实际上就是通过一个空的跳板构造函数,来继承另一个已有的对象,然后返回一个新的对象。代码如下:

  1. function object(o){
  2. function F() {}
  3. F.prototype = o
  4. return new F()
  5. }

在 object()函数内部,先创建了一个临时性的构造函数,然后将传入的对象作为这个构造函数的原型,最后返回了这个临时类型的一个新实例。从本质上讲,object()对传入其中的对象执行了一次浅复制。下面的例子改造于JavaScript高级程序设计第3版第6章

  1. var A = {
  2. name: 'Sixty',
  3. teachers = ['Sixty', 'xxx']
  4. }
  5. var a1 = object(A)
  6. a1.name = 'Greg'
  7. a1.friends.teachers('Rob')
  8. console.log(a1.name) // Greg
  9. console.log(A.teachers) // ['Sixty', 'xxx', 'Rob']
  10. var a2 = object(person)
  11. a2.name = 'Linda'
  12. a2.teachers.push('Barbie')
  13. console.log(a2.name) // Linda
  14. console.log(A.teachers) // ['Sixty', 'xxx',,'Rob','Barbie']

从上面的打印可以看到,原型式继承跟原型链继承十分的相似,它有共同的问题:就是引用类型的数据会被共享,不同的是原型式继承是用一个空的桥梁构造函数直接返回了一个对象实例,实例继承的是一个普通的基础对象。而原型链继承的是父类构造函数的原型。

ECMAScript 5 新增的 Object.create()方法就是使用了原型式继承来实现。但是它要实现的更加丰富一些,它接受两个参数,第一个跟这里的object的参数一样,是个基础对象;第二个参数是与Object.defineProperties()方法的第二个参数一样,可以去看看。

优点

  1. 可以直接创建简单的对象,有时候我们仅仅想简单继承一下另一个普通对象,没必要去new一个构造函数。

缺点

  1. 包含引用类型值的属性始终都会共享相应的值,就像使用原型模 式一样。

寄生式继承


寄生式继承简单地说就是对原型式继承的再次封装,返回一个被增强了的对象,一看下面代码你就知道是啥意思了

  1. function object(o){
  2. function F() {}
  3. F.prototype = o
  4. return new F()
  5. }
  6. function createAnother (original) {
  7. var clone = object(original)
  8. // var clone = Object.create(original) // 也可以直接使用es5中的create
  9. clone.sayHi = function() { // 以某种方式来增强这个对象
  10. alert("hi")
  11. }
  12. return clone // 返回这个对象
  13. }
  14. var person = {
  15. name: 'Nicholas',
  16. friends: ['Shelby', 'Court', 'Van']
  17. }
  18. var anotherPerson = createAnother(person)
  19. anotherPerson.sayHi() // "hi"

这个例子中的代码基于 person 返回了一个新对象——anotherPerson。新对象不仅具有 person 的所有属性和方法,而且还有自己的 sayHi()方法。

缺点

  1. 使用寄生式继承来为对象添加函数,会由于不能做到函数复用而降低效率;这一 点与构造函数模式类似。

寄生组合式继承


上文提到的组合式继承有个主要问题就是父类的构造函数会被调用两次,我再次把组合式继承的代码放到这里,我们回顾一下:

  1. function A (name) {
  2. this.name = name,
  3. teachers = ['Sixty', 'xxx']
  4. }
  5. function B (name, age) {
  6. A.call(this, name) // 这里调用了A的构造函数
  7. this.age = age
  8. }
  9. B.prototype = new A() // 在这里B的原型上也有了一份name属性
  10. B.prototype.constructor = B
  11. var instance = new B('Sixty', 18) // 此时调用B的构造函数,间接调用A的构造函数

可以看到,父类的构造函数确实多执行了一次,能不能想办法少执行一次呢?如果我们不使用 B.prototype = new A() ,而是间接的让 B.prototype 访问到 A呢?

寄生组合式继承就是为了解决这个问题的,寄生组合式继承通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。本质上,就是使用寄生式继承来继承父类的原型,然后再将结果指定给子类的原型。

  1. function A (name) {
  2. this.name = name,
  3. teachers = ['Sixty', 'xxx']
  4. }
  5. function B (name, age) {
  6. A.call(this, name)
  7. this.age = age
  8. }
  9. var F = function () {};
  10. F.prototype = A
  11. B.prototype = new F()
  12. var = new B('Sixty', 18)

封装一下就是这样(来自高级教程…)

function object(o){
  function F() {}
  F.prototype = o
  return new F()
}

function inheritPrototype(child, parent){
  var prototype = object(parent.prototype); // 用原型式继承将父类的原型作为基本对象来创建副本对象
  prototype.constructor = child; // 用子类的构造函数增强跳板对象并解决副本对象因重写原型而失去默认的constructor属性
  child.prototype = prototype; // 最后把副本对象直接赋值给子类的原型
}

这个示例中的inheritPrototype()函数实现了寄生组合式继承的最简单形式。这个函数接收两 个参数:子类型构造函数父类型构造函数。在函数内部,第一步是创建父类原型的一个副本。第二 步是为创建的副本添加constructor属性,从而弥补因重写原型而失去的默认的constructor属性。 最后一步,将新创建的对象(即副本)赋值给子类型的原型。这样,我们就可以用调用 inheritPrototype()函数的语句,去替换前面例子中为子类型原型赋值的语句了。

function A (name) {
  this.name = name,
    teachers = ['Sixty', 'xxx']
}

A.prototype.sayName = function(){
  alert(this.name);
}

function B (name, age) {
  A.call(this, name) 
  this.age = age
}

inheritPrototype(A, B)
B.prototype.sayAge = function(){
  alert(this.age)
}
var  = new B('Sixty', 18)

这个例子的高效率体现在它只调用了一次父类的构造函数(A),并且因此避免了在子类的原型(B. prototype)上面创建不必要的、多余的属性。与此同时,原型链还能保持不变;因此,还能够正常使用instanceofisPrototypeOf()。开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。

恶补JavaScript基础系列


恶补JavaScript基础系列目录地址:https://www.sixtyden.com/archive
恶补JavaScript基础系列是我在从学校毕业入坑前端的学习产物,它主要是我看完书以及其他资料后的一个浓缩总结。以下是我参考的主要资料:
JavaScript高级程序设计
你不知道的JavaScript(上卷)
陪你读书(JavaScript web前端)
王福朋的博客
冴羽写博客的地方
本人能力有限,如果有错误或者不严谨的地方,请务必给予指出,十分感谢!愿与君共勉。

(完)