js构造函数的继承方式

1,592 阅读5分钟

继承是面向对象编程一个非常重要的特性。很多面向对象语言都支持两种继承:接口继承和实现继承。接口继承就是只继承方法签名,而实现继承是继承实际的方法。ES中是不存在接口继承的,因为函数没有方法签名,实现继承是ES唯一的继承方式,而这主要是通过原型来实现的。

原型链继承

也称为 prototype模式

function Super (){
    this.name = 'Super'
}
Super.prototype.getName = function(){
    return this.name
}

function Sub (){
    this.name = 'Sub'
}
// 让Sub继承Super
Sub.prototype = new Super()
//保持继承链的正确
Sub.prototype.constructor = Sub

let instance = new Sub()
console.log(instance.getName()); //Sub

这种方式完成继承虽然很方便,但也是有问题的。

  • 首先是原型机制本身的问题,因为原型是所有实例共享的,那也就意味着通过实例对原型上引用属性进行修改,而且会反映在每一个实例上。
  • 另一个问题是,在使用原型实现继承的时候,子构造函数的原型实际上变成了父构造函数的实例。这意味着原先的实例属性摇身一变成为了原型属性。我们举例看一下:
function Super (){
    this.colors = ['red','blue','green']
}

function Sub (){}
// 让Sub继承Super
Sub.prototype = new Super()

let instance1 = new Sub()
instance1.colors.push('black')
console.log(instance1.colors);  //[ 'red', 'blue', 'green', 'black' ]

let instance2 = new Sub()
console.log(instance2.colors);  //[ 'red', 'blue', 'green', 'black' ]

这个例子中colors本来是Super的实例属性,但是当Sub继承Super之后,Sub.prototype变成了Super的实例。colors也就变成了Sub实例所共享的一个原型属性。

  • 还有一个问题就是子构造函数在实例化时不能给父构造函数传参。

已上问题就导致原型链继承实际基本上不会被单独使用

盗用构造函数

为了解决原型原型是哪个包含引用值导致的问题,一种叫做盗用构造函数(constructor stealing)的方式出现了(也叫构造函数绑定对象伪装经典继承等等)。这种方法很简单,就是通过call/apply在子构造函数内执行父构造函数。看下面的例子:

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

function Sub (){
    // 继承Super
    Super.call(this)
}

let instance1 = new Sub()
instance1.colors.push('black')
console.log(instance1.colors);  //[ 'red', 'blue', 'green', 'black' ]

let instance2 = new Sub()
console.log(instance2.colors);  //[ 'red', 'blue', 'green' ]

这意味着在Sub实例对象上上执行了一遍Super函数中的代码,从而让Sub的每一个实例都有了自己的colors属性。
盗用构造函数的另一个优点就是可以给父构造函数传递参数。

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

function Sub (name){
    // 继承Super
    Super.call(this, name)
    //Sub实例属性
    this.age = 15
}

let instance1 = new Sub('小明')
console.log(instance1.name);   //小明
console.log(instance1.age);   //15

这里有一个注意点就是最好先调用父构造函数,之后再添加子构造函数属性。以免属性被覆盖

组合继承

组合继承结合了原型链继承盗用构造函数,将两者的优点结合到了一起。基本思路是使用原型链继承原型上的属性和方法,通过盗用构造函数继承实例属性。看下面的例子:

function Super (name){
    this.name = name
    this.colors =  ["red", "blue", "green"]
}
Super.prototype.sayName = function(){
    console.log(this.name);
}

function Sub (name, age){
    // 继承Super
    Super.call(this, name) 
    this.age = age
}
Sub.prototype = new Super()
Sub.prototype.constructor = Sub
Sub.prototype.sayAge = function(){
    console.log(this.age);
}

let instance1 = new Sub('小明', 15)
instance1.colors.push('black')
console.log(instance1.colors);   //[ 'red', 'blue', 'green', 'black' ]
instance1.sayName() //小明
instance1.sayAge()  //15

let instance2 = new Sub('小王', 18)
instance2.sayName() //小王
instance2.sayAge()  //18

组合继承弥补了原型链继承盗用构造函数的不足,是JavaScript中使用最多的继承模式。而且组合继承也保留了instanceof和isPrototypeOf方法识别的能力。

不知道大家有没有发现组合继承两个不足的地方。
1、父构造函数的实例属性依然会存在于子构造函数的原型上,虽然访问时会被子构造函数的同名实例属性所覆盖
2、继承过程中调用了两次SuperType,第一次call和第二次new

原型式继承

原型式继承不是严格意义上构造函数的继承方法。可以通过一个函数来完成:

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

这个函数创建一个临时的构造函数,把传进来的对象作为这个构造函数的原型,并返回它的实例。
看下面的例子:


function create (o){
    function F(){}
    F.prototype = o
    return new F()
}
let Person = {
    name:'小王',
    friends:['张三', '李四']
}
let p1 = create(Person)
p1.name = '小张'
p1.friends.push('小张')
console.log(p1.name);   //小张
console.log(p1.friends);    //[ '张三', '李四', '小张' ]

let p2 = create(Person)
p2.friends.push('小李')
console.log(p2.name);   //小王
console.log(p2.friends);    //[ '张三', '李四', '小张', '小李' ]

这里Person会作为实例p1和p2的原型被共享。

ECMAScript5 对原型式继承给出了一个规范化的方法,Objece.create()。这个方法返回一个新对象,接收两个参数:作为新对象原型的对象,和给新对象定义额外属性的对象。当只有一个参数时,Objece.create()与上面的create方法效果一样:

let Person = {
    name:'小王',
    friends:['张三', '李四']
}
let p1 = Object.create(Person)
p1.name = '小张'
p1.friends.push('小张')
console.log(p1.name);   //小张
console.log(p1.friends);    //[ '张三', '李四', '小张' ]

let p2 = Object.create(Person)
p2.friends.push('小李')
console.log(p2.name);   //小王
console.log(p2.friends);    //[ '张三', '李四', '小张', '小李' ]

其实Object.create()的原理用下面一段代码就能说明:

let p1 = {}
Object.setPrototypeOf(p1, Person)

就是把对象p1的内部属性[[portotype]]设置成了Person。可以验证一下:

let Person = {
    name:'小王',
    friends:['张三', '李四']
}
let p1 = Object.create(Person)

console.log(Object.getPrototypeOf(p1) === Person);  //true
console.log(p1.__proto__ === Person);  //true

Object.create()的第二个参数与Object.defineProperties()的第二个参数一样。以这种方式新增的属性会遮蔽原型对象(也就是第一个参数)上面的属性:

let Person = {
    name:'小王',
    friends:['张三', '李四']
}
let p1 = Object.create(Person, {
    name:{
        value:'小张',
        enumerable:true,
        writable:true,
        configurable:true
    }
})
console.log(p1.name);   //小张

原型式组合继承

还记的上面组合继承最后提到的两个不足吗。其实可以通过结合原型式来弥补这两个不足:

function Super (name){
    this.name = name
    this.colors =  ["red", "blue", "green"]
}
Super.prototype.sayName = function(){
    console.log(this.name);
}

function Sub (name, age){
    Super.call(this, name)
    this.age = age
}
// Sub.prototype = new Super()  把这行代码替换成下面一行
Sub.prototype = Object.create(Super.prototype)

Sub.prototype.constructor = Sub
Sub.prototype.sayAge = function(){
    console.log(this.age);
}

这里只调用了一次Super构造函数,而且避免了Sub.prototype上不必要也用到的属性,因此此方式效率更高。可以说是继承的最佳模式。

结语

未来面向对象以及继承应该最先使用ES6的Class,因为Class已经原生规避了上面所说的种种不足,虽然其实现依然是基于原型prototype的。