从头创建一个"郎朗家族"3——带你深入了解继承
上期我们介绍了原型的概念,了解了js引擎在查找属性时,会顺着对象的隐式原型向上查找,找不到,则查找隐式原型的隐式原型,一直向上,直到找到null为止,这种查找关系,就是所谓的原型链。
今天,就让我们用上之前聊到的知识,在原版坚果的基础上,创造出属于我们的杂交坚果吧!(不知道从哪里混进来一个大喷菇)
我们很容易发现,既然它们都属于坚果,大多都继承了原版坚果的许多特性,并有着自己的一些新特性,那么,如果我们能把一部分原版坚果的属性直接套用在新杂交坚果上,就能省去很多属性的重复定义,实现复用。
先来个原版坚果,我们这里列出一些大多坚果都通用的属性。
function Nut() {
this.type = 'plant';
this.maxHealth = 4000;
this.health = 4000;
this.status = [4000,2666,1333]; // 残血变化外观,用一个数组来存储
this.shape = 'oval';
this.cost = 50;
this.color = 'brown';
}
我们定义了一些基础的属性,并用一个数组储存了坚果残血变化形态的阈值。接下来,我们从与原版坚果最像的火炬坚果开始入手。火炬坚果的血量、状态阈值、形状等都和原版坚果墙一样;不同的是,花费变为了100,新增了范围伤害特性,还能点燃豌豆。我们先用上期讲到的原型来实现继承的效果。
function Torch_nut() {
this.type = 'hybrid-plant';
this.cost = 100;
this.damage = 60;
}
Torch_nut.prototype = new Nut();
// 把Nut的实例赋值给Torch_nut.prototype
// Torch-nut.prototype.__proto__ = Nut.prototype 原型链继承 让子类实例能访问到父类的属性和方法
// 再在火炬坚果的构造函数的显式原型上增加点燃豌豆的方法
Torch_nut.prototype.ignite = function(peas) {
peas.forEach(pea => {
pea.ignition = true; // 假设 ignition 是一个表示点燃状态的属性
});
}
let n1 = new Torch_nut();
let n2 = new Torch_nut();
console.log(n1); // Nut { type: 'hybrid-plant', cost: 100, damage: 60 }
console.log(n1.status); // [ 4000, 2666, 1333 ]
n1.status.push(0)
console.log(n1.status); // [ 4000, 2666, 1333, 0 ]
console.log(n2.status); // [ 4000, 2666, 1333, 0 ]
通过原型链继承,我们使得火炬坚果继承到了父类原版坚果的属性,我们通过console.log(n1),可以访问实例对象上我们新定义的,显式具有的特性;同样,我们也能够访问到从父类继承得来隐式拥有的属性(比如status)。
现在,我们想给n1对象在status中单独添加一个0的元素,以期在它的血量为0时,新增一个死亡特效。但是,我们通过上面的代码发现,给n1的status数组中新增元素,还影响到了n2。按理说从一个模子里复刻出的两个对象,在他们创建后应该是互不干扰的,现在这样的情况并不符合逻辑。原来,n1和n2中的status属性是引用类型,对应的是同一个数组地址,所有实例对象引用的都是同一个数组,那么,当我们改变一个对象的中的数组后,所有实例对象也会一起受影响,这当然是不合理的,有没有什么办法避免这种情况呢?
接下来是第二种继承方法:构造函数继承。
Nut.prototype.getHealth = function () {
return this.health;
}
function Nut() {
this.health = 4000;
this.status = [4000,2666,1333];
}
function Torch_nut() {
Nut.call(this); // Torch_nut的this 指向实例对象 让 Nut 的 this 也指向这个实例对象 并调用 Nut 的构造函数
// 相当于把这两行代码写进来
// his.health = 4000;
// this.status = [4000,2666,1333];
this.type = 'hybrid-plant';
}
let n1 = new Torch_nut();
let n2 = new Torch_nut();
// console.log(n1.getHealth()); // TypeError: n1.getHealth is not a function 因为n1.getHealth 指向的是Nut的原型对象 但n1的原型对象是Torch_nut 所以找不到getHealth方法
n1.status.push(0)
console.log(n1.status); // [ 4000, 2666, 1333, 0 ]
console.log(n2.status); // [ 4000, 2666, 1333 ] 用的不是同一个 又创建了一个新的数组 所以不受n1.status.push(0)的影响
在构造函数继承方法中,我们通过在子类构造函数中,通过 Nut.call(this); 把父类构造函数的this也指向子类构造函数创建的实例对象,相当于把父类中的属性代码写进来了,每次创建一个火炬坚果对象,都会新创建一个status数组,这样就不会使对象直接互相影响了。但是!!当我们在实例对象上调用父类构造函数原型上定义的方法n1.getHealth时,会报错。因为n1的原型对象是Torch_nut,Torch_nut和Nut已经没有原型链上的关联了,所以不会顺着原型链去查找Nut原型上的getHealth方法。
通过这个例子,我们发现构造函数继承的方法,解决了子类实例互相影响的问题,但不能继承到父类原型上的属性。
那么,我们很容易就想到,既然前面提到的这两种方法互相可以弥补彼此的缺点,那我们试着把这两种方法结合起来。得到我们的第三种继承方法:组合继承(原型链继承+构造函数继承)
Nut.prototype.getHealth = function () {
return this.health;
}
function Nut() {
this.health = 4000;
this.status = [4000,2666,1333];
}
function Torch_nut() {
Nut.call(this);
this.type = 'hybrid-plant';
}
Torch_nut.prototype = new Nut();
let n1 = new Torch_nut();
let n2 = new Torch_nut();
console.log(n1.getHealth()); // 4000
console.log(n1);
n1.status.push(0)
console.log(n1.status); // [ 4000, 2666, 1333, 0 ]
console.log(n2.status); // [ 4000, 2666, 1333 ] 用的不是同一个 又创建了一个新的数组 所以不受n1.status.push(0)的影响
console.log(n1.constructor); // [Function: Nut] n1 是由 Torch_nut 创建的实例对象 但n1.constructor指向的是Nut
从输出结果可以看出,通过组合继承,构造函数继承方法不能继承到父类原型上的属性和原型链继承方法子类实例互相影响的问题都得到了解决,但是我们发现,打印n1时它的构造函数显示为Nut;当我们打印出实例对象的构造器时发现实例对象的构造器的指向变成了父类而非创建它的子类。让我们跟着原型链来看看为什么会这样:
n1.constructor === n1.proto.constructor === Torch_nut.prototype.constructor === Nut.prototype.constructor
实例对象n1的constructor等于它隐式原型上的constructor,等于构造函数显式原型上的constructor,等于构造函数的constructor。
所以,我们还需要加上一步,把实例对象的constructor指向子类。
Torch_nut.prototype.constructor = Torch_nut; // 重要!确保由Torch_nut创建的对象的 constructor 指向 Torch_nut
console.log(n1.constructor); // [Function: Torch_nut]
我们再来聊聊对象的继承,假设我们已有一个对象,想要照着这个对象批量生产,我们就可以通过Object.create()方法来实现,这种方法叫做对象的原型式继承,需要注意的是,这个方法使用的是浅拷贝,即多个实例之间继承到的引用类型是相同的地址,会相互影响:
let nut = {
type: 'hybrid-plant',
cost: 100,
damage: 60,
status: [4000,2666,1333],
getStatus: function() {
return this.status
}
}
let n1 = Object.create(nut)
let n2 = Object.create(nut) // 浅拷贝 只拷贝引用地址
n1.status.push(0)
console.log(n1.status); // [ 4000, 2666, 1333, 0 ]
console.log(n2.getStatus()); // [ 4000, 2666, 1333, 0 ]
下面是对象的寄生式继承,寄生式继承允许你扩展一个现有对象的功能,而不必创建一个子类(克隆的新对象多了一个getStatus()方法:
let nut = {
type: 'hybrid-plant',
cost: 100,
damage: 60,
status: [4000,2666,1333],
}
function clone(obj) {
let clone = Object.create(obj);
clone.getStatus = function () {
return this.status
};
return clone;
}
let n1 = clone(nut)
let n2 = clone(nut) // 浅拷贝 只拷贝引用地址
n1.status.push(0)
console.log(n1.status); // [ 4000, 2666, 1333, 0 ]
console.log(n2.getStatus()); // [ 4000, 2666, 1333, 0 ]
对象之间还是会互相影响,继承的引用类型都是只拷贝引用地址,看来只能用深拷贝来解决了。(深拷贝简单来说即遍历对象,遍历到的属性值是原始值类型,直接往新对象中赋值,如果是引用类型,递归创建新的子对象),这样就避免了不同对象引用同一个引用地址的问题。
回到类的继承,如果说组合继承还有值得优化的地方,那么就是由于把父类的实例赋值给子类的显式原型,父类的构造函数调用了两次,性能开销较大,显式原型和隐式原型都继承了同样的父类属性,但一般我们只需要隐式原型能够继承到父类属性就够了。下面就要用到我们的最终绝技——寄生组合继承:
Nut.prototype.getHealth = function () {
return this.health;
}
function Nut() {
this.health = 4000;
this.status = [4000,2666,1333];
}
function Torch_nut() {
Nut.call(this);
this.type = 'hybrid-plant';
}
Torch_nut.prototype = Object.create(Nut.prototype); // 复制一份父类原型对象给子类
Torch_nut.prototype.constructor = Torch_nut; // 重要!确保 constructor 指向 Torch_nut
let n1 = new Torch_nut();
let n2 = new Torch_nut();
console.log(n1.getHealth()); // 4000
console.log(n1);
n1.status.push(0)
console.log(n1.status); // [ 4000, 2666, 1333, 0 ]
console.log(n2.status); // [ 4000, 2666, 1333 ] 用的不是同一个 又创建了一个新的数组 所以不受n1.status.push(0)的影响
console.log(n1.constructor); // [Function: Torch_nut]
我们用上了之前提到的寄生继承,复制了一份父类原型对象,这样便无需再去执行两遍父类的构造函数,同时,注意确保 constructor 指向子类,我们便解决了前面的三大问题。
下面,了解一下ES6引入的类class,它是一种更接近面向对象编程风格的方式来定义类和实现继承。使用 class
关键字可以更直观地创建类和实例化对象。
class Nut {
constructor(type) { // 通过 constructor 来接收参数
this.type = type
}
getType() { // 等同于 Nut.prototype.getType = function() {} 往构造函数的原型上添加方法
return this.type; // 指向实例对象 实例对象可以访问到构造函数原型上的方法
}
}
let n = new Nut('plant');
console.log(n); // Nut { type: 'plant' }
// Torch_nut 继承自 Nut
class Torch_nut extends Nut {
constructor(type) {
super(type);
this.damage = 60
}
}
let n1 = new Torch_nut('hybrid-plant');
console.log( n1.getType() ); // 输出 hybrid-plant
这段代码展现了如何使用 ES6 的 class
语法定义类,并且通过继承来扩展类的功能。Torch_nut
类继承了 Nut
类,并且可以访问和使用 Nut
类中定义的 getType
方法。
最后,来点轻松的测试一下自己吧,相信你已经掌握了继承的方法了,我们来实现一些杂交坚果吧(坚果的技能效果函数简写即可)
先给出原版坚果墙
function Nut() {
this.type = 'plant';
this.maxHealth = 4000;
this.health = 4000;
this.status = [4000,2666,1333]; // 残血变化外观,用一个数组来存储
this.shape = 'oval';
this.cost = 50;
this.color = 'brown';
}
生瓜蛋子,除了type、cost和color属性其他与坚果墙都相同,还新增了一个damage伤害效果
function Watermelon_nut() {
Nut.call(this);
this.type = 'hybrid-plant';
this.cost = 150;
this.color = 'green';
this.damage = 80;
}
Watermelon_nut.prototype = Object.create(Nut.prototype);
Watermelon_nut.prototype.constructor = Watermelon_nut;
冰冻坚果
function Ice_nut() {
Nut.call(this);
this.type = 'hybrid-plant';
this.cost = 150;
this.color = 'blue';
this.damage = 40;
}
Ice_nut.prototype = Object.create(Nut.prototype);
Ice_nut.prototype.constructor = Ice_nut;
// 减速僵尸
Ice_nut.prototype.slowDown = function(zombie) {
zombie.speed *= 0.5;
};
// 亡语:冻结僵尸
Ice_nut.prototype.deathEffect = function(zombies) {
zombies.forEach(zombie => {
zombie.frozen = true; // 假设 frozen 是一个表示冻结状态的属性
});
};
钢刺坚果王继承到了高坚果的血量、阻挡僵尸跳跃效果,同时也继承到了地刺王的扎伤、爆胎效果;场上每有一株,价格增加100。实际上,JavaScript 本身并不支持传统的多继承,但我们可以通过组合的方式实现类似的效果,这里简单展示一下:
// 高坚果的属性为坚果墙的两倍
function Tall_nut() {
Nut.call(this); // 调用 Nut 构造函数初始化属性
this.maxHealth *= 2; // 高坚果的最大生命值翻倍
this.health *= 2; // 高坚果的当前生命值翻倍
this.status = this.status.map(value => value * 2); // 高坚果的残血状态值翻倍
this.cost = 125;
}
// 设置 Tall_nut 的原型为 Nut 的原型
Tall_nut.prototype = Object.create(Nut.prototype);
Tall_nut.prototype.constructor = Tall_nut; // 重置 constructor 指向 Tall_nut
// 阻挡僵尸跳过效果,省略
Tall_nut.prototype.blockJumpingZombie = function(){
}
// 地刺王
function Spikerock() {
this.type = 'plant';
this.damage = 40;
}
// 扎爆轮胎效果,省略
Spikerock.prototype.punctureTires = function(){
}
let spikerockTallNutCount = 0; // 用于跟踪场上钢刺坚果王的数量 每有一株价格加100
// 钢刺坚果王继承了上面两种植物的特性
function Spikerock_tall_nut()
Tall_nut.call(this); // 调用 Tall_nut 构造函数初始化属性
Spikerock.call(this); // 调用 Spikerock 构造函数初始化属性
this.cost = 300 + spikerockTallNutCount * 100; // 根据场上的数量计算成本
spikerockTallNutCount++; // 每调用一次构造函数,场上钢刺坚果王的数量+1
}
// 设置 SpikerockTallNut 的原型为 TallNut 的原型
SpikerockTallNut.prototype = Object.create(TallNut.prototype);
SpikerockTallNut.prototype.constructor = SpikerockTallNut;
// 添加 Spikerock 的方法
SpikerockTallNut.prototype.punctureTires = Spikerock.prototype.punctureTires;
我们通过调用两种构造函数来初始化属性,设置子类的原型为其中一个父类的原型,把constructor指回子类,再添加另一个父类上的方法,通过组合的方式实现了类似多继承的效果。
总结一下:继承是让子类的实例能够访问到父类上的属性和方法。实现继承的方法有
-
原型链继承 缺点:子类实例会继承同一个原型对象,内存共享,所以实例之间会相互影响
-
构造函数继承 缺点:不能继承到父类原型上的属性
-
组合继承 缺点:父类的构造函数调用了两次,性能开销大了,显式原型和隐式原型都继承了同样的父类属性
-
寄生组合继承
-
class继承
一般面试时问到怎么实现继承就直接用寄生组合继承来实现。
还有不少坚果尚未实现,自己动手试试吧!
如有错误,欢迎指正,万分感谢。
转载自:https://juejin.cn/post/7397024981572059175