这一次带你彻底搞懂JS继承

本文最后更新于:9 个月前

目录

前言

起步

“new” 究竟发生了什么?

类式继承(原型链继承)

构造函数继承

组合继承

原型式继承

寄生式继承

寄生组合式继承

总结


前言

这段时间复习JS从看懂到看开(前端面试题整合)_DieHunter1024的博客-CSDN博客时发现对继承概念又陌生了,平时大多用的都是extends,对底层知识难免会生疏,于是决定分享这篇文章,重新学习一下继承。

起步

JavaScript和面向类的语言不同,它没有类做对象的抽象模式,它能够不通过类直接创建对象,相比其他的面向对象语言,JavaScript才能算是真正的面向 “ 对象 “ 语言。在面向类的语言中构造函数通常是属于类的,而JavaScript中(在ES6之前),类是属于构造函数的,为什么这么说?因为我们使用的类实际上是用构造函数实现的。下面进入主题让我们聊聊继承。

继承作为面向对象程序设计特征之一,必定有其重要的意义

继承是指:在已存在的类的基础上,拓展出新的类。那么存在的类就是父类,或基类,超类;新的类就是子类,或派生类

其重要意义就是使代码可以复用,子类中也拥有父类的属性和方法,从父类一级一级往下,属性和函数由泛化到细化

那么js中的继承又是怎样的呢?

“new” 究竟发生了什么?

要了解继承,得先了解new,我们在node环境下看看以下案例

JavaScript中的类在es6之前,没有class语法糖时,用的是构造函数实现的,与class不同的是,构造函数既是类,也是函数,既可以使用 “函数名()” 的方式执行,也可以采用”new 函数名()” 的方式执行,这二者之间的效果却是截然不同,下面的例子中,使用 “函数名()” 的方式执行打印的是小暗,而另一个使用new的却打印了小明(这里我们是在node环境下执行的,function中的this指向的是全局的global,如果是在浏览器控制台执行,就需要把global换成window),由此可以得知 new 实际上是把构造函数原型(prototype)上的属性放在了原型链(__proto__)上,那么当实例化对象取值时就会在原型链上取,而实例化对象上的prototype已经不见了

global.name = "小暗";
function Person() {
  console.log(this.name);
}
Person.prototype = {
  name: "小明",
};
const fnReturn = Person(); // 小暗
const newReturn = new Person(); // 小明
console.log(fnReturn); // undefined
console.log(newReturn.name, newReturn.__proto__, newReturn.prototype); // 小明 { name: '小明' } undefined

我们可以简单理解为 new 实际上是将构造函数的prototype上的属性放在了实例化对象的__proto__上 ,通过实例化对象 . 属性名进行取值

那么new如何实现呢?

来看看下面的代码

exports.newClass = function () {
  const _target = new Object(); // 新增一个容器,用来装载构造函数(目标类)prototype上的所有属性
  const _this = this; //不能直接通过 this() 来运行构造函数,所以用一个变量装载
  _target.__proto__ = _this.prototype; // 核心部分:将构造函数prototype上的所有属性放到新容器中
  const result = _this.apply(_target, arguments); // 执行构造函数,相当于执行class中的constructor
  return result && typeof result === "object" ? result : _target; // 若函数返回值为引用类型返回当前函数执行结果,否则将新的容器返回,此时通过 _target[属性名]就可以访问 this.prototype 中的属性了
};

上述代码将 new 实现了一下,其中最重要的一步就是将构造函数prototype上的所有属性放到新容器中,最后获得的实例化对象的__proto__上就有了构造函数原型中所有属性了,下面我们放在之前的代码中看看效果

const { newClass } = require("./lib/new");
function Person() {
  console.log(this.name);
}
Person.prototype = {
  name: "小明",
};
const newReturn = new Person(); // 小明
const myNew = newClass.call(Person); // 小明
console.log(newReturn.name, newReturn.__proto__, newReturn.prototype); // 小明 { name: '小明' } undefined
console.log(myNew.name, myNew.__proto__, myNew.prototype); // 小明 { name: '小明' } undefined

说了这么多,其实目的是为了让大家知道:实例化一个构造函数,实际上可以简单理解为将类的prototype上的属性转移到实例化对象中,这样有助于理解后续的继承的实现,话不多说,直接开始

类式继承(原型链继承)

结合new的原理可以知道:类式继承实际上是通过new将SuperClass.prototype绑定到SuperClass.__proto__上,然后赋值给SubClass.prototype,当实例化SubClass时,SubClass.__proto__上也会带有SuperClass及其原型链上的属性,即SubClass实例化对象上有以下属性:SuperClass.prototype上的属性(实例化对象.__proto__.__proto__),SuperClass构造函数上的属性(实例化对象.__proto__),SubClass构造函数上的属性(实例化对象)

function classInheritance(SuperClass, SubClass) {
  SubClass.prototype = new SuperClass();
}
function SuperClass(props) {
  this.state = props;
  this.info = { color: "red" };
}
SuperClass.prototype = {
  name: "Car",
};
classInheritance(SuperClass, SubClass);
function SubClass() {
  this.price = 1000;
}
const BMW = new SubClass();
const BenZ = new SubClass();
console.log(BMW, BMW.__proto__, BMW.__proto__.__proto__); // { price: 1000 } { state: undefined, info: { color: 'red' } } { name: 'Car' }
console.log(BenZ.name, BenZ.info); // Car { color: 'red' }
BMW.info.color = "blue";
console.log(BenZ.name, BenZ.info); // Car { color: 'blue' }
console.log(BMW instanceof SubClass) // true
console.log(BMW instanceof SuperClass) // true

优点:简洁方便,子类拥有父类及父类prototype上属性

缺点:

  • 子类通过prototype继承父类,只能父类单向传递属性给子类,无法向父类传递参数。为什么要向父类传递参数?如果父类中的某属性对参数有依赖关系,此时子类继承父类就需要在newSuperClass()时传参
  • 当父类原型上的引用属性改变时,所有子类实例相对应的引用属性都会对应改变,即继承的引用类型属性都有引用关系
  • 子类只能继承一个父类(因为继承方式是直接修改子类的prototype,如果再次修改,会将其覆盖)
  • 继承语句前不能修改子类的prototype因为此类继承会覆盖子类原型

构造函数继承

在SubClass构造函数中使用SuperClass.call直接运行SuperClass构造函数,然而直接执行构造函数和使用new实例化构造函数二者是完全不同的:

  • 前者(直接执行构造函数)在下方代码中会将SuperClass构造函数里初始化的属性带到SubClass中,而SuperClass.prototype中的name属性并未带到SubClass中;

  • 而后者(使用new实例化构造函数)则会将SuperClass.prototype中的属性带到SuperClass实例化对象的__proto__上

function SuperClass(props) {
  this.state = props;
  this.info = { color: "red" };
}
SuperClass.prototype = {
  name: "Car",
};
function SuperClass2() {
  this.size = 'small';
}
// 注意:构造函数使用 call 会重写子类同名属性,要写在子类的最开始
function SubClass() {
  SuperClass.call(this, ...arguments);
  SuperClass2.call(this, ...arguments);
  this.price = 1000;
}
SubClass.prototype.name = "Small Car";
const BMW = new SubClass(true);
const BenZ = new SubClass(false);
console.log(BMW, BMW.__proto__, BMW.__proto__.__proto__); 
// SubClass {
//   state: true,
//   info: { color: 'red' },
//   size: 'small',
//   price: 1000
// } SubClass { name: 'Small Car' } {}
console.log(BenZ.name, BenZ.info, BenZ.state); // Small Car { color: 'red' } false
BMW.info.color = "blue";
console.log(BenZ.name, BenZ.info); // Small Car { color: 'red' }
console.log(BMW instanceof SubClass) // true
console.log(BMW instanceof SuperClass) // false

所以其优点是:

  • 可以在SuperClass执行时传参数

  • 可以继承多个父类

  • 继承同一个父类的子类的属性之间不会有引用关系(因为父类构造函数的执行是在每个子类中call(this)了,从而在父类构造函数执行时,this分别代表着每个子类)

缺点是:父类 prototype 上的属性无法继承,只能继承父类构造函数的属性,正是因为这点,父类的函数无法复用(指无法复用父类prototype中的函数,只能通过父类构造函数将函数放在子类中)

针对父类的函数无法复用的理解:

父类 SuperClass每次在子类SubClass中执行都会在每个子类重新初始化this.属性或this.函数,这些属性是属于每个子类单独的,这样既增加了性能负担又使父类原型中的公共属性无法复用;

而倘若这些函数或者属性在SuperClass的prototype上,并且子类能继承父类,则所有子类用公共属性的都是父类的,此时就达到了复用效果,而类式继承却能够实现这个效果,于是就有了下面的组合继承

组合继承

构造函数继承不能继承父类原型上的属性,而类式继承无法传参给父类,组合继承正好将两者规避了

然而组合继承在实例化父类和执行父类构造函数时执行了两次SuperClass,实际上类式继承是为了解决构造函数继承上的父类的prototype无法被子类继承的问题,看代码可以得知,newSuperClass()确实会将父类的prototype继承到子类中,但是也会将SuperClass构造函数中的操作又执行一遍(具体可看console.log(++count)执行了3次),而且类式继承是将子类的原型直接替换掉,所以无法继承多个父类的问题也被延续下来了(但是可以在父类上多加一次继承,使多个类形成原型链关系,达到多继承的目的,即A,B,C三个类,A要继承B和C,那么让A继承B再继承C)

function classInheritance(superClass, subClass) {
  subClass.prototype = new superClass();
}
let count = 0;
function SuperClass(props) {
  this.state = props;
  this.info = { color: "red" };
  console.log(++count);// 打印 1 2 3 
}
SuperClass.prototype = {
  name: "Car",
};
classInheritance(SuperClass, SubClass);
function SubClass() {
  SuperClass.call(this, ...arguments);
  this.price = 1000;
}
SubClass.prototype.name = "Small Car";
const BMW = new SubClass(true);
const BenZ = new SubClass(false);

console.log(BMW, BMW.__proto__, BMW.__proto__.__proto__);
// { state: true, info: { color: 'red' }, price: 1000 } { state: undefined, info: { color: 'red' }, name: 'Small Car' } { name: 'Car' }
console.log(BenZ.name, BenZ.info, BenZ.state);// Small Car { color: 'red' } false
BMW.info.color = "blue";
console.log(BenZ.name, BenZ.info);// Small Car { color: 'red' }
console.log(BMW instanceof SubClass) // true
console.log(BMW instanceof SuperClass) // true

优点:解决类式继承和构造函数继承的主要问题

缺点:父类构造函数执行两遍,性能损耗

原型式继承

原型式继承是基于类式继承的封装,特点和类式继承一样,继承的引用类型属性都有引用关系

原型式继承的过渡对象F实际上就是类式继承中的子类构造函数,这么做相比类式继承的特点:减少性能开销(子类是空白的构造函数,没有任何内容),对应的,无法在子类构造函数中初始化属性

是不是觉得原型式继承和Object.create( )很像? create 函数的原理就是生成一个新对象,这个新对象的 __proto__ 等于传入的对象。让我们回忆一下前面讲到的 new 的原理,new 实际上就是将prototype 放在实例化对象的__proto__ 上,不难理解,下面代码中 F.prototype = superClass 和newF() 做的就是这一步

function prototypeInheritance(superClass) {
  function F() {}
  F.prototype = superClass;
  return new F();
}
function SuperClass(props) {
  this.state = props;
  this.info = { color: "red" };
}
SuperClass.prototype = {
  name: "Car",
};
const superClass = new SuperClass(true);
const BenZ = prototypeInheritance(superClass);
const BMW = prototypeInheritance(superClass);
BMW.price = 2000;

console.log(BMW, BMW.__proto__, BMW.__proto__.__proto__); // { price: 2000 } { state: true, info: { color: 'red' } } { name: 'Car' }
console.log(BenZ, BenZ.name, BenZ.info, BenZ.state); // {} Car { color: 'red' } true
BMW.info.color = "blue";
console.log(BenZ.name, BenZ.info); // Car { color: 'blue' }
console.log(BMW instanceof SuperClass); // true

类式继承是如何转换成原型式继承?看以下代码是不是清晰了一点,所以原型式继承也可以写成constsubClass=Object.create(superClass)

function prototypeInheritance(SuperClass) {
  function SubClass() {}
  SubClass.prototype = new SuperClass();
  return new SubClass();
}

优点:无子类构造函数开销,相当于实现了对象的浅复制

缺点:

  • 继承时无法向父类传参
  • 和类式继承一样,继承父类的引用类型属性都有引用关系

寄生式继承

寄生式继承实际上是在上面的原型式继承的基础上做了二次封装,可以看成工厂模式+原型式继承,将继承步骤放在新的函数中,此时便可以在子类构造函数上添加子类独有的函数和属性,由此叫做寄生式继承,就好像子类独有的属性方法寄生在下面的parasiticInheritance函数中一样。使用这种继承在新建子类时,每个子类中的属性都不一样,违背了代码复用的效果

function prototypeInheritance(superClass) {
  function F() {}
  F.prototype = superClass;
  return new F();
}
function SuperClass(props) {
  this.state = props;
  this.info = { color: "red" };
}
SuperClass.prototype = {
  name: "Car",
};

function parasiticInheritance(superClass) {
  const subClass = prototypeInheritance(superClass);
  subClass.type = { electricity: true, gasoline: false };
  return subClass;
}

const superClass = new SuperClass(true);
const BenZ = parasiticInheritance(superClass);
const BMW = parasiticInheritance(superClass);
console.log(BenZ.type === BMW.type); // false  说明每个子类的属性都不一样
console.log(BenZ, BenZ.__proto__, BenZ.__proto__.__proto__);
// { type: { electricity: true, gasoline: false } } { state: true, info: { color: 'red' } } { name: 'Car' }
console.log(BMW, BMW.name, BMW.info, BMW.state); // { type: { electricity: true, gasoline: false } } Car { color: 'red' } true
BMW.info.color = "blue";
console.log(BMW.name, BMW.info); // Car { color: 'blue' }
console.log(BMW instanceof SuperClass); // true
console.log(BenZ instanceof SuperClass); // true

优点:

  • 无子类构造函数开销

  • 继承父类所有属性

  • 子类拥有自己的属性

缺点:

  • 继承时无法向父类传参

  • 和类式继承一样,继承父类的引用类型属性都有引用关系

  • 子类公共属性无法在原型上定义,导致无法复用

针对代码无法复用缺点的理解:让我们回忆一下上面的构造函数继承对代码复用的理解,子类构造函数中直接执行父类构造函数并改变this指向从而达到将父类属性初始化到子类中。而寄生式继承则是每次生成的子类都是新的构造函数F,所以在继承时单独给subClass增加属性实际上是操作不同的子类构造函数,而如果这个做法能在子类prototype中进行,那么子类的函数及属性可以复用。

寄生组合式继承

实际上上述继承方式都是实现最终继承方式的猜想和尝试,在ES6的class语法糖出现之前,寄生组合式继承是最理想的继承方式,下面让我们来看看

顾名思义寄生组合式继承就是寄生式继承和组合式继承的结合,个人认为叫它寄生组合式继承倒不如称其为原型组合式继承,因为他的写法就是原型式继承+组合式继承

作为ES6之前最理想的继承,我们当然是要深入分析一下,这么做到底好在哪?

我们按照标题寄生组合式继承实现一下这种继承的写法

// 之前写的原型式继承
function prototypeInheritance(superClass) {
  function F() {}
  F.prototype = superClass;
  return new F();
}

function parasiticCombinatorialInheritance(SuperClass, SubClass) {
  // 核心代码
  SubClass.prototype = prototypeInheritance(SuperClass.prototype);
  SubClass.prototype.superClass = SuperClass;
}
// 父类
function SuperClass(props) {}
// 子类
function SubClass() {
  this.superClass.call(this, ...arguments);
}
parasiticCombinatorialInheritance(SuperClass, SubClass);

乍一看,这种写法和组合式继承属实有点像,但是有一点不同:
prototypeInheritance 函数会生成一个只包含父类原型上属性而没有执行父类构造函数的 “纯净” 的新对象(即不执行父类构造函数)。
这句话怎么理解?
让我们结合一下new的原理,回忆一下类式继承或组合式继承是如何实现的:SubClass.prototype=newSuperClass()这样会导致子类 prototype 中既执行了父类构造函数,也有父类原型上的属性。而实际上我们是暂时不需要执行父类构造函数的,因为在组合式继承中还有一步:在子类中执行 SuperClass.call(this, …arguments) ,这一步会将父类构造函数再执行一次,将其二者结合,于是我们就得到了组合式继承的升级版:寄生组合式继承

我们将prototypeInheritance简写成Object.create,得到以下示例

function parasiticCombinatorialInheritance(SuperClass, SubClass) {
  SubClass.prototype = Object.create(SuperClass.prototype);
  SubClass.prototype.superClass = SuperClass;
}

function SuperClass(props) {
  this.state = props;
  this.info = { color: "red" };
}
SuperClass.prototype = {
  name: "Car",
};

function SubClass() {
  this.superClass.call(this, ...arguments); //调用一下父类构造函数,将父类的属性放在子类中
}

parasiticCombinatorialInheritance(SuperClass, SubClass);
SubClass.prototype.name = "small car";//修改prototype值写在继承后面
const BMW = new SubClass(true);
const BenZ = new SubClass(false);
console.log(BenZ, BenZ.__proto__, BenZ.__proto__.__proto__); // { state: false, info: { color: 'red' } } { superClass: [Function: SuperClass], name: 'small car' } { name: 'Car' }
console.log(BMW.info); // { color: 'red' }
BenZ.info.color = "blue";
console.log(BenZ.name,BenZ.info); // small car { color: 'blue' }
console.log(BenZ.name,BMW.info); // small car { color: 'red' }
console.log(BMW instanceof SuperClass); // true
console.log(BenZ instanceof SuperClass); // true

最后总结一下寄生组合式继承的优缺点

优点:解决了组合式继承的父类构造函数调用两次的问题,只创建了一次父类属性,并且子类拥有父类原型上的属性

缺点:多继承问题和子类prototype被修改(个人感觉后者可以适当调整赋值位置解决,而多继承问题可以考虑使用mixin进行优化)

看到这里,不知道你是否对JS继承有感触,觉得它和深复制有点像

不错,JS继承的类被继承时,其属性和行为也会被复制到子类中,JavaScript中没有类只有对象,而我们所说的类的继承,实际上是基于对象的深复制

想了解深复制和寄生组合式继承的同学可以跳到这篇文章代码

总结

以上就是JS继承的实现与使用,感谢你看到了最后,如果这篇文章有帮助到你,请支持一下作者,你的支持是我创作的动力

有需要源码的小伙伴可以看这里(有点乱):myCode: 一些小案例 - Gitee.com