JS - 实例、原型、继承

113 阅读9分钟

前言

  继承在面向对象语言中是一个很重要的概念。许多面向对象语言都支持两种继承方式:接口继承和实现继承。接口继承只继承方法签名,而实现继承还继承了方法的实现。由于在ECMAScript中函数没有签名,ECMAScript只支持实现继承,而且其实现继承主要依靠的是原型链来实现的。

1、原型链

  在介绍原型链之前,先来简单了解一下构造函数、原型对象、实例对象之间的关系。每一个构造函数都有一个原型对象、原型对象都包含一个指向构造函数的指针、每个实例对象都包含一个指向原型对象的内部指针。

  现在,我们先假设一个实例对象的原型对象指针指向的是另一个实例AA。这就意味着原型对象AA也包含一个指向另一个原型对象BB的指针,同样的,原型对象BB也包含一个指向另一个构造函数的指针。再进一步假设原型对象B也包含一个指向新的原型对象CC的指针,那么上诉的关系依然成立,接着层层递进,就形成一条实例和原型的链条,这就是原型链的基本概念。

 //相关指针
 prototype //构造函数中指向原型对象的指针。
 __proto__ //实例对象中指向原型对象的指针。
 constructor //原型对象中指向构造函数的指针。  

2、 对象的继承

2.1、 原型链继承

  直接将原型对象赋值给构造函数,实现如下:

function SuperType() {
    this.superName = 'Super';
}

function SubType() {
    this.name = 'Sub';
}

SuperType.prototype.getSuperName = function(){
    return this.superName;
}

SubType.prototype = new SuperType();

SubType.prototype.getSubName = function(){
    return this.name;
}

const obj = new SubType();

console.log(obj.getSuperName()); //Super
console.log(obj.getSubName()); //Sub

  上面的代码定义了两个类型SuperTypeSubType,两个类型都有各自的属性和方法。两个类型的主要区别在于,SubType继承了SuperType。继承的方式是,通过创建SuperType的实例对象,并把这个实例对象赋值给SubType.prototype。实现的本质是重写了SubType的默认原型对象。这样一来,SuperType实例的所有属性和方法都存在与SubType.prototype之中了。上述代码的实例、构造函数和原型对象之间的关系如下图所示。

  通过实现原型链,访问一个实例对象的属性时,如果实例上没有该属性,就会沿着原型链进行寻找直到找到属性或者原型链结束为止。以上述代码为例,访问obj实例对象的getSuperName方法的过程:1、在实例上寻找;2、实例上面没有,在SubType.prototype上寻找;3、还是没有,寻找SuperType.prototype,找到了。

  原型链继承还需要注意一下几点:

  1. 默认原型对象
    事实上前面例子展示的原型链少了一环,我们知道,所有引用对象都默认继承了Object,这也是JS万物皆对象的原因。因此,所有函数的默认原型都会包含一个指针指向Object. prototype。这也所有自定义类型都会继承toString()valueOf()等默认方法的原因。下图展示了完整的原型链。
  1. 确定原型和实例的关系   两个方法可以判断两个对象是否是实例与原型关系。第一种方式是instanceof操作符。
    console.log(obj instanceof SubType);  //true
    console.log(obj instanceof SuperType);//true
    console.log(obj instanceof Object;    //true    

  第二个方式是Object.prototype的方法isPrototypeOf

    console.log(Object.prototype.isPrototypeOf(obj));   //true
    console.log(SuperType.prototype.isPrototypeOf(obj));//true
    console.log(SubType.prototype.isPrototypeOf(obj));  //true

  由上诉代码的输出可以得知,实例对象obj是原型链上所有原型的实例,且原型链上所有的原型都是实例对象obj的原型

  1. 原型链继承的问题
    前面介绍过包含引用类型值的原型属性会被所有实例继承,在不重写原型属性的情况下,原型属性就相当于静态属性被所有实例共享。这也正是为什么要在构造函数内定义对象,而不是在原型对象上定义。下列代码可以充分反映,原型链继承的这个问题。
function SuperType() {
    this.superArr = [1,2,3];
}

function SubType() {
    this.name = 'Sub';
}

SuperType.prototype.getSuperArr = function(){
    return this.superArr;
}

SubType.prototype = new SuperType();

SubType.prototype.getSubName = function(){
    return this.name;
}

const instance1 = new SubType();
const instance2 = new SubType();
instance1.superArr.push(4);
instance2.superArr.push(5);
console.log(instance1.getSuperArr()); // 1,2,3,4,5
console.log(instance2.getSuperArr()); // 1,2,3,4,5

  两个实例都对原型上的数组进行修改,结果就是修改操作是共享的,而这往往不是我们想要得到的效果。

  原型链的第二个问题:在创建子类型的实例时,不能向超类型的对象传递参数。实际上,是不能在不影响所有子类型实例的对象的基础上,给超类型的构造函数传参。再加上引用值类型共享的问题,实践中很少单独使用原型链进行继承。

2.2、 借用构造函数

  这种继承方式很简单,不依靠原型链进行继承,而是在构造函数中调用原型对象的构造函数进行继承。可以通过callapply方法在新构造的对象上使用原型对象的构造函数。实现如下:

function SuperType() {
    this.superArr = [1,2,3];
}

function SubType() {
    //借用构造函数
    SuperType().call(this);
    this.name = 'Sub';
}

SuperType.prototype.getSuperArr = function(){
    return this.superArr;
}

SubType.prototype.getSubName = function(){
    return this.name;
}

const instance1 = new SubType();
const instance2 = new SubType();
instance1.superArr.push(4);
instance2.superArr.push(5);
console.log(instance1.getSuperArr()); // 1,2,3,4
console.log(instance2.getSuperArr()); // 1,2,3,5

  由上可知,借用构造函数后,在新的SubType实例对象上执行SuperType()函数中定义的所有初始化代码。因此两个实例都拥有一个superArr的副本。

  借用构造函数继承有以下两个需要注意:

  1. 传递参数
    相较于原型链继承,借用构造函数继承有一个很大的优势,就是可以在子类构造函数向超类构造函数进行传参。例子如下所示。

    function SuperType(name) {
        this.name = name;
    }
    
    function SubType(age) {
        //借用构造函数
        SuperType().call(this,'super');
        this.age = age;
    }
    
    const instance1 = new SubType(16);
    console.log(instance1.name); // super
    console.log(instance1.age);  // 16
    
  2. 借用构造函数的问题
    仅仅使用构造函数进行继承,会有一个最大的问题,子类型实例只能继承构造函数的初始化内容,无法继承原型对象的其他属性。同样的,这也导致构造函数继承很少单独使用。

2.3、 组合继承(伪经典继承)

  组合继承,也被称为伪经典继承,指的是将原型链继承和借用构造函数继承组合到一起,从而发挥两者特长的一种继承模式。主要思路是使用原型链继承原型对象的函数方法,并借用构造函数继承原型对象的属性。例子如下:

function SuperType(name) {
    this.name = name;
    this.superArr = [1,2,3]
}

SuperType.prototype.sayName = function(){
    console.log(this.name);
}

function SubType(name,age) {
    //继承属性,第二次调用SuperType()
    SuperType().call(this,name);
    this.age = age;
}
//继承方法,第一次调用SuperType()
SubType.prototype = new SuperType();

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

const instance1 = new SubType(16,Sub);
const instance2 = new SubType(18,Sub2);
instance1.superArr.push(4);
instance2.superArr.push(5);

instance1.sayName(); // super
instance1.sayAge();  // 16
console.log(instance1.superArr); // 1,2,3,4 
console.log(instance2.superArr); // 1,2,3,5

  通过组合继承,每个实例都继承了原型对象的所有方法,同时又拥有各自的原型属性。但是,仔细观察可以发现超类的构造函数调用了两次。

  实际上,组合继承让实例拥有各自的原型属性是通过借用构造函数对原型属性进行重写。因此,不是在超类构造函数中定义的引用型属性,在子类实例中其实还是全体共享的。

2.4、 寄生组合式继承

  寄生组合继承,就是通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。基本思路是:不必为了指定子类型的原型而调用超类型的构造函数,我们所需要的无非是超类型原型的一个副本而已。本质上,就是使用寄生式继承来继承超类型的原型,然后将结果指定给子类型的原型。基本模式如下所示。

function inheritPrototype(SubType,Supertype){
    const prototype = Object(SuperType.prototype); //创建对象
    prototype.constructor = SubType; //增强对象
    SubType.prototype = prototype //指定对象
}

  示例中的inheritPrototype函数是寄生组合继承的简单形式。函数接受两个参数:子类型的构造函数和超类型的构造函数。函数内部的第一步是,创建超类型构造函数的原型对象的副本、第二步是,为创建的副本添加constructor属性,以弥补因重写原型而失去的默认constructor属性、第三步是将原型副本指定给SubType。这样我们就可以用inheritPrototype函数代替组合式继承中为子类型原型赋值的语句了。例子如下所示。

function SuperType (name){
    this.name = name;
    this.superArr = [1,2,3];
}
    
SuperType.prototype.sayName = function(){
    console.log(this.name);
}

function SubType(name,age){
    //继承属性
    SuperType.call(this,name);
    this.age = age;
}

//继承方法
inheritPrototype(SubType,SuperType);

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

  寄生组合继承原型链结构如下:

  寄生组合继承,只执行了依次超类的构造函数,避免了在子类构造函数的原型中创建了多余的属性。与此同时原型链还可以正常使用,因此,开发人员普遍认为寄生组合式继承是引用类型最理想的继承方式。

总结

  JavaScript主要通过原型链实现继承。原型链的构建是通过将一个类型的实例赋值给另一个构造函数的原型实现的。原型链的问题式对象实例共享所有属性和方法,因此不适宜单独使用。

  除原型链外的其他继承模式。

  • 借用构造函数

      不重写子类构造函数继续继承,而是通过在子类构造函数内调用超类构造函数实现继承超类在构造函数内定义的属性。这种继承方式有一个致命的问题就是,无法继承超类在构造函数外定义的属性和方法。

  • 组合式继承

      使用原型链继承共享的属性和方法,使用超类的构造函数继承实例属性。这种继承方式的问题是,超类构造函数会被执行两次,同时会在子类构造函数的原型对象中创建不必要且多余的属性。

  • 寄生组合式继承

      基本于组合式继承相似,只是在使用原型链继承时,不是通过创建超类型的对象进行赋值,而是创建一个超类型构造函数的原型的副本,并将子类的构造函数赋值给这个副本,再将这个副本作为子类型构造函数的原型。这样就避免了一次超类构造函数的调用。这个方式是实现类型继承的最理想方式。