初学JavaScript:原型继承/盗用构造函数继承/组合继承/寄生式继承/原型式继承/寄生组合式继承

继承

简介

继承是面向对象编程中讨论最多的话题。很多面向对象语言都支持两种继承:接口继承和实现继承
前者继承方法签名,后者继承实际的方法。接口继承在 ECMAScript 中是不可能的,因为函数没有签名。实现继承是ECMAScript 唯一支持的继承方式,而这主要是通过原型链实现的。

通过原型链实现继承的主要思想是:通过原型对象继承多个引用类型的属性和方法。

====复习下构造函数、原型对象、实例对象的关系:每个构造函数都对应一个原型对象,通过prototype属性指向这个原型对象,这个原型对象中有一个属性construct指回构造函数,实例对象中有一个内部指针指向原型对象。
====复习下啥是原型链:当某一个构造函数对应的原型对象是另一个类型的实例,这就意味着这个原型对象本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另一个构造函数,这样就在实例和原型之间构成了一条原型链。

1、原型链继承

看如下例题,帮助理解原型链与原型继承

//构造函数:构造父类Animal
function Animal(){
    this.name = "animal";
}

//在Animal原型对象中定义方法
Animal.prototype.getAnimalName = function() {
    console.log(this.name + 'getAnimalName');
}

//构造函数,构造Dog
function Dog(){
    this.name = 'dog';
}

//让Dog继承Animal
Dog.prototype = new Animal();
/**
 * 上面这串代码的意思可以理解为将Animal的实例对象赋值给Dog的原型对象。如此就达到了继承的效果。
 */

/**
 * 不建议使用未被承认的_proto_属性
 * Dog.prototype.__proto__ === Animal.prototype
 * 因为双下划线的属性是js中的内部属性,各个浏览器兼容性不一,
 * 不建议直接操作属性,ES6中提供了操作属性的方法可以实现。
 */

// 在使用原型链继承的时候,要在继承之后再去原型对象上定义自己所需的属性和方法
Dog.prototype.getDogName = function(){
    console.log(this.name + 'getDogName');
}

//创建Dog实例对象
var dog1 = new Dog();
dog1.getAnimalName(); //调用继承自Animal中的方法
dog1.getDogName(); //调用自身的方法

分析:
在这里插入图片描述
如图所示:
(1)这里实现继承的关键是Dog的实例对象中的_proto_属性指向Dog原型对象,而这个Dog原型对象又是Animal原型对象的实例,也就是说Dog原型对象中的_proto_属性指向Animal.prototype原型对象,从而使得Dog实例对象间接的指向了Animal.prototype原型对象,这样就构成了一条原型链。在这个原型链中,Dog的实例不仅能够从Dog原型对象中继承属性和方法,还能从Animal原型对象中继承其拥有的属性和方法。

(2)并且此时Dog原型对象中的constructor指针重新指向了Animal构造函数,所以Dog实例对象中的constructor属性也指向了Animal构造函数,想要让constructor指回Dog构造函数,则需要修改Dog.prototype.constructor。

(3)如果想要读取实例上的属性或方法时,首先会在实例上搜索这个属性或方法,若没有找到,则会接着搜索实例的原型,这就是原型搜索机制。在通过原型链实现继承之后,搜索就可以继续向上搜索原型的原型,直到找到这个属性或方法。例如,这里调用dog1.getAnimalName()经过了三步搜索:首先找Dog实例对象有没有getAnimalName()方法,没找到,紧接着找Dog的原型对象有没有这个方法,发现也没有,继续向上找,找Animal的原型对象,哎,找到了该方法,开始调用。

默认原型

实际上,原型链中还有一环。默认情况下,所有引用类型都继承自 Object,这也是通过原型链实现的。任何函数的默认原型都是一个 Object 的实例,这意味着这个实例有一个内部指针指向Object.prototype。这也是为什么自定义类型能够继承包括 toString()、valueOf()在内的所有默认方法的原因。

判断原型与实例间是否为继承关系

原型与实例的关系可以通过两种方式来确定。

第一种方式是使用 instanceof 操作符,如果一个实例的原型链中出现过相应的构造函数,则 instanceof 返回 true。如下例所示:

//instanceof运算符用于检测构造函数的 `prototype` 属性是否出现在某个实例对象的原型链上。
console.log(d1 instanceof Object);  //true
console.log(d1 instanceof Animal);  //true
console.log(d1 instanceof Dog);     //true

第二种方式是使用 isPrototypeOf()方法。原型链中的每个原型都可以调用这个方法,只要原型链中包含这个原型,这个方法就返回 true,如下例所示:

console.log(Object.prototype.isPrototypeOf(d1)); // true 
console.log(Animal.prototype.isPrototypeOf(d1)); // true 
console.log(Dog.prototype.isPrototypeOf(d1)); // true

原型继承中的方法

在实现原型继承中,子类有时候需要覆盖父类的方法,或者增加父类没有的方法。为此,这些方法必须在原型赋值之后再添加到原型上。
如下:

//构造父类Animal
function Animal() {
  this.name = 'animal_';
}
//在Animal原型对象中添加getAnimalName方法
Animal.prototype.getAnimalName = function () {
  console.log(this.name + 'getAnimalName');
}
// 创建Animal的实例
var a1 = new Animal();
//实例会继承原型对象中的方法,所以a1可以调用getAnimalName()方法
a1.getAnimalName(); //animal_getAnimalName

//构造子类Dog
function Dog() {
  this.name = 'dog_';
}
//让子类继承父类,也就是让Dog的prototype属性指向Animal的实例对象,以此达到继承的目的
Dog.prototype = new Animal();

//给子类的原型对象中添加getDogName()方法
Dog.prototype.getDogName = function () {
  console.log(this.name + 'getDogName');
}
// 子类中再添加一个getAnimalName()方法,会覆盖父类同名的方法
Dog.prototype.getAnimalName = function () {
  console.log('我覆盖了父类的方法');
}

//创建Dog实例对象
var d1 = new Dog();
d1.getAnimalName(); // 我覆盖了父类的方法
d1.getDogName(); //dog_getDogName

在上面的代码中,getDogName()方法是只属于Dog对象的方法,父类Animal中没有;而getAnimalName()方法是原型链上已经存在的方法,所以在子类Dog中再次定义的时候会覆盖父类中的getAnimalName()方法。需要注意的是,上述两个方法都是在把原型赋值为 Animal 的实例之后定义的,也就是在完成Dog.prototype = new Animal();这句代码时定义的,就是实现子类继承父类后再定义的。

原型链的破坏

以对象字面量方式创建原型方法会破坏之前的原型链,因为这相当于重写了原型链。

如下:

function Animal() {
  this.name = 'animal';
}
Animal.prototype.getAnimalName = function () {
  console.log(this.name);
};
function Dog() {
  this.name = 'dog';
}
// 继承
Dog.prototype = new Animal();

//字面量方式再次创建原型对象,原来的原型就被覆盖了,这就成为另一个原型了。
Dog.prototype = {
  getDogName() {
    console.log(this.name);
  },
  someOtherMethod() {
    return false;
  }
};

var d1 = new Dog();
d1.getAnimalName(); // 出错!

在这段代码中,子类的原型在被赋值为 Animal 的实例后,又被一个对象字面量覆盖了。覆盖后的原型是一个 Object 的实例,而不再是 Animal 的实例。因此之前的原型链就断了。Dog和 Animal 之间也没有关系了。

原型继承的问题

原型链虽然是实现继承的强大工具,但它也有问题。
第一个主要问题是当原型对象中包含引用值时,引用值会被所有实例对象共享(这也是为什么属性通常会在构造函数中定义而不会定义在原型上的原因),在使用原型实现继承的时候,子类的原型实际上变成了父类的实例,这意味着子类原先的实例属性就变成了父类中的原型属性。
第二个主要问题是子类型在实例化时不能给父类型的构造函数传参。我们无法在不影响所有对象实例的情况下把参数传进父类的构造函数。

看如下例题:

//构造父类Animal,该构造方法中含有引用类型的属性categories
function Animal(name,age) {
    this.name = name
    this.age = age
    this.categories = ["cat", "rabbit"];
}

//构造子类Dog
function Dog(color) {
    this.color = color
 }

// Dog 继承 Animal 
Dog.prototype = new Animal();

//创建一个Dog实例,同时尝试往父类传参
var d1 = new Dog("白色");
console.log(d1.color);  // "白色"
console.log(d1.name); // undefined 因为没有在创建子类实例时传参数,所以为undefined
console.log(d1.age); // undefined 因为没有在创建子类实例时传参数,所以为undefined

console.log(d1.hasOwnProperty('color')) // true // 自身的属性
console.log(d1.hasOwnProperty('name')); // false // 这个是父类中有但子类实例却没有继承下来的属性
console.log(d1.hasOwnProperty('age')); // false // 这个是父类中有但子类实例却没有继承下来的属性

//Dog实例继承了父类Animal中的属性categories,所以可以给categories添加元素
d1.categories.push("dog");
console.log(d1.categories); // [ 'cat', 'rabbit', 'dog' ]

//再创建一个Dog实例
var d2 = new Dog();
// 可以发现categories属性共享
console.log(d2.categories); // [ 'cat', 'rabbit', 'dog' ]

分析:
在这个例题中,Animal构造函数定义了一个 categorys 属性,其中包含一个数组(引用值)。每个Animal 的实例都会有自己的 categorys 属性,包含自己的数组。但当 Dog 通过原型继承Animal 后,Dog.prototype变成了 Animal 的一个实例,因而也获得了自己的 categorys属性。最终结果是,Dog 的所有实例都会共享这个 categorys 属性。从这里的修改d1.categories会影响到d2.categories可以看出来。

2、盗用构造函数继承

简介

为了解决原型对象中包含引用类型数据导致引用值被所有对象共享的继承问题而产生的,这种方式也可以解决子类构造函数不能向父类构造函数传参的问题。
基本思路是在子类构造函数中调用父类构造函数。因为毕竟函数就是在特定上下文中执行代码的简单对象,所以可以使用apply()和 call()方法以新创建的对象为上下文执行构造函数。

  • 解决原型包含引用值造成的问题

具体操作如下:

function Animal() {
  this.categorys = ["cat", "rabbit"];
}

function Dog() {
  // 继承 Animal 
  Animal.call(this);
}

//创建Dog实例
var d1 = new Dog();

d1.categorys.push("dog");
console.log(d1.categorys); // [ 'cat', 'rabbit', 'dog' ]
var d2 = new Dog();
console.log(d2.categorys); // [ 'cat', 'rabbit']

我们分析下这两句代码function Dog(){Animal.call(this);}var d1 = new Dog();
在创建实例时,d1调用Dog构造函数,构造内部的this的值指向的是d1(在函数中谁用this调用属性或方法,this就代表哪个对象),Animal.call(this)也就是相当于Animal.call(d1);,也就是d1.Animal();。在d1调用Animal方法时,Animal内部的this就指向了d1实例对象。那么Animal内部this上的所有属性和方法都被拷贝到了d1上,因此,每个实例都具有自己的categories副本,并且互不影响。

  • 解决传参问题
    相比于使用原型链,经典继承函数的一个优点就是可以在子类构造函数中向父类构造函数传参。
function Animal(name) {
  this.name = name;
}

function Dog() {
  // 继承 Animal 并传参
  Animal.call(this, "zhangsan");
  // 实例属性
  this.age = 29;
}

var d = new Dog();
console.log(d.name); // zhangsan
console.log(d.age); // 29

传递过程如下:
在 Dog构造函数中调用 Animal 构造函数时传入一个参数,实际上会在 Dog 的实例上定义 name 属性。Animal 构造函数接收来自Dog构造函数传过来的参数 name,然后将它赋值给this指向的属性。
为确保 Animal 构造函数不会覆盖 Dog 定义的属性,可以在调用父类构造函数之后再给子类实例添加额外的属性。

盗用构造函数继承的问题

1、方法无法复用:由于在子类构造函数中创建父类实例对象时,每次都会重新调用一次父类构造函数,因此父类中的方法无法被复用,每个子类对象都会拥有一份独立的副本,从而导致内存空间的浪费。

2、无法继承父类原型对象上的属性和方法:由于在盗用构造函数继承中,子类并没有直接继承父类原型对象上的属性和方法,而是通过调用父类构造函数来创建一份与父类完全独立的副本。这意味着如果父类原型对象上新增或修改了某个属性或方法,子类并不会感知到这些变化,从而导致子类与父类之间的差异性增加。

3、不支持多继承:盗用构造函数继承的实现方式是基于调用父类构造函数来实现的,因此它无法同时继承多个父类的属性和方法。这在需要实现多重继承的场景下就显得非常不便。


总结一下盗用构造函数继承的特点:
1.创建的实例并不是父类的实例,只是子类的实例。
2.没有拼接原型链,不能使用instanceof。因为子类的实例只继承了父类的实例属性/方法,没有继承父类原型对象中的属性/方法。
3.每个子类的实例都持有父类实例方法的副本,浪费内存,影响性能,而且无法实现父类的实例方法的复用。


3、组合继承

简介

组合继承综合了原型链和经典继承函数,将两者的优点集中了起来。
基本的思路是使用原型链继承原型上的属性和方法,而通过经典继承函数继承实例属性。这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性。

实现

//父类Animal构造函数
function Animal(name) {
  this.name = name;
  this.categories = ["cat", "rabbit"];
}

//在父类原型对象中定义方法
Animal.prototype.sayName = function () {
  console.log(this.name);
};

//子类Dog构造函数,通过经典继承函数继承父类实例中的属性
function Dog(name, age) {
  // 继承属性
  Animal.call(this, name); // 第一次调用父类构造函数
  this.age = age;
}

//实现继承
Dog.prototype = new Animal(); // 第二次调用父类构造函数

//在子类原型对象中定义独有的方法
Dog.prototype.sayAge = function () {
  console.log(this.age);
};

//创建子类实例对象并传参
var d1 = new Dog("zhangsan", 29);
//给d1实例对象的categories数组类型属性添加元素(不会影响到d2对象)
d1.categories.push("dog");
console.log(d1.categories); // [ 'cat', 'rabbit', 'dog' ]
//调用d1中的独有方法sayAge()以及从父类中继承过来的方法sayName();
d1.sayName(); // zhangsan
d1.sayAge(); // 29 

//创建第二个Dog对象并传参
var d2 = new Dog("lisi", 27);
console.log(d2.categories); // [ 'cat', 'rabbit' ]
d2.sayName(); // lisi
d2.sayAge(); // 27

分析:
主要还是围绕这句话通过使用原型链继承原型上的属性和方法,并且通过经典继承函数继承实例属性。
在这个例子中,Animal 构造函数定义了两个属性,name 和 categorys,而它的原型上也定义了一个方法叫 sayName()。Dog 构造函数调用了 Animal 构造函数,传入了 name 参数,然后又定义了自己的属性 age。此外,Dog.prototype 也被赋值为 Animal 的实例。原型赋值之后,又在这个原型上添加了新方法 sayAge()。这样,就可以创建两个 Dog 实例,让这两个实例都有自己的属性,包括 categorys,同时还共享相同的方法。

组合继承弥补了原型链和经典继承函数的不足。而且组合继承也保留了 instanceof 操作符和 isPrototypeOf()方法识别合成对象的能力。

缺点是每次创建子类实例时都会调用两次父类构造函数,创建了两次父类实例对象,浪费内存

4、原型式继承

主要思想就是根据一个已有的对象,创建一个新的对象。

方式一:自定义一个函数实现

在Object()函数内部,先创建一个临时性的构造函数,然后将传入的对象作为这个构造函数的原型,最后返回这个临时类型的新实例。

本质是Ojbect()对传入其中的对象执行了一次浅复制

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

方式二:使用Object.create()方法实现

这个方法接收两个参数:一是用作新对象原型的对象,还有一个是为新对象定义额外属性的对象。
在传入一个参数的情况下,这个方法于object()方法的作用一致。
在传入第二个参数的情况下,指定的任何属性都会覆盖原型对象上的同名属性。

var person = {
    name:'xiaolizi',
    colors:['red','green','blue']
}
// 传入一个参数
var person1 = Object.create(person)

// 传入两个参数,第二个参数会覆盖原来的属性值
var person2 = Object.create(person,{
    name:{
        value : 'jack'
    }
})

console.log(person1.name);
console.log(person2.name);
console.log(person1.colors);
console.log(person2.colors); 
console.log(person === person1); // false 证明person1是一个新对象

优缺点

原型式继承的优点在于它比较简单、灵活,可以快速地创建对象,并从现有对象那里继承属性和方法。同时,由于原型式继承本质上是基于引用共享的机制,因此可以有效地节省内存空间。

但原型式继承也存在一些缺点,例如它会导致对象之间的关联性变得相对模糊,还容易出现同名属性和方法的覆盖问题,这些都需要在设计和开发过程中加以注意和处理。

5、寄生式继承

创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后返回这个对象

// 父类对象
let Father = {
    name:'ll',
    hobbies:['tennis','music'],
    getName:function(){
        console.log(this.name)
    }
}

// 寄生式继承
function createSon(obj,age){
    let newSon = Object.create(obj)
    // 强化属性
    newSon.age = age;
    // 强化方法
    newSon.getAge = function(){
        console.log(this.age);
    }
    return newSon
}

let son1 = createSon(Father,12)
console.log(son1);
son1.getName();
son1.getAge();

缺点是
(1)做不到函数复用
(2)对象识别难度大:不能够使用 instanceof 操作符来确定对象类型,因为它只能测试原型链中的构造函数。
(3)增加复杂性:通过寄生式继承创建对象时,必须记住做了什么,并确保不会在对象间混淆引用。这增加了复杂性,降低了可读性和可维护性。

6、寄生组合继承*(最完美继承)

使用盗用构造函数继承父类中的属性,将子类原型作为父类原型的属性,实现原型链继承,会解决2次调用父类函数以及复用率的问题

// 父类构造方法
function SuperType(name) {
    this.name = name;
    this.colors = ["red", "blue", "green"];
}

// 父类原型上的方法
SuperType.prototype.sayName = function () {
    console.log(this.name);
}

// 子类构造函数
function SubType(name, age) {
    SuperType.call(this, name);
    this.age = age;
}
// 将子类原型设置到父类原型上(这个是ES6之后添加上的方法)
Object.setPrototypeOf(SubType.prototype, SuperType.prototype)

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

const instance = new SubType('xiaohong', 18)
console.log(instance);
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值