这次一定能记住JavaScript中实现继承的这几种方式

524 阅读11分钟

目的

继承在JavaScript中算是比较重要的一环了。

无论是对基础完备性的考察,还是在平常的工作开发以及源码阅读时,继承都会有所体现,本文也是重新梳理并且带大家深刻认识一下常用的这几种继承方式的实现,解决死记硬背代码的尴尬。

image.png

继承的概念

百度百科对继承做出的概念:

继承是面向对象软件技术当中的一个概念,与多态、封装共为面向对象的三个基本特征。继承可以使得子类具有父类的属性和方法或者重新定义、追加属性和方法等。

这里我们也不做深究,拿一个我们比较容易理解的🌰来讲一下:子承父业。

不知道大家有没有看过那种古装剧,通常老皇帝在驾崩前就已经打下了一片江山,而小皇帝只要在他的父皇升仙后就可以继承这大好江山,而之后小皇帝的任务,就是在这个基础上去开疆扩土!

其实映射到我们程序中也是这样,我们的子类可以从父类身上拿到它已有的一些属性和方法进行使用,并且可以在这个基础之上去进行定义一下专属与自己的属性和方法。

image.png

JS中实现继承的方式

js中实现继承的方式主要是通过原型链来实现的,所以在这里我们有必要了解一些有关原型的知识。

/**
 * 构造函数
 * @param name 名字
 * @param age 年龄
 **/
function Person(name, age) {
  this.name = name || 'nothing';
  this.age = age || 18;
}

Person.prototype.say = function(){
    console.log('你瞅啥');
}
// 构造实例对象
const xiaoMing = new Person('xiaoMing', '18');

我们通过上面的案例来简单分析一下构造函数和实例对象之间的关系:

Mar-16-2021 07-45-00.gif

  1. 构造函数prototype属性指向原型对象,并且原型的contructor指向其构造函数
  2. 通过构造函数创建的实例对象身上有个__proto__属性也指向原型

结论:

构造函数.prototype === 实例对象.__proto__

当我们在访问一个对象身上的属性或者方法的时候,会有这样一个过程:

  1. 查找该对象本身是否有该属性或者方法,如果有就返回对应的值
  2. 没有,就会去该对象对应的原型对象上(即上图中,__proto__属性指向的空间)寻找,如果还没有,就去原型对象的原型上查找,这个查找的过程就是通过原型链来完成的,而通过原型链构造的这种查找关系就可以理解为继承

9Y9Jz.gif

原型链继承

ok,上面这些知识就是用来抛砖引玉的,我们继续往下看。

这时,如果我们想要用Person作为一个父类构造函数,通过继承它去创建一个子类Son,那么我们应该怎么做呢?

Mar-17-2021 06-41-35.gif

如上图所示,如果我们想完成子类Son对父类Person的继承,只需要将Son.prototype指向父类构造函数创建出来的实例 new Person() 即可,这种做法便是原型链继承

/**
 * 父类构造函数
 * @param name 名字
 * @param age 年龄
 **/
function Person(name, age) {
  this.name = name || 'nothing';
  this.age = age || 18;
  this.share = [1,2,3];
}

Person.prototype.say = function(){
    console.log('你瞅啥');
}

/**
 * 子类构造函数
 **/
function Son() {
  this.sex = 'man'
}

// 实现原型链继承
Son.prototype = new Person();

// 构建子类的实例对象
const liLei = new Son();

接下来让我们分析一下这种方式实现的继承有什么优缺点:

优点 - 思路简单清晰,代码实现也很简单

缺点

  1. 如果父类身上有引用值属性的话,那么子类实例之间会共享这个引用属性的值
// 省略上面的代码

// 构建两个子类实例
const liLei = new Son();
const meiMei = new Son();

// 给第一个实例的share属性赋值
liLei.share.push(4);

console.log(liLei.share) // [1,2,3,4]

console.log(meiMei.share) // [1,2,3,4]

image.png 其实原因也很简单,引用值指向的是同一块内存空间,所以子类之间的share属性用的都是这一块空间,也就造成了A操作这个空间的时候,B获取的值也会随之发生变化。

  1. 在创建子类实例的时候,没办法给父类函数传递参数

从上面的例子中我们可以看到,我们如果想给父类构造函数传递参数的话,只能在实现原型链继承的那一步去传递

image.png 无法在我们创建子类实例的时候把参数传给父类。

那么有办法解决这种继承的缺点吗? 答案是肯定的,这也就引出来下面一种继承方式↓

借用构造函数继承

借用构造函数这种继承方式也被大家称作经典继承

直接看代码吧~

/**
 * 父类构造函数
 * @param name 名字
 * @param age 年龄
 **/
function Person(name, age) {
  this.name = name || 'nothing';
  this.age = age || 18;
  this.share = [1,2,3];
}

Person.prototype.say = function(){
    console.log('你瞅啥');
}

/**
 * 子类构造函数
 **/
function Son(name) {
  // 将父类身上的属性偷过来放自己身上直接用
  Person.call(this,name)
  
  
  this.sex = 'man'
}


// 构建子类的实例对象
const liLei = new Son('liLei');
const meiMei = new Son('meiMei');

liLei.share.push(4);

console.log(liLei.share); // [1,2,3,4]
console.log(meiMei.share);// [1,2,3]

这种继承方式的关键就在于,怎么实现借用。从上文我们可以知道,这里我们使用的是通过call方法来实现的

  // 将父类身上的属性偷过来放自己身上直接用
  Person.call(this,name)

这里就不深入讲解call的作用和原理了,我们把重心放到继承上。大家可以简单理解为执行了上面这一行代码之后,子类构造函数Son就变成了下面这样:

function Son(name) {
 /** ===这里就是借来用的属性或者方法 === */
  this.name = name || 'nothing';
  this.age = age || 18;
  this.share = [1,2,3];
 /** ============================= */
  
  this.sex = 'man'
}

优点 总的一句来说就是弥补了原型链继承的缺点。

  1. 子类实例之间不会共享引用值属性(把别人的东西偷来自己家里就是自己的属性了,所以不会和别人共享了)
  2. 创建子类实例时可以传入参数 缺点 从上面的代码中我们也可以看出来,我们只是偷来了父类构造函数身上的属性和方法,但是他的亲戚--原型身上的方法我们没有偷来,所以在我们尝试访问liLei.say() 方法的时候就会报错:

image.png

无法访问父类原型对象身上的属性和方法

那还有办法解决这个缺点吗?

既然敢这样问,那就说明必定有,我们继续往下走!

image.png

组合继承

对于组合继承方式只需要说一句,大家应该就会了:

组合继承 = 原型链继承 + 借用构造函数继承

所以我们直接把上面实现继承的两行关键代码组合在一起就完事了

  // 实现原型链继承
  Son.prototype = new Person();
  // 将父类身上的属性偷过来放自己身上直接用
  Person.call(this,name)

image.png 完整代码如下:

/**
 * 父类构造函数
 * @param name 名字
 * @param age 年龄
 **/
function Person(name, age) {
  this.name = name || 'nothing';
  this.age = age || 18;
  this.share = [1,2,3];
}

Person.prototype.say = function(){
    console.log('你瞅啥');
}

/**
 * 子类构造函数
 **/
function Son(name) {
  // 将父类身上的属性偷过来放自己身上直接用
  Person.call(this,name)
  this.sex = 'man'
}

// 实现原型链继承
Son.prototype = new Person();
// 因为我们上一步实现中实例对象身上是没有constructor属性的
// 所以为了更加严谨的实现继承,需要在这里加上原型对象身上的构造函数的指向
Son.prototype.constructon = Son;

// 构建子类的实例对象
const liLei = new Son('liLei');
const meiMei = new Son('meiMei');

liLei.share.push(4);
// 可以访问原型身上的方法
liLei.say(); // 你瞅啥
console.log(liLei.share); // [1,2,3,4]
console.log(meiMei.share);// [1,2,3]

优点

  1. 子类的实例可以访问父类原型身上的属性和方法
  2. 子类的实例之间不会共享引用值属性

缺点

虽然组合继承填补了原型链继承和借用构造函数继承的缺点,但是它本身仍然存在这一个缺点:

调用了两次父类构造函数,造成了不必要的运行,并且产生了多余的属性,产生了内存的浪费

  1. 第一次调用: Son.prototype = new Person(),这时会将父类身上的属性挂载到子类原型上
  2. 第二次调用: Person.call(this,name),这时会将父类身上的属性偷来放在自己本身之上

image.png

那么有办法解决这个缺点吗?

当然是有的,不过在这之前,我们需要先去了解一下另外两种其他的继承方式。

原型式继承

道格拉斯·克罗克福德(Douglas Crockford) 原型式继承是一位叫道格拉斯·克罗克福德(Douglas Crockford)在一篇文章《JavaScript的原型式继承》(“Prototypal Inheritance in JavaScript")中提出的,在文章最后给了一个函数:

function object(o){
    // 创建临时的一个构造函数
    function F(){};
    // 将临时构造函数的原型指向传进来的对象o
    F.prototype = o;
    // 返回一个实例对象
    return new F();
}

当时大佬的出发点就是希望即时不通过自定义类型的方式也可以通过原型实现对象之间的信息共享。我对这句话的理解就是:他就采用上面这种,创建临时构造函数来实现继承,然后将实现继承的实例对象直接返回出去,就不用我们去再次的定义子类了,通过这个函数就可以直接拿到实现继承的对象(个人理解,欢迎大家讨论指正)

其实这个函数就是ES5中的Object.create()的基本实现。

具体使用方式如下

const obj = {
    name: 'fire',
    age: '22',
    share: [1,2,3,4]
}

const newObj = object(obj);
newObj.share.push(5);
console.log(newObj.name); // fire

const newObj2 = object(obj);
// 和原型链继承一样会共享引用值
console.log(newObj2.share); // [1,2,3,4,5]

缺点

  1. 和原型链继承一样会共享引用值

寄生式继承

这种继承方式可以认为是增强原型式继承

我们直接看代码,通过代码来剖析它

function objectPro(o){
    // 获取实现继承的子类实例对象
    const newObj = object(o);
    // 这里就是做的增强操作,可以给已经实现继承的对象增加一些方法和属性
    // 方便直接使用,类似工厂模式的操作
    newObj.say = function(){
        console.log('你瞅啥')
    }
    // 将增强后的对象返回
    return newObj;
}

其实我理解的这里的增强就是为了那些创建出来的的对象都有一些属性和方法,但是父类身上却没有,所以需要在继承完成之后来增加一下它,方便新创建来的实例对象直接使用。

const obj = {
    name: 'fire',
    age: '22',
    share: [1,2,3,4]
    // 这个对象身上没有say方法
}

// 但是我们在这里创建出来的对象,都需要有这个方法
// 所以我们就需要寄生式继承,来实现增强的功能
const veryNewObj = objectPro(obj);
const veryNewObj1 = objectPro(obj);
const veryNewObj2 = objectPro(obj);

veryNewObj.say();
veryNewObj1.say();
veryNewObj2.say();

veryNewObj.share.push(5);
console.log(veryNewObj.share); // [1,2,3,4,5]
console.log(veryNewObj1.share); // [1,2,3,4,5]

优点

  1. 增强了原型式继承,可以自定义添加属性和方法 缺点
  2. 依旧没有解决共享引用值

我们在上面组合式继承中说过它的缺点是调用了两次父类构造函数,那么下面这种继承方式就可以帮助我们解决,一起来看看吧

寄生组合式继承

我们看名字就可以知道这种继承方式就是

寄生组合是继承 = 寄生继承 + 组合式继承(原型链继承 + 借用构造函数继承)

/** 这个方法就实现了对原型对象的继承 **/
function nbPlus(parent, child){
    // 利用父类构造函数的原型对象构建出一个新的对象(寄生继承)
    // 在这里解决了调用两次父类的问题
    const newPrototype = objcet(parent.prototype);
    // 增强新的对象,增加constructor属性,使其指向子类构造函数
    newPrototype.constructor = child;
    // 将子类构造函数的原型执行新的 (原型链继承)
    child.prototype = newPrototype;
}
/**
 * 父类构造函数
 * @param name 名字
 * @param age 年龄
 **/
function Person(name, age) {
  this.name = name || 'nothing';
  this.age = age || 18;
  this.share = [1,2,3];
}

Person.prototype.say = function(){
    console.log('你瞅啥');
}

/**
 * 子类构造函数
 **/
function Son(name) {
  // 在这里实现父类本身属性和方法的借用(借用构造函数继承)
  Person.call(this,name);
  
  this.sex = 'man';
}


// 使用方式
nbPlus(Person, Son);

// 构建子类的实例对象
const liLei = new Son('liLei');
const meiMei = new Son('meiMei');

liLei.share.push(4);

console.log(liLei.share); // [1,2,3,4]
console.log(meiMei.share);// [1,2,3]

从上面的代码中我们可以看出,为了解决两次调用父类构造函数的问题,在nbPlus方法中,我们没有去像组合式继承那样再去通过new Person的方式去获取原型,而是直接通过构造函数的prototype属性去获取原型,并且基于这个原型去创建新的一个对象,作为子类的新原型,这就既可以避免重复执行的问题,也可以避免引用值共享的问题。

总结

ok,这篇关于继承的文章就到这里了

后续会继续把js基础的一些东西再整理一下输出,如果大家觉得有用的话,欢迎关注点赞~

我的所有文章都始发与我的公众号:除了技术还有生活,欢迎大家扫码关注!

分享技术还有我和猫的生活~

WechatIMG62.jpeg