[转载]JS面向对象编程之继承

准备知识

为了更好的讲解继承,先把一些准备知识放在前面

1.构造函数,实例

构造函数,是用来创建对象的函数,本质上也是函数。与其他函数的区别在于调用方式不同:

  • 如果通过new操作符来调用的,就是构造函数
  • 如果没有通过new操作符来调用的,就是普通函数
function Person(name, age) {
   this.name = name;
   this.age = age;
 }
 //当做构造函数调用
 var person1 = new Person('Mike',10);

 //当做普通函数调用,这里相当于给window对象添加了name和age属性,这个不是重点,只要注意调用方式
 Person('Bob',12);

 console.log(person1)//Person {name: "Mike", age: 10}
 console.log(name)//Bob
 console.log(age)//12

var person1 = new Person('Mike',10);中,通过new操作符调用了函数Person,并且生成了person1,这里的Person就称为构造函数person1称为Person函数对象的一个实例。可以通过实例的constructor访问对应的构造函数(但是其实上这个constructor不是实例的属性,后面会解释为什么),看下面的例子:

function Person(name, age) {
    this.name = name;
    this.age = age;
}
var person1 = new Person('Mike',10);
var person2 = new Person('Alice',20);
console.log(person1.constructor)//function Person(){省略内容...}
console.log(person2.constructor)//function Person(){省略内容...}

2.原型对象

当我们每次创建一个函数的时候,函数对象都会有一个prototype属性,这个属性是一个指针,指向它的原型对象。原型对象的本质也是一个对象。初次看这句话可能有点难以理解,举个例子,还是刚刚那个函数:

 function Person(name, age) {
    this.name = name;
    this.age = age;
 }
 console.log(Person.prototype)//object{constructor:Person}

可以看到Person.prototype指向了一个对象,即Person的原型对象,并且这个对象有一个constructor属性,又指向了Person函数对象。是不是有点晕?没关系,接下来我们就上比举例子更好的手段--画图。

3.构造函数,原型对象和实例的关系

在前面,我们刚刚介绍过了构造函数,实例和原型对象,接下来我们用一张图来表示这三者之间的关系:

preview

从图上我们可以看到:

  • 函数对象的prototype指向原型对象,原型对象的constructor指向函数对象
  • 实例对象的[Protoptype]属性指向原型对象,这里的[Protoptype]内部属性,可以先理解为它是存在的,但是不允许我们访问(虽然在有些浏览器是允许访问这个属性的,但是我们先这样理解),这个属性的作用是:允许实例通过该属性访问原型对象中的属性和方法。比如说:
function Person(name, age) {
    this.name = name;
    this.age = age;
}
//在原型对象中添加属性或者方法
Person.prototype.sex = '男'; 
var person1 = new Person('Mike',10);
var person2 = new Person('Alice',20);
//只给person2设置性别
person2.sex = '女';
console.log(person1.sex)//'男'
console.log(person2.sex)//'女'

这里我们没有给person1实例设置sex属性,但是因为[Protoptype]的存在,会访问原型对象中对应的属性;
同时我们给person2设置sex属性后输出的是'女',说明只有当实例本身不存在对应的属性或方法时,才会去找原型对象上的对应属性或方法

  • 补充一下:ECMA-262第五版的时候这个内部属性叫[Prototype],而_proto_Firefox,Chrome和Safari浏览器提供的一个属性,在其他的实现里面,这个内部属性是没法访问的。所以我们能从控制台看到的是_proto_属性,但是我在文中用的还是[Prototype],个人认为这样较符合它的本质。
  • tips:这里刚好解释一下console.log(person1.constructor)时,说到的,可以通过实例的constructor访问构造函数,但是constructor本质上是原型对象的属性。

继承

原型链

在js中,继承的主要思路就是利用原型链,因此如果理解了原型链,继承问题就理解了一半。

原型链的原理是:让一个引用类型继承另一个引用类型的属性和方法。
先回顾一下刚刚讲过的知识:

  • 原型对象通过constructor属性指向构造函数
  • 实例通过[Prototype]属性指向原型对象

那现在我们来思考一个问题:如果让原型对象等于另一个构造函数的实例会怎么样?
例如:

function A() {
 
}
//在A的原型上绑定sayA()方法
A.prototype.sayA = function(){
        console.log("from A")
}
function B(){

}

 //让B的原型对象指向A的一个实例
 B.prototype = new A();
 
 //在B的原型上绑定sayB()方法
 B.prototype.sayB = function(){
        console.log("from B")
 }
 //生成一个B的实例
 var a1 = new A();
 var b1 = new B();
 
 //b1可以调用sayB和sayA
 b1.sayB();//'from B'
 b1.sayA();//'from A'

为了方便理解刚刚发生了什么,我们再上一张图:

preview

现在结合图片来看代码:

  • 首先,我们创建了AB两个函数对象,同时也就生成了它们的原型对象

  • 接着,我们给A的原型对象添加了sayA()方法

    然后是关键性的一步B.prototype = new A();,我们让函数对象Bprotytype指针指向了一个A的实例,请注意我的描述:是让函数对象Bprotytype指针指向了一个A的实例,这也是为什么最后,B的原型对象里面不再有constructor属性,其实B本来有一个真正的原型对象,原本可以通过B.prototype访问,但是我们现在改写了这个指针,使它指向了另一个对象,所以B真正的原型对象现在没法被访问了,取而代之的这个新的原型对象是A的一个实例,自然就没有constructor属性了

  • 接下来我们给这个B.prototype指向的对象,增加一个sayB方法

  • 然后,我们生成了一个实例b1

  • 最后我们调用了b1sayB方法,可以执行,为什么?
    因为b1[Prototype]属性可以访问B.prototype里面的方法;

  • 我们调用了b1sayA方法,可以执行,为什么?
    因为b1沿着[Prototype]属性可以访问B.prototypeB.prototype继续沿着[Prototype]属性访问A.prototype,最终在A.prototype上找到了sayA()方法,所以可以执行

所以,现在的结果就相当于,b1继承了A的属性和方法,这种[Prototype]不断把实例和原型对象联系起来的结构就是原型链。也是js中,继承主要的实现方式。

问一下几个问题:

  1. 在最后一个例子里,console.log(b1.constructor),结果是什么?

    function A,因为b1本身没有constructor属性,会沿着原型链向上找到B.prototype对象,然后再往上找到A.prototype对象,此时找到了constructor属性,也就是指向函数对象A,可参见上文最后一张图片。

  2. B.prototype = new A();B.prototype.sayB = function(){ console.log("from B") }这两句的执行顺序能不能交换?

    不能,因为我们说过了,第一句是改写B函数对象的prototype指向的原型对象,如果我们交换了顺序,是在原先的B的原型对象上绑定了方法,然后再把指针指向新的原型对象,那新的原型对象上自然就没有绑定sayB方法,接下来的b1.sayB()就会报函数未定义错误。

  3. 在最后一个例子里,A看似已经是原型链的最顶层,那A还能再往上吗?

    可以,因为其实所有的引用类型都默认继承了了Object,也就是说,完整的原型链应该是A.prototype的[Prototype]属性指向Object.prototype。

    如图:

    完整的原型链

顺便补充一下,Object.prototype上的原生方法,包括我们常用的hasOwnProperty()isPropertyOf()等。

原型链的缺陷
  1. 引用类型的值在原型链传递中存在的问题
    我们知道js中有值类型和引用类型,其中引用类型包括ObjectArray等,引用类型的值有一个特点:在赋值的时候,赋给变量的是它在内存中的地址。换句话说,被赋值完的变量相当于一个指针,这会有什么问题呢?看例子:

    function A() {
    	this.name = "a" 
    	this.color = ['red','green'];         
    }
    function B(){
        
    }
    //让B的原型对象指向A的一个实例
    B.prototype = new A();
     
    //生成两个个B的实例
    var b1 = new B();
    var b2 = new B();
    //观察color属性
    console.log(b1.name)//a
    console.log(b2.name)//a
    console.log(b1.color)//[red,green]
    console.log(b2.color)//[red,green]
    //改变b1的name和color属性
    b1.name = 'b'
    b1.color.push('black')
    
    //重新观察color属性
    console.log(b1.name)//b
    console.log(b2.name)//a
    console.log(b1.color)//["red", "green", "black"]
    console.log(b2.color)//["red", "green", "black"]
    

    发现问题了吗?我们修改了b1colorname属性,但是b2name属性不变,color属性发生了改变。

    为了搞清楚这里问题,请尝试回答我的问题:

    1. b1和b2有自己的color属性吗?

      没有,只是B.prototype上有color属性,因为它是A的一个实例,b1和b2其实是通过[Prototype]属性访问B.prototype上的color属性(指针),从而访问和操作color数组的;

    2. b1和b2有自己的name属性吗?

      答案:一开始都没有,当执行了b1.name = 'b'时,相当于b1有了自己的name属性,而b2依然没有name属性。

    所以以上问题的原因来源就是我们前面说的:引用类型的值在赋值的时候,赋给变量的是它在内存中的地址。
    所以在原型链中如果A(其实就是继承中的父类型)含有引用类型的值,那么子类型的实例共享这个引用类型得值,容易造成属性的修改混乱,也就是上面的color数组,这就是原型链的第一个缺陷。

  2. 第二个缺陷是:在创建子类型的实例(如b1,b2)时,无法向父类型的构造函数中传递参数。比如在上面的例子中,如果Aname属性是要传递参数的而不是写死的,那么我们在实例化b1b2的时候根本没法传参

借用构造函数继承

为了解决引用类型值带来的问题,我们会采用借用构造函数继承的方式,又名伪造对象或者经典继承,核心思路是:我们在子类型的构造函数中调用父类型的构造函数,这里要用到一个方法call()或者apply()函数,关于这个函数,可以简单的理解它的功能就是允许一个对象调用另一个对象的方法。具体实现如下:

function A() {
    this.name = "a" 
    this.color = ['red','green'];         
}
function B(){
	//“借用”|就体现在这里,子类型B借用了父类型A的构造函数,从而在这里实现了继承
	A.call(this);
}

//生成两个个B的实例
var b1 = new B();
var b2 = new B();
//观察color属性
console.log(b1.name)//a
console.log(b2.name)//a
console.log(b1.color)//['red','green']
console.log(b2.color)//['red','green']
//改变b1的name和color属性
b1.name = 'b'
b1.color.push('black')

//重新观察属性
console.log(b1.name)//b
console.log(b2.name)//a
console.log(b1.color)//['red','green','black']
console.log(b2.color)//["red", "green"]

在这里我们没有采用原型链,而是利用call()方法来实现在子类型的构造函数中借用父类型的构造函数,完成了继承,这样继承的结果就是:b1b2都分别拥有自己的namecolor属性(可以直接console.log(b1)查看对象的属性),也就是b1和~完全独立的。这就解决了之前的第一个问题,而且传递参数的问题其实也可以解决,再稍微改一下A函数:

//这里name改成传递参数的
function A(name) {
    this.name = name 
    this.color = ['red','green'];         
}
function B(name){
  //在这里我们接受一个参数,并且通过call方法传递到A的构造函数中
  A.call(this,name);
}

//生成两个个B的实例
var b1 = new B('Mike');
var b2 = new B('Bob');
//观察属性
console.log(b1.name)//Mike
console.log(b2.name)//Bob
console.log(b1.color)//['red','green']
console.log(b2.color)//['red','green']

其实上面就可以直接写成这样,但是为了让大家更容易理解,故意分开,隔离变量,顺便再解释一下A.call(this,name);就是让this对象(这里是指B)调用构造函数A,同时传入一个参数name

可以看到,借用构造函数继承不会有原型链继承的问题,那为什么不都借用采用构造函数继承的方法呢?原因在于:这种继承方式,所有的属性和方法都要在构造函数中定义,比如我们这里也要绑定之前的sayA()方法并继承,就只能写在A的构造函数里面,而写在A.prototype的的方法,没法通过这种方式继承,而把所有的属性和方法都要在构造函数中定义的话,就不能对函数方法进行复用.

组合继承

学习了原型链的继承和借用构造函数的继承后,我们可以发现,这两种方法的优缺点刚好互补:

  • 原型链继承可以把方法定义在原型上,从而复用方法
  • 借用构造函数继承法可以解决引用类型值的继承问题和传递参数问题

因此,就自然而然的想到,结合这两种方法,于是就有了下面的组合继承,也叫伪经典继承,(前面的借用构造函数是经典继承,可以联系起来),具体实现如下:

function A(name) {
    this.name = name 
    this.color = ['red','green'];     
}
A.prototype.sayA = function(){
	console.log("form A")
}
function B(name,age){
    //借用构造函数继承
    A.call(this,name);
    this.age = age;
}

//原型链
B.prototype = new A();
B.prototype.sayB = function(){
	console.log("form B")
}

//生成两个个B的实例
var b1 = new B('Mike',12);
var b2 = new B('Bob',13);
//观察color属性
console.log(b1)//{name:'Mike'...}
console.log(b2)//{name:'Bob'...}
b1.sayA()//from A
b2.sayB()//from B

这个例子只是对上面的例子稍作修改:

  1. 我们在A.prototype上定义了sayA() ,在B.prototype 定义了sayB()
  2. 我们增加了B.prototype = new A();原型链

最终实现的效果就是,b1b2都有各自的属性,同时方法都定义在两个原型对象上,这就达到了我们的目的:属性独立,方法复用,这种继承的理解相对简单,因为就是把前两种继承方式简单的结合一下,原型链负责原型对象上的方法,call借用构造函数负责让子类型拥有各自的属性

组合继承是js中最常用的继承方式。

缺点:调用了两次父类型的构造函数,导致子类型的原型对象中增添了不必要的父类型的实例对象中的所有属性。

原型式继承

原型式继承与之前的继承方式不太相同,原理上相当于对对象进行一次浅复制,浅复制简单的说就是:把父对像的属性,全部拷贝给子对象。但是我们前面说到,由于引用类型值的赋值特点,所以属性如果是引用类型的值,拷贝过去的也仅仅是个指针,拷贝完后父子对象的指针是指向同一个引用类型的。原型式继承目前可以通过Object.create()方式来实现,实现方式:
Object.create()接收两个参数:

  1. 第一个参数是作为新对象的原型的对象
  2. 第二个参数是定义为新对象增加额外属性的对象(这个是可选属性)
  3. 如果没有传递第二个参数的话,就相当于直接运行object()方法
    上面的说法可能有点拗口,换句话说:

比如说我们现在要创建一个新对象B,那么要先传入第一个参数对象A,这个A将被作为B.prototype;然后可以再传入一个参数对象CC对象中可以定义我们需要的一些额外的属性。来看例子

var A  = {
    name:'A',
    color:['red','green']
}

//使用Object.create方法先复制一个对象
var B = Object.create(A);
B.name = 'B';
B.color.push('black');

//使用Object.create方法再复制一个对象
var C = Object.create(A);
C.name = 'C';
B.color.push('blue');
console.log(A.name)//A
console.log(B.name)//B
console.log(C.name)//C
console.log(A.color)//["red", "green", "black", "blue"]

在这个例子中,我们只传入第一个参数,所以BC都是对A浅复制的结果,由于name是值类型的,color是引用类型的,所以ABCname值独立,color属性指向同一个对象。接下来举个传递两个参数的例子:

var A  = {
    name:'A',
    color:['red','green'],
    sayA:function(){
        console.log('from A');
    }
};

//使用Object.create方法先复制一个对象
var B = Object.create(A,{
    name:{
      value:'B'
    }
});
console.log(B)//Object{name:'B'}
B.sayA()//'from A'

这个例子就很清楚的表明了这个函数的作用了,传入的A对象被当做B的原型,所以生成B对象没有sayA()方法,却可以调用该方法(类似于通过原型链),同时我们在第二个参数中修改了B自己的name,所以就实现了这种原型式继承。原型式继承的好处是:如果我们只是简单的想保持一个对象和另一个对象类似,不必大费周章写一堆代码,直接调用就能实现。

优点:可以实现基于一个对象的简单继承,不必创建构造函数

缺点:与原型链中提到的缺点相同,一个是传参的问题,一个是属性共享的问题。

寄生式继承

寄生式继承和原型继承联系紧密,思路类似于工厂模式,即创建一个只负责封装继承过程的函数,在函数中根据需要增强对象,最后返回对象

function createA(name){
    //创建新对象
    var obj = Object(name);
    //增强功能
     obj.sayO = function(){
         console.log("from O")
     };
    //返回对象
	return obj;
}
var A = {
    name:'A',
    color:['red','green','blue']
};
//实现继承
var  B = createA(A);
console.log(B)//Object {name: "A", color: Array[3]}
B.sayO();//from O

继承的结果是B拥有A的所有属性和方法,而且具有自己的sayO()方法,效果和原型式继承很相似,可以比较一下寄生式继承和原型式继承的相似和区别。

优点: 在主要考虑对象而不是自定义类型和构造函数的情况下,实现简单的继承。

缺点:使用该继承方式,在为对象添加函数的时候,没有办法做到函数的复用。

寄生组合式继承

终于写到最后一个继承了,我们在之前讲了5种继承方式,分别是原型链,借用构造函数继承,组合继承,原型式继承,寄生式继承,其中,前三种联系比较紧密,后面两种也比较紧密,而我们要讲的最后一种,是和组合继承还有寄生式继承有关系的。

组合继承仍有缺陷

我们在之前说过,最常用的继承方式就是组合继承,但是看似完美的组合继承依然有缺点:子类型会两次调用父类型的构造函数,一次是在子类型的构造函数里,另一次是在实现原型链的步骤,来看之前的代码:

function A(name) {
    this.name = name 
    this.color = ['red','green'];     
}
A.prototype.sayA = function(){
	console.log("form A")
}
function B(name,age){
    //第二次调用了A
    A.call(this,name);
    this.age = age;
}

//第一次调用了A
B.prototype = new A();
B.prototype.sayB = function(){
	console.log("form B")
}

var b1 = new B('Mike',12);
var b2 = new B('Bob',13);
console.log(B.prototype)//A {name: undefined, color: Array[2]}

在第一次调用的时候,生成了B.prototype对象,它具有namecolor属性,因为它是A的一个实例;第二次调用的时候,就是实例化b1b2的时候,这时候b1b2也具有了namecolor属性,

我们之前说过,原型链的意义是:当对象本身不存在某个属性或方法的时候,可以沿着原型链向上查找,如果对象自身已经有某种属性或者方法,就访问自身的,但是我们现在发现,通过组合继承,只要是A里面原有的属性,B.prototype对象一定会有,b1b2肯定也会有,这样就造成了一种浪费:B.prototyope上的属性其实我们根本用不上,为了解决这个问题,我们采用寄生组合式继承。
寄生组合式继承的核心思路是其实就是换一种方式实现 B.prototype = new A();从而避免两次调用父类型的构造函数,官方定义是:使用寄生式继承来继承父类型的原型,然后将结果指定给子类型的原型。这句话不容易理解,来看例子:

//我们一直默认A是父类型,B是子类型
function inheritPrototype(B,A){
    //复制一个A的原型对象
    var pro  = Object(A.prototype);
    //改写这个原型对象的constructor指针指向B
    pro.constructor = B;
    //改写B的prototype指针指向这个原型对象
    B.prototype = pro;
}

这个函数很简短,只有三行,函数内部发生的事情是:

我们复制一个A的原型对象,然后把这个原型对象替换掉B的原型对象。

为什么说这样就代替了 B.prototype = new A();,不妨思考一下,我们最初为什么要把B的prototype属性指向A的一个实例?无非就是想得到Aprototype的一个复制品,然后实现原型链。而现在我们这样的做法,同样达到了我们的目的,而且,此时B的原型对象上不会再有A的属性了,因为它不是A的实例。因此,只要把将上面的 B.prototype = new A();,替换成inheritPrototype(B,A),就完成了寄生组合式继承。

寄生组合式继承保持了组合继承的优点,又避开了组合继承会创建不必要属性的缺陷,被认为是最理想的继承方式。

文章来源:

posted @ 2020-08-14 21:35  hello9102  阅读(54)  评论(0)    收藏  举报