走近JavaScript继承的世界

吐槽君 分类:javascript

前言

食用这篇文章之前,建议先对原链型要有一定的了解。

解析JavaScript继承

继承是所有面向对象的语言必备的一个特性,但JavaScript继承实现与其他语言如Java、C++等的继承有很大不同。这到底是是什么原因呢?我们先来了解有关继承的一些概念。

在日常生活中,我们为了更好地了解万事万物,通常会选择给它们按照某种规则进行分类。比如说身边的一些事物:猫、狗、牛、羊等等,这些事物因具有某些共同的特征可以被归纳为动物。而编程的世界同样如此,某些具有相同属性和行为的对象都可以归为一类。

类与继承

在面向对象的语言里,你可以先定义一个类,然后在定义一个继承前者的类。后者通常被称为子类,前者则通常被称为父类。(注意:我们讨论的父类和子类并不是实例)这就好比父母与孩子,虽然孩子可以从父母那里继承许多特性,但二者之间没有直接上的联系。同理,子类相对父类来说就是一个独立并且完全不同的类。子类会包含父类行为的原始副本,但是可以重写所有继承的行为甚至定义新行为。事实上这种继承也被称为基于类的继承,其本质就是复制。

走近JavaScript继承的世界
如图所示,鸟继承了动物的属性与行为,并且也拥有自身独有属性而方法。

基于类的继承正是Java、C++等面向对象语言的继承方式,然而JavaScript的继承与此截然不同。JavaScript中是没真正意义上的类的,但因为受当时主流语言的C++、Java的影响,JavaScript的设计者将构造函数设计得像类的构造器一样(所以通常会将说类名即构造函数名),这正是混淆学习JavaScript继承的地方。JavaScript的构造函数是用来创建对象,而继承则是通过原型来实现的,因此JavaScript的继承也被称为基于原型的继承。

基于原型的继承

在原型链中我们曾聊到,当我们尝试去获取对象的某个属性值,但该对象并没有这个属性时,那么JavaScript会试着从原型对象中获取属性值。如果那个原型对象也没有该属性,那么再从它的原型中寻找,直至依次类推直到该过程最后到达终点Object.prototype,如果仍然没有找到就返回undefined。这正是JavaScript继承实现的思想所在。基于这种思想,我们可以把一些公共的属性和方法放置在原型上,谁想继承这些属性和方法,就让谁的[[Prototype]]属性指向这个原型。

基于原型的继承本质是关联,让两个对象通过原型关联起来,这样一个对象就可以通过委托的方式访问到另一个对象的属性和方法,从而实现继承。

最简单地总结一句:无论是何种继承,子类的实例能够获取到父类的属性和方法,那么它就是继承。

JavaScript继承方式

JavaScript继承有许多种方式,下面我们就一起来了解每种继承方式的实现以及其特点。

原型链继承

原型式继承的基本思路:子类的构造函数的prototype为父类构造函数实例化的对象,从而实现继承。

function Animal()
{
    this.types = ["Cat","Rabbit"];
    this.number = 11
}
Animal.prototype.sayName = function () 
{
    console.log(`My name is ${this.name}`);
}
function Dog(name)
{
    this.name = name;
}
Dog.prototype = new Animal();
let cheems = new Dog("cheems"); 
cheems.sayName(); // My name is cheems
console.log(cheems.number); // 11
console.log(cheems.types); // ["Cat","Rabbit"]
 

从上述代码可以看出,cheems借助自己的原型Dog.prototype继承了父类属性和方法,同时继承了父类构造函数原型对象Animal.Prototype的属性和方法。下面我们可以通下面这张图片来描述一下原型链继承
走近JavaScript继承的世界

如果你对原型链十分熟悉的话,不难看出如果让实例cheems通过constructor属性访问构造函数,得到的会是Animal。为什么呢?因为实例dog自身是没有constructor,而是通过委托访问到了Animal.prototypeconstructor属性。此外原型链式继承还存在着隐患:实例可以借助原型链来篡改原型对象上的引用类型的属性的值(当原型对象的属性为引用类型时,如果通过修改该引用类型的属性,会直接修改原型上面的属性),进而导致上述种情况的发生。

// 上面这段话有点绕口,结合代码你就秒懂
let doge = new Dog("doge");
console.log(cheems.types); // ["Cat","Rabbit"]
doge.types.push("Dog");
console.log(cheems.types); // ["Cat","Rabbit","Dog"]
 

走近JavaScript继承的世界

从代码到控制台都显示了,原型对象属性已经被篡改。这是为什么属性通常会在构造函数中定义而不会定义在原型上的原型。除上述外,原型链继承还有个缺点是在子类构造函数Dog实例化时,无法向父类Animal传参。

盗用构造函数(经典继承、借助call)

为了解决原型链继承中实例篡改原型属性的问题,盗用构造函数继承应运而生。基本思路:在子类构造函数调用父类构造函数,并将父类构造函数内的this绑定为实例。

function Animal(name)
{
    this.name = name
    this.types = ["Cat","Rabbit"];
}
Animal.prototype.sayName = function () 
{
    console.log(`My name is ${this.name}`);
}
function Dog(name)
{
    Animal.call(this,name);
    this.sayHello = function(){
        console.log("Hello!");
    }
}
let cheems = new Dog("cheems"); 
let doge = new Dog("doge");
doge.types.push("Dog");
console.log(cheems.types); // [ "Cat", "Rabbit"]
 

虽然盗用构造函数解决原型继承的问题(让属性从原型转移到了实例上,像极了基于类的继承),同样用一张图来描述:

image.png

子类在实例化时,通过调用父类构造函数并绑定this实现了继承,但这样的做法可以说是最不像基于原型的继承,也存在着缺点如下:

  1. 父类所有的方法都必须在构造函数中定义,且无法进行复用。
  2. 子类无法继承父类构造函数原型对象上的属性和方法。
cheems.sayName(); // TypeError: cheems.sayName is not a function  
 

组合继承

组合式继承综合了上述两种继承方式,将两者的有点集中到一起了。

function Animal(name)
{
    this.name = name
    this.types = ["Cat","Rabbit"];
}
Animal.prototype.sayName = function () 
{
    console.log(`My name is ${this.name}`);
}
function Dog(name)
{
    Animal.call(this,name);
}
Dog.prototype = new Animal();
// 重新设置constructor让它指向
Dog.prototype.constructor = Dog;
let cheems = new Dog("cheems"); 
let doge = new Dog("doge");
doge.types.push("Dog");
console.log(cheems.types); // [ "Cat", "Rabbit"]
cheems.sayName(); // My name is cheems
 

为了更好地理解同样是使用一张图来描述:
image.png

组合继承使用原型链继承实现继承父类构造函数原型上的属性和方法,而通过盗用构造函数继承父类属性和方法。这样既可以把方法定义在原型上以实现复用,又可以让每个实例都有自己的属性。

组合继承虽然弥补了原型链和盗用构造函数的不足,但同样存在着问题,至于是什么,等到讲解寄生组合继承时会说明。

以上三种继承方式都使用了构造函数,从下面开始我们将不再使用构造函数来实现继承。

原型式继承

ES5提供了一个方法Object.create(),能帮我们实现原型继承。原型式继承适用于:假设你有一个对象,想在它的基础再创建一个新的对象,

// Object.create()简单实现
function object(o)
{
    function F(){}
    F.prototype = o;
    return new F();
}
 
let dog = {
    type:"Dog",
    colors:["Black","White"]
}
let cheems = Object.create(dog);
cheems.name = "cheems";
console.log(cheems.type); // Dog
console.log(cheems.color);// ["Black","White"]
 

原型式继承思想很简单:让对象通过原型获得属性和方法,与原型链继承有异曲同工之妙。照例放图:

走近JavaScript继承的世界

cheems通过委托的方式继承了dog属性和方法。和原型链继承的缺点一样,会出现实例篡改原型对象属性的情况。

寄生式继承

寄生式继承的思路:创建一个实现继承的函数,以某种方式增强对象,然后返回对象。

function create(obj){
    let newObj = Object.create(obj);
    newObj.sayName = function(){
        console.log("Hi");
    }
    return newObj;
}
let dog = {
    type:"Dog",
    colors:["White","Black"]
}
let cheems = create(dog);
let dog = create(dog);
 

寄生式继承可以说是在原型式继承的基础上进行了封装,如果通过寄生式继承给对象添加方法,该方法无法进行复用。

寄生组合继承

在探究寄生组合继承前,我们先来了解一下组合式继承到底存在什么问题呢?

function Animal(name)
{
    this.name = name
    this.types = ["Cat","Rabbit"];
}
Animal.prototype.sayName = function () 
{
    console.log(`My name is ${this.name}`);
}
function Dog(name)
{
    Animal.call(this,name);
}
Dog.prototype = new Animal();
// 重新设置constructor让它指向
Dog.prototype.constructor = Dog;
let cheems = new Dog("cheems"); 
 

image.png

恭喜!盲生,你发现了华点。Dog.protype上同样出现name和types这两个属性。
我们并不期望这种情况的发生,那么该如何避免呢?此时寄生组合继承表示这个我会!

function Animal(name)
{
    this.name = name
    this.types = ["Cat","Rabbit"];
}
Animal.prototype.sayName = function () 
{
    console.log(`My name is ${this.name}`);
}
function Dog(name)
{
    Animal.call(this,name);
}
Dog.prototype = Object.create(Animal);
// 重新设置constructor让它指向
Dog.prototype.constructor = Dog;
let cheems = new Dog("cheems"); 
 

image.png

在实现子类构造函数原型继承父类构造函数原型的做法上使用寄生继承,就能较为完美的解决这个问题。老规矩上图:

image.png

寄生组合继承可以说是JavaScript中最佳的继承方式了。

总结

除上述六种外,ES6提供了一种新的继承方式extends关键字搭配class关键字,其底层的思想也是参考寄生式组合继承来实现的。至于更为细节的讲解,由于笔者自己还没完全熟悉ES6这部分的语法,所以请自行探究吧。

最后

有关JavaScript的继承的分享就到这里了,如果阅读完本篇文章后,你对JavaScript的继承有了更深一步地理解和认识的话,欢迎给笔者点赞鼓励?!你们的支持是我继续分享有关JavaScrit知识的动力。若有不妥之处欢迎在评论区指出。

参考文献:你不知道JavaScript,JavaScript继承机制的设计思想

回复

我来回复
  • 暂无回复内容