JavaScript中的原型与继承

569 阅读6分钟

继承是面向对象中讨论最多的话题。像C++Java这样的语言支持两种继承:接口继承和实现继承。所谓接口继承,就是只继承方法签名,如Java中的interfaceC++的虚函数,而实现继承就是继承实际的方法,如Java中的extendsJavaScript中,因为函数没有签名(没有定义输入输出的类型),无法实现接口继承,因而实现继承就成了ECMAScript中唯一支持的实现方式,而这主要是通过原型链实现的。

原型链

ECMA-262 中把 原型链定义为ECMAScript的主要继承方式。其基本思想就是通过原型链继承多个引用类型的属性和方法(实际上主要是方法)。为了防止有些小伙伴们已经遗忘了原型链有关知识,这里我们重温一下构造函数、原型和实例的关系:

每个构造函数都有一个原型对象,可通过构造函数.prototype获得,原型有个属性constructor指回构造函数,而实例有一个内部指针__proto__(其实就是内置属性[[Prototype]])指向原型。这里,请读者思考:如果原型是另一个类型的实例呢?

那就意味着这个原型本身有个内部指针,指向另一个原型,相应地,另一个原型也有个指针指向另一个构造函数。这样就在实例和原型之间构造了一条原型链,其顶端是Object.prototype。这就是原型链的基本思想。

我们看个例子:

<script>
      function Person (name, age) {
        this.name = name
        this.age = age
      }

      Person.prototype.eat = function () {
        console.log(this.name + ' is eating...')
      }

      Person.prototype.sleep = function () {
        console.log(this.name + ' is sleeping...')
      }

      Person.prototype.walk = function () {
        console.log(this.name + ' is walking...')
      }

      const Bob = new Person('Bob', 24)
      Bob.eat()
      Bob.sleep()
      Bob.walk()
      console.log(Bob.__proto__)
      console.log(Bob.constructor === Person)
      console.log(Bob.__proto__ === Person.prototype) //true
      console.dir(Bob)
      console.log(Object.prototype.__proto__) //null
    </script>

以上代码在最新版的Edge浏览器中运行结果如下:

proto.PNG

其中,原型链如图:

prototype.png

注意:__proto__属性已从web标准中废弃,但是仍然有些浏览器依然支持它。 MDN (mozilla.org)上建议使用Object.getPrototypeOf()

大家如果想检验自己的原型链是否掌握,可以尝试去手写一个_instanceof函数,实现instanceof操作符相同的效果。这里,我给出一个参考版本:

/**
         * 仿instanceof函数 
         * @param {object} obj
         * @param {function} constructor
         * @return {boolean} 
        */
        function _instanceof(obj,constructor){
            let proto=obj.__proto__;
            while(proto!==null){
               
                if(proto.constructor===constructor)
                    return true
                proto = proto.__proto__
            }
            return false
        }

函数的第一个参数是要判断的实例,第二个参数是构造函数,返回一个布尔值,其大致的逻辑有点像链表的遍历。

原型链继承

JS中,我们访问某个对象的属性,先是从对象自身上找,如果找不到,那么就从原型链上找。

假设现在有两个构造函数SuperSub,要让后者继承前者,该怎么做呢?

有经验的开发者很容易想到Sub.prototype = new Super(),这样Sub的实例就可以通过原型链访问到Super的属性和方法了,乍一看感觉好像美的很,完美的继承了父类的方法和属性,但很遗憾,这种方式有个致命的问题,我们来看如下代码:

function Super () {
  this.colors = ['red', 'blue', 'green']
}

function Sub () {}

//继承Super
Sub.prototype = new Super()

const sub1 = new Sub()
console.log(sub1.colors)
sub1.colors.push('white')
console.log(sub1.colors)

const sub2 = new Sub()
console.log(sub2.colors)

函数输出结果如下:

[ 'red', 'blue', 'green' ]
[ 'red', 'blue', 'green', 'white' ]
[ 'red', 'blue', 'green', 'white' ]

在14行代码之前,得到的结果都是符合预期的,但是最后一行的输出结果并不是我们想要的,我们只想改变实例sub1colors属性,但是sub2的属性也受到了牵连。这是因为,colors是一个引用类型,sub1colorssub2colors属性指向的是同一个对象,都是其原型对象的colors属性,所以这个两个实例对colors所做的修改都会反映到另一个实例上,即原型中的引用值会在所有实例之间共享。

此外,原型继承还有个无法解决的问题:子类在实例化时不能给父类构造函数传参。我们来看如下代码:

function Super (name) {
  this.name = name
}

function Sub () {}

Sub.prototype = new Super()

const p = new Sub('wzq')
console.log(p.name) //undefined

这里我们期望p.namewzq,但实际输出的是undefined

这里有读者就会问了:既然用原型继承有这么多缺点,那还讨论它干啥?别急,中国有句话叫“取其精华,去其糟粕”。

盗用构造函数继承

为了解决原型包含引用值导致的继承问题,一种叫做“盗用构造函数”(constructor stealing)的技术在开发社区流行起来(这种技术有时候也称为“对象伪装”或“经典继承”)。基本思路为:在子类的构造函数中调用父类构造函数。因为毕竟函数就是在特定上下文中执行代码的简单对象,所以可以使用apply()call()方法以新创建的对象为上下文执行构造函数。看下面的例子:

function Super (name) {
  this.name = name
}

function Sub (name, age) {
  //继承Super
  Super.call(this, name)
  this.age = age
}

const p = new Sub('wzq', 24)
console.log(p)

输出结果:

Sub { name: 'wzq', age: 24 }

可以看到,这个里面,子类继承并扩展了父类的构造方法,达到了继承属性的目的,那么继承方法呢?请看以下 代码:

function Super (name) {
  this.name = name
  this.sayHi = function () {
    console.log('Hi I am ' + this.name)
  }
}

Super.prototype.sayAge = function () {
  console.log(`I am ${this.age} old.`)
}

function Sub (name, age) {
  Super.call(this, name)
  this.age = age
}

const p1 = new Sub('wzq', 24)
const p2 = new Sub('rock', 18)
p1.sayHi()
p2.sayHi()
console.log(p1.sayHi === p2.sayHi)
p1.sayAge()

输出结果:

$ node proto.js 
Hi I am wzq
Hi I am rock
false
F:\JavaScript\js面向对象\proto.js:67
p1.sayAge()
   ^

TypeError: p1.sayAge is not a function
    at Object.<anonymous> (F:\JavaScript\js面向对象\proto.js:67:4)
    at Module._compile (internal/modules/cjs/loader.js:1085:14)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1114:10)
    at Module.load (internal/modules/cjs/loader.js:950:32)
    at Function.Module._load (internal/modules/cjs/loader.js:790:14)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:76:12)
    at internal/main/run_main_module.js:17:47

可以看到子类只能继承父类中构造函数定义的方法,无法访问父类原型上的方法。因此使用这种模式继承,你需要把所有的属性和方法都放在构造函数里。从输出的false可以看到,p1.sayHip2.sayHi指向的不是同一块区域,那么意味着每创建一个一个实例,就会产生一个新的sayHi,要知道sayHi是函数,是一个有着特定执行上下文的子程序,这样是很占内存的。由于存在以上的问题,这种继承方式也不会单独使用。

组合继承!

组合继承(有时候也称伪经典继承)综合了原型链和盗用构造函数。其思想是通过原型链继承原型上的属性和方法,通过盗用构造函数继承实例属性。这样既可以把方法定义在原型上重用,又可以让每个实例有自己的属性。看下面代码:

function Super (name) {
  this.name = name
  this.friends = ['zs', 'ls']
}

Super.prototype.walk = function () {
  console.log(this.name + ' is walking.')
}

function Sub (name, age) {
  Super.call(this, name)
  this.age = age
}

Sub.prototype = new Super()

Sub.prototype.run = function () {
  console.log(this.name + ' is ' + this.age + ' years old. He can run now.')
}

const p1 = new Sub('wzq', 24)
p1.walk()
p1.run()
p1.friends.push('zmk')
console.log(p1.friends)

const p2 = new Sub('zmk', 24)
p2.walk()
p2.run()
console.log(p2.friends)

输出结果:

$ node proto
wzq is walking.
wzq is 24 years old. He can run now.
[ 'zs', 'ls', 'zmk' ]
zmk is walking.
zmk is 24 years old. He can run now.
[ 'zs', 'ls' ]

从输出的结果中可以看到,组合继承完美的解决了原型链和盗用构造函数的不足,其也是JavaScript中使用最多的继承模式。而且组合继承也保留了instanceof操作符合isPrototypeof()方法识别合成对象的能力。

学到这里就完了吗?细心的读者可能发现:上文中组合继承后的括号有一行字,说这是叫经典继承,那么怎么才能把去掉呢?且看下文。

原型式继承

时间回到2006年(ES52009年发布),有个叫Douglas Crockford的大佬发布了篇文章:Prototypal Inheritance in Javascript(《Javascript中的原型式继承》)。他在文中介绍了一种不涉及严格意义上构造函数的继承方法。他的出发点是即使不自定义类型也可以通过原型实现对象之间的信息共享。文章最后给出了一个函数:

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

大佬不愧是大佬,5行代码就提出了一种解决继承的方案。我们来分析一下这个obejct()函数。

  1. 第二行,创建了一个构造函数F
  2. 第三行,把F的原型指向传入的参数o
  3. 第四行返回该构造函数的实例

本质上,object()是对传入的对象做了一次浅复制,看下面的代码

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

let person = {
  name: 'wzq',
  friends: ['zmk', 'gjx', 'wz']
}

let anotherPerson = object(person)
anotherPerson.name = 'zmk'
anotherPerson.friends.push('hxd')
anotherPerson.__proto__.name = 'rock'
console.log(person)
person.age = 15
console.dir(anotherPerson)

在最新版的Edge上运行结果:

object.PNG

可以看到,修改anotherPerson的原型会导致person也发生改变,反过来,修改person会导致another的原型发生改变。

Crockford推荐的原型式继承适用于这种情况:你有一个对象,想在它的基础上再创建一个对象。你需要把这个对象先传给object(),然后再返回的对象进行适当的修改。在上面的例子中,person定义了一个另一个对象也应该共享的信息,把它传给object()之后会返回一个新对象。这个对象的原型是person,意味着它的原型上既有原始值属性又有引用值属性,意味着person.friends不仅是person的属性,还 跟anotherPerson共享。

ES5里通过增加Object.create()方法将原型式继承的概念规范化了。这个方法接收两个参数:作为新对象原型的对象,以及给新对象定义的额外属性的对象(可选),只有一个参数时,Object.create()与这里的object()效果相同:

const person = {
  name: 'wzq',
  friends: ['zmk', 'gjx']
}

const thirdPerson = Object.create(person)
thirdPerson.name = 'gjx'
thirdPerson.friends.push('hxd')
console.dir(thirdPerson)

const anotherPerson = Object.create(person, {
  age: {
    writable: true,
    configurable: true,
    enumerable: true,
    value: 24
  }
})
anotherPerson.name = 'zmk'
anotherPerson.friends.push('wz')
console.dir(anotherPerson)

运行结果如图:

Object.create.PNG

Object.create()第二个参数是对象属性描述符,不清楚的可以去MDN上看看Object.defineProperty()的文档。需要注意的是,以这种方式添加的属性会遮蔽掉对象上的同名属性。如:

const person = {
  name: 'wzq',
  age: 24
}

const p = Object.create(person, {
  name: {
    value: 'rock'
  }
})

console.log(p.name) //"rock"

原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的长河。但要记住,属性中包含的引用值会始终在对象间共享,跟使用原型模式是一样的。

寄生式继承

与原型式继承比较接近的一种方式是 寄生式继承(parasitic),也是Crockford首推的一种模式。寄生式继承背后的思路类似于寄生构造函数和工厂模式:创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象。基本的寄生继承模式如下:

function createAnother (original) {
  const clone = Object.create(original)
  clone.sayHi = function () {
    console.log('Hi!')
  }
  return clone
}

const person = {
  name: 'wzq',
  friends: ['zmk', 'gjx', 'wz']
}

const anotherPerson = createAnother(person)
anotherPerson.sayHi() //Hi!

解释下一下createAnother(),该函数接收一个参数,就是新对象的基准对象,这个对象会被传给Object.create(),因为只传了一个参数,所以其实就是上面的object(),然后将返回值赋值给clone。接着给clone添加一个sayHi()方法,最后返回clone。因为clone的原型是original,然后又自己添加了一个实例方法,所以anotherPerson对象既有person的方法和属性,又有自己的实例方法和属性。我们称这种方式为寄生式继承,有点青出于蓝而胜于蓝的意思。

寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。这里不一定要使用Object.create(),任何返回新对象(包含original的方法和属性)的函数都可以在这里使用。

注意:通过寄生式继承给对象添加的方法会导致方法难以重用,与构造函数模式类似。

寄生式组合继承!!

可以看到我这个标题打了两个感叹号,可以见得这个继承模式超级重要。ES6extends的原理就是寄生组合继承。从这个名字可以看出,这是一种改良的组合继承,那么组合继承的缺点在哪里呢?

组合继承存在一些效率问题,最主要的效率问题是父类构造函数始终会被调用两次:一次是在创建子类原型的时候调用,另一次是在子类构造函数中调用。第一次调用是为了继承原型上的方法,第二次调用是为了继承实例上的属性。那么能不能只调用一次,也能达到这种效果呢?答案是肯定的。

寄生式组合继承通过盗用构造函数继承属性,使用混合式原型链继承方法。基本思想是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。说到底就是使用寄生式继承来继承父类原型,然后将返回的新对象赋值给子类原型。寄生式组合继承基本模式如下:

function inheritPrototype(subType,superType){
  const prototype = Object.create(superType.prototype)  //创建对象
  prototype.constructor = subType //增强对象
  subType.prototype = prototype //赋值对象
}

这个inheritPrototype()函数实现了寄生式组合继承的核心逻辑。这个函数接收两个参数:子类构造函数和父类构造函数。函数内部,第一步是创建父类函数原型的一个副本。然后,给返回后的prototype设置constructor属性,让其指回原来的构造函数,最后将新创建的对象赋值给子类原型。调用inheritPrototype()就可以实现前面的子类原型赋值:

function Super (name) {
  this.name = name
  this.friends = ['zmk', 'gjx']
}

Super.prototype.sayHi = function () {
  console.log('Hi I am ' + this.name)
}

function Sub (name, age) {
  Super.call(this, name)
  this.age = age
}

inheritPrototype(Sub, Super)

Sub.prototype.sayAge = function () {
  console.log(`I am ${this.age} years old.`)
}

const p1 = new Sub('wzq', 24)
p1.sayHi()
p1.sayAge()
p1.friends.push('wz')
console.log(p1.friends)
const p2 = new Sub('zmk', 23)
p2.sayHi()
p2.sayAge()
console.log(p2.friends)
console.log(p1 instanceof Sub)
console.log(p1 instanceof Super)

输出结果:

$ node proto
Hi I am wzq
I am 24 years old.
[ 'zmk', 'gjx', 'wz' ]
Hi I am zmk
I am 23 years old.
[ 'zmk', 'gjx' ]
true
true

从结果可以看出,完美的解决了继承的五个主要问题:

  1. 方法共享(复用)
  2. 引用值修改不会产生副作用
  3. 可以传参数到父类构造函数
  4. 原型键保持不变(instanceofisPrototypeOf()有效)
  5. 效率提升,只调用了一次父类构造函数,避免了Sub.prototype上不必要也用不到的属性。

最后我们来探讨一个问题:inheritPrototype()是复制父类的原型到子类身上,那为什么不直接用Sub.prototype=Super.prototype呢,这样也达到了复制的目的啊?

这是因为,这样做是一种浅拷贝,如果我们改变Sub.prototype的某个属性或者方法,Super.prototype也会跟着变,看以下代码:

function Super (name) {
  this.name = name
  this.friends = ['zmk', 'gjx']
}

Super.prototype.sayHi = function () {
  console.log('Hi I am ' + this.name)
}

function Sub (name, age) {
  Super.call(this, name)
  this.age = age
}

Sub.prototype = Super.prototype
Sub.prototype.constructor = Sub

Sub.prototype.sayAge = function () {
  console.log(`I am ${this.age} years old.`)
}

const p1 = new Sub('wsc', 24)
const p2 = new Super('wjl', 50)
console.log(p1.constructor)
console.log(p2.constructor)

输出结果:

$ node proto
[Function: Sub]
[Function: Sub]

可以看到,p2明明是Super创造的,但是却说constructorSub,这就是引用类型带来的副作用,相信不难理解。那么新问题来了,为什么inheritPrototype不会产生这用副作用呢?你不说object()本质也是浅拷贝吗?

这里就有个套娃的过程,object()的确是浅复制,但是它的浅复制,不是将传入的参数直接赋值给要返回的新对象,而它是将原型挂到了返回的新对象上(F.prototype=o),这就意味着,你需要去修改新对象的__proto__才能影响到被复制的那个原型对象。上面代码你要修改Sub.prototype.__proto__才会影响到Super.prototype(不信的读者可以试试),但是我想正常开发应该没人去做这种骚事情把?

感谢阅读。