js对象继承

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

1.原型链

ECMAScript 把原型链定义为 ECMAScript 的主要继承方式。其基本思想就是通过原型继承多个引用类型的属性和方法。重温一下构造函数、原型和实例的关系:每个构造函数都有一个原型对象,原型有一个属性指回构造函数,而实例有一个内部指针指向原型。如果原型是另一个类型的实例呢?那就意味着这个原型本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另一个构造函数。这样就在实例和原型之间构造了一条原型链。这就是原型链的基本构想。

实现原型链涉及如下代码模式:

// 创建Animal
function Animal() {
  this.name = 'animal';
}
Animal.prototype.getAnimalName = function () {
  console.log(this.name + 'getAnimalName');
}
// 创建Dog
function Dog() {
  this.name = 'dog';
}
// Dog继承自Animal  将Animal的实例赋值给Dog的原型对象,相当于将Animal的实例中的__proto__赋值给了Dog的原型对象
// 如此 Dog原型对象 就能通过 Animal 对象的实例中的[[prototype]](__proto__) 来访问到 Animal原型对象 中的属性和方法了。
Dog.prototype = new Animal();
// 不建议使用Dog.prototype.__proto__=== Animal.prototype,因为双下划线的属性是js中的内部属性,各个浏览器兼容性不一,不建议直接操作属性,ES6中提供了操作属性的方法可以实现。
console.log(Dog.prototype.__proto__ === Animal.prototype );
// 在使用原型链继承的时候,要在继承之后再去原型对象上定义自己所需的属性和方法
Dog.prototype.getDogName = function () {
  console.log(this.name + 'getDogName');
}
var d1 = new Dog();
d1.getAnimalName()
d1.getDogName()

以上代码定义了两个类型:Animal 和 Dog。

这两个类型分别定义了一个属性和一个方法。这两个类型的主要区别是通过创建 Animal 的实例并将其赋值给Dog的原型对象,所以Dog. prototype 实现了对 Animal 的继承。这个赋值重写了 Dog 最初的原型,将其替换为Animal 的实例。这意味着 Animal 实例可以访问的所有属性和方法也会存在于 Dog. prototype。这样实现继承之后,代码紧接着又给Dog.prototype,也就是这个 Animal 的实例添加了一个新方法。最后又创建了 Dog 的实例并调用了它继承的 getAnimalName方法。

下图展示了子类的实例与两个构造函数及其对应的原型之间的关系。

 

这个案例中实现继承的关键,是 Dog 没有使用默认原型,而是将其替换成了一个新的对象。这个新的对象恰好是 Animal 的实例。这样一来,Dog 的实例不仅能从 Animal 的实例中继承属性和方法,而且还与 Animal 的原型挂上了钩。于是 d1(通过内部的[[Prototype]] )指向Dog.prototype,而 Dog.prototype(作为 Animal 的实例又通过内部的[[Prototype]])指向 Animal.prototype。注意,getAnimalName()方法还在 Animal.prototype 对象上,而 name 属性则在 Dog.prototype 上。这是因为 getAnimalName()是一个原型方法,而name 是一个实例属性。Dog.prototype 现在是 Animal 的一个实例,因此 name才会存储在它上面。

还要注意,由于 Dog.prototype 的 constructor 属性被重写为指向Animal,所以 d1.constructor 也指向 Animal,想要指回Dog可以修改Dog.prototype.constructor。在读取实例上的属性时,首先会在实例上搜索这个属性。如果没找到,则会继承搜索实例的原型,这就是原型搜索机制。在通过原型链实现继承之后,搜索就可以继承向上,搜索原型的原型。对前面的例子而言,调用 d1.getAnimalName()经过了 3 步搜索:d1、Dog.prototype 和Animal.prototype,最后一步才找到这个方法。对属性和方法的搜索会一直持续到原型链的末端。

1.1.默认原型

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

 

Dog 继承 Animal,而 Animal 继承 Object。在调用 d1.toString()时,实际上调用的是保存在Object.prototype 上的方法。

1.2.原型与继承关系

原型与实例的关系可以通过两种方式来确定。第一种方式是使用 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

1.3.关于方法

子类有时候需要覆盖父类的方法,或者增加父类没有的方法。为此,这些方法必须在原型赋值之后再添加到原型上。来看下面的例子:

function Animal() {
  this.name = 'animal';
}
Animal.prototype.getAnimalName = function () {
  console.log(this.name + 'getAnimalName');
}
// 创建Animal的实例
var a1 = new Animal()
a1.getAnimalName(); //animalgetAnimalName
function Dog() {
  this.name = 'dog';
}
Dog.prototype = new Animal();
// 新方法
Dog.prototype.getDogName = function () {
  console.log(this.name + 'getDogName');
}
// 覆盖父类已有的方法
Dog.prototype.getAnimalName = function () {
  console.log('我覆盖了父类的方法');
}
var d1 = new Dog();
d1.getAnimalName(); // 我覆盖了父类的方法
d1.getDogName();

在上面的代码中。getDogName()方法 是 Dog 的新方法。而最后一个方法 getAnimalName()是原型链上已经存在但在这里被遮蔽的方法。后面在 Dog 实例上调用 getAnimalName()时调用的是这个方法。而 Animal 的实例仍然会调用最初的方法。重点在于上述两个方法都是在把原型赋值为 Animal 的实例之后定义的。

1.4.原型链的破坏

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

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 之间也没有关系了。

1.5.原型链的问题

原型链虽然是实现继承的强大工具,但它也有问题。主要问题出现在原型中包含引用值的时候。前面在谈到原型的问题时也提到过,原型中包含的引用值会在所有实例间共享,这也是为什么属性通常会在构造函数中定义而不会定义在原型上的原因。在使用原型实现继承时,原型实际上变成了另一个类型的实例。这意味着原先的实例属性摇身一变成为了原型属性。

function Animal() {
  this.categorys = ["cat", "rabbit"];
}
function Dog() { }
// 继承 Animal 
Dog.prototype = new Animal();
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', 'dog' ]

在这个例子中,Animal 构造函数定义了一个 categorys 属性,其中包含一个数组(引用值)。每个Animal 的实例都会有自己的 categorys 属性,包含自己的数组。但是,当 Dog 通过原型继承Animal 后,Dog.prototype 变成了 Animal 的一个实例,因而也获得了自己的 categorys属性。这类似于创建了Dog.prototype.categorys s属性。最终结果是,Dog 的所有实例都会共享这个 categorys 属性。这一点通过d1.categorys 上的修改也能反映到 d2.categorys上就可以看出来。

原型链的第二个问题是,子类型在实例化时不能给父类型的构造函数传参。事实上,我们无法在不影响所有对象实例的情况下把参数传进父类的构造函数。再加上之前提到的原型中包含引用值的问题,就导致原型链基本不会被单独使用。

2.经典继承

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

function Animal() {
  this.categorys = ["cat", "rabbit"];
}
function Dog() {
  // 继承 Animal 
  Animal.call(this);
}
 在var d1 = new Dog()时,是d1调用Dog构造函数,所以其内部this的值指向的是d1,所以Animal.call(this)就相当于Animal.call(d1),就相当于d1.Animal()。最后,d1去调用Animal方法时,Animal内部的this指向就指向了d1。那么Animal内部this上的所有属性和方法,都被拷贝到了d1上。所以,每个实例都具有自己的categorys属性副本。他们互不影响。
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' ]

我们在Dog的构造函数中展示了经典继承函数的调用。通过使用 call()(或 apply())方法,Animal构造函数在为 Dog 的实例创建的新对象的上下文中执行了。这相当于新的 Dog 对象上运行了Animal()函数中的所有初始化代码。结果就是每个实例都会有自己的 categorys 属性。

2.1.传递参数

相比于使用原型链,经典继承函数的一个优点就是可以在子类构造函数中向父类构造函数传参。

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

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

2.2.经典继承函数的问题

经典继承函数的主要缺点,也是使用构造函数模式自定义类型的问题:必须在构造函数中定义方法,因此函数不能重用。此外,子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。由于存在这些问题,经典继承函数基本上也不能单独使用。

总结:

1.创建的实例并不是父类的实例,只是子类的实例。

2.没有拼接原型链,不能使用instanceof。因为子类的实例只继承了父类的实例属性/方法,没有继承父类的构造函数的原型对象中的属性/方法。

3.每个子类的实例都持有父类的实例方法的副本,浪费内存,影响性能,而且无法实现父类的实例方法的复用。

3.组合继承

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

function Animal(name) {
  this.name = name;
  this.categorys = ["cat", "rabbit"];
}
Animal.prototype.sayName = function () {
  console.log(this.name);
};
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.categorys.push("dog");
console.log(d1.categorys); // [ 'cat', 'rabbit', 'dog' ]
d1.sayName(); // zhangsan
d1.sayAge(); // 29 
var d2 = new Dog("lisi", 27);
console.log(d2.categorys); // [ 'cat', 'rabbit' ]
d2.sayName(); // lisi
d2.sayAge(); // 27

在这个例子中,Animal 构造函数定义了两个属性,name 和 categorys,而它的原型上也定义了一个方法叫 sayName()。Dog 构造函数调用了 Animal 构造函数,传入了 name 参数,然后又定义了自己的属性 age。此外,Dog.prototype 也被赋值为 Animal 的实例。原型赋值之后,又在这个原型上添加了新方法 sayAge()。这样,就可以创建两个 Dog 实例,让这两个实例都有自己的属性,包括 categorys,同时还共享相同的方法。

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

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

北木南-

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值