JS继承【完整版】🧧

1,502 阅读16分钟

🙌 最近回顾 js 继承的时候,发现还是对一些概念不是很清晰。这里再梳理一下 JS 中继承的几种主要的方式。想要更好的理解JS的继承方式,必须了解JS构造函数、原型对象、实例化对象、原型链等概念。

本文是小编在回顾JS的时候,经过查阅文档和自己的例子总结的,欢迎在评论区讨论。当然也希望点进来的盆友,不吝惜你们的大拇指,来个👍点赞➕收藏➕关注三连击,这是对小编继续分享文章最大的动力。前面我也分享了JS封装,感兴趣的可以看一下。这样看着更过瘾。

课前回顾:

对象:

想要搞清楚原型、原型链、继承这一堆概念之前首先要搞清楚对象是啥🌼

对象是单个食物的抽象

一本书、一辆汽车、一个人都可以是对象,一个数据库、一张网页、一个与远程服务器的连接也可以是对象。当实物被抽象成对象,实物之间的关系就变成了对象之间的关系,从而就可以模拟现实情况,针对对象进行编程。

对象是一个容器,封装了属性(property)和方法(method)

属性是对象的状态,方法是对象的行为。比如,我们可以把一个人抽象为Person对象,它有姓名、年龄、性别等属性,而跑步可以当作一个方法,属于人的一种行为。

 var person = {
     name: '马云',
     age: 50,
     sex: '男',
     running: function(){
         console.log('我要跑步')
     }

 }

对象原型和原型链

说到继承,就必须谈一谈原型和原型链

原型: 在javascript中,函数可以有属性。 每个函数都有一个特殊的属性:原型(prototype),属性值是一个对象。

  1. 每个函数上都有一个prototype属性;属性值是一个对象。
function Person() {}
console.log(Person.prototype)

  1. 在prototype原型对象中,默认存在一个constructor属性,属性值就是当前函数本身

获取实例对象obj的原型对象,有三种方法:

  1. obj.proto
  2. obj.constructor.prototype
  3. Object.getPrototypeOf(obj) 推介使用
 function Person(){
     this.name = '马云'
 }
Person.prototype.getName = function(){
    console.log(this.name)
}
var p = new Person(); 

console.log(p.__proto__)
console.log(p.constructor.prototype)
console.log(Object.getPrototypeOf(p))

但是:

__proto__属性只有浏览器才需要部署,其他环境可以不部署;

obj.constructor.prototype在手动改变原型对象时,可能会失效。

原型对象的所有属性和方法都能被实例对象共享;

原型对象的作用: 就是定义所有实例对象共享的属性和方法;

原型链:

JavaScript 规定,所有对象都有自己的原型对象,原型对象也是对象,所以它也有自己的原型。因此,就会形成一个原型链(prototype chain):对象=>原型=>原型的原型

那么,Object.prototype对象有没有它的原型呢?回答是Object.prototype的原型是null。null没有任何属性和方法,也没有自己的原型。因此,原型链的尽头就是null。

我们发现他最后指向了他自己;指向自己就失去了原型链查找的意义,所以我们规定Object.prototype.proto === null;

Object.getPrototypeOf(Object.prototype) //null

Object.prototype的原型就是null,null没有任何属性和方法,也没有自己的原型。所以原型链的尽头是null.

function Parent(age) {
    this.age = age;
}
var p = new Parent(50);

p;	// Parent {age: 50}
p.__proto__ === Parent.prototype; // true
p.__proto__.__proto__ === Object.prototype; // true
p.__proto__.__proto__.__proto__ === null; // true

构造函数:

所谓的构造函数,它就是专门用来生成实例对象的函数,它就是对象的模板,描述对象的基本结构。 一个构造函数可以生成多个实例对象,这些实例对象都有相同的结构。

构造函数的特点:

  • 函数体内使用了this关键字,代表了所要生成的实例对象;
  • 生成对象的时候,必须使用new命令;
  • 为了与普通函数区别,构造函数名字的第一个字母通常大写;
function Person(){
     this.name = '马云'
 }

constructor 属性:

prototype对象有一个constructor属性,默认指向prototype对象所在的构造函数。

constructor属性的作用是,可以得知某个实例对象,到底是哪一个构造函数产生的。

function Person(){
     this.name = '马云'
 }
P.prototype.constructor === Person // true

由于constructor属性定义在prototype对象上面,意味着可以被所有实例对象继承。

function Person(){
     this.name = '马云'
 }
var p = new Person();

p.constructor === Person // true
p.constructor === Person.prototype.constructor // true
p.hasOwnProperty('constructor') // false

constructor属性表示原型对象与构造函数之间的关联关系,如果修改了原型对象,一般会同时修改constructor属性。

function Person(name) {
  this.name = name;
}

Person.prototype.constructor === Person // true

Person.prototype = {
  method: function () {}
};
//此处修改了构造函数的原型对象,所以constructor属性不再指向Person

Person.prototype.constructor === Person // false
Person.prototype.constructor === Object // true

建议:修改构造函数的原型对象时,一般要同时修改constructor属性的指向。

Person.prototype.constructor = Person;

instanceof 运算符:

instanceof运算符返回一个布尔值,表示对象是否为某个构造函数的实例。

var v = new Vehicle();
v instanceof Vehicle // true

instanceof运算符的左边是实例对象,右边是构造函数。它会检查右边构建函数的原型对象(prototype),是否在左边对象的原型链上。因此,下面两种写法是等价的。

v instanceof Vehicle
// 等同于
Vehicle.prototype.isPrototypeOf(v)

new 命令:

new命令的作用,就是执行构造函数,返回一个实例对象; 使用new命令的时候。根据需要,构造函数也可以接受参数。

 function Person(){
     this.name = '马云'
 }

 var p = new Person();
 
 console.log(p.name) //马云

new 命令的原理

使用new 命令时, 它后面的函数一次执行下面的步骤:

  • 创建一个空对象,作为将要返回的对象实例;
  • 将这个空对象的原型,指向构造函数的prototype属性;
  • 将这个空对象的赋值给函数内部的this关键子。
  • 开始执行构造函数内部的代码

也就是说,构造函数内部,this指的是一个新生成的空对象,所有针对this的操作,都会发生在这个空对象上 。

一个很自然的问题是,如果忘了使用new命令,直接调用构造函数会发生什么事?

这是,构造函数里面的this表示全局对象,而name也变成全局变量,所以name能打印出来,而p变成undefined

 function Person(){
     this.name = '马云'
 }

 var p = Person();
 console.log(p) //undefined
 console.log(name) //马云
 console.log(p.name) // 'name' of undefined

在开发的过程中,如何避免不使用new命令、直接调用构造函数?

  1. 构造函数内部使用严格模式,即第一行加上use strict。这样的话,一旦忘了使用new命令,直接调用构造函数就会报错。

  2. 构造函数内部判断是否使用new命令,如果发现没有使用,则直接返回一个实例对象。

如果构造函数内部存在return语句

如果return语句返回的是一个跟this无关的新对象,new命令会返回这个新对象,而不是this对象。

function Person(){
     this.name = '马云';
     return {age: 23}
 }

var p = new Person();
console.log(p) //{age: 23}

否则,就会不管return语句,都返回this对象。

 function Person(){
     this.name = '马云';
     return '马云'
 }

var p = new Person();
console.log(p) //Person {name: "马云"}

new命令总是返回一个对象,要么是实例对象,要么是return语句指定的对象。

new.target

函数内部可以使用new.target属性。如果当前函数是new命令调用,new.target指向当前函数,否则为undefined。

 function Person(){
     if(!new.target){
        throw new Error('请使用new 命令调用!')
     }else{
         this.name = '马云'
     }
 }

 // var p = new Person(); //马云
 var p =  Person();  //Uncaught Error: 请使用new 命令调用!

使用这个属性,可以判断函数调用的时候,是否使用new命令。

ES5继承

概念: 继承就是子类可以使用父类的所有功能,并且对这些功能进行扩展。

原型链继承

将子类的原型对象指向父类的实例。

function Parent(name, sex){
    this.name = name;
    this.sex = sex;
}
Parent.prototype.getInfo = function(){
    console.log(`name: ${this.name}`)
    console.log(`sex: ${this.sex}`)
}
function Child (){
    this.name = '我是child'
}
var parent = new Parent('马云','男');

var child = new Child();

console.log(parent)

console.log(child)

看到没?这时候parent和child没有任何关系。

但是正常来说parent和child不可能没有关系,child要继承parent的属性和方法了。

function Parent(name, sex){
    this.name = name;
    this.sex = sex;
}
Parent.prototype.getInfo = function(){
    console.log(`name: ${this.name}`)
    console.log(`sex: ${this.sex}`)
}
function Child (){
    this.name = '我是child'
}
var parent = new Parent('马云','男');

Child.prototype = parent; //子类的原型对象指向父类的实例

var child = new Child();

console.log(parent) //name: 马云   sex: 男

console.log(child) //name:我是child  sex: 男

parent.getInfo() 

child.getInfo() 

这个时候child继承了parent所有属性和方法。

虽然parent的所有属性和方法都在child的原型上,没有直接显示在实例上,但是实例可以直接访问原型上的属性和方法。

如果child自己有该属性,它会使用自己的属性,如果自己没有该属性,那么他会一层一层的找,直到找到为止。上面它在原型上找到了该属性。

原型继承的优缺点


function Parent(name, sex){
    this.name = name;
    this.sex = sex;
    this.hobby = ['跑步','游泳']
}
Parent.prototype.getInfo = function(){
    console.log(`name: ${this.name}`)
    console.log(`sex: ${this.sex}`)
}
function Child (name){
    this.name = name;
}
var parent = new Parent('马云','男');

Child.prototype = parent; 

var lisa = new Child('lisa');
lisa.sex = 'girl';

var jack = new Child('jack', 'boy');
jack.hobby.push('画画')

console.log(lisa)

console.log(jack)

console.log(lisa.sex) //girl
console.log(lisa.hobby) //["跑步", "游泳", "画画"]

console.log(jack.sex) //男
console.log(jack.hobby) //["跑步", "游泳", "画画"]


解析:

  1. lisa.sex = 'girl';相当于lisi给自己的实例添加了一个sex属性,不管之前有没有sex属性,所以打印出来都是girl;如果之前存在改属性的话,新添加属性的时候会覆盖掉之前的。
  2. jack.hobby.push('画画') 先找该属性,发现自己没有该属性,找到原型上的hobby属性,新增了一个。就导致lisa.hobby值也跟着改变了。因为lisa和jack共用一个原型对象。
  3. jack.sex 虽然new Child的时候传了boy值,但是Child构造函数并没有接收sex属性,所以自己没有该属性,找到原型上的sex属性打印出来。

有的盆友可能还对刚才的jack.hobby.push('画画')耿耿于怀,怎样才不会影响到原型,我们再举个例子:

function Parent(name, sex){
    this.name = name;
    this.sex = sex;
    this.hobby = ['跑步','游泳']
}
Parent.prototype.getInfo = function(){
    console.log(`name: ${this.name}`)
    console.log(`sex: ${this.sex}`)
}
function Child (name){
    this.name = name;
}
var parent = new Parent('马云','男');
Child.prototype = parent; 
var lisa = new Child('lisa');
lisa.sex = 'girl';

var jack = new Child('jack', 'boy');
jack.hobby = ['喝茶'];
jack.hobby.push('画画')
console.log(lisa.hobby) //["跑步", "游泳"]
console.log(jack.hobby) //["喝茶", "画画"]

jack.hobby = ['喝茶'];这里相当于jack给自己新增了一个hobby属性,然后给自己的hobby属性push,所以不会影响lisa.因为lisa使用的是原型上的hobby属性。 那么原型继承的优缺点一目了然了。

原型链继承总结

实现方法:子类的原型对象指向父类的实例。

Child.prototype = new Parent('马云','男'); 

✅优点: 可以继承父类所有的属性和方法;

❌缺点:

  1. 子类无法向父类传参;
  2. 如果要给子类的原型上新增属性和方法,就必须放在Child.prototype = new Parent()这样的语句后面;
  3. 父类原型链上的属性会被多个实例共享,这样会造成一个实例修改了原型,其他的也会改变。

构造函数继承

在子类构造函数内部使用call或apply来调用父类构造函数。

  • 通过call()、apply()或者bind()方法直接指定this的绑定对象, 如foo.call(obj)

  • 使用.call()或者.apply()的函数是会直接执行的

  • 而bind()是创建一个新的函数,需要手动调用才会执行

  • call()和.apply()用法基本类似,不过call接收若干个参数,而apply接收的是一个数组

先来一个最简单的构造函数继承:

function Parent(name, sex){
    this.name = name;
    this.sex = sex
}

function Child (name,sex){
    Parent.call(this,name, sex)
}
var child = new Child('child''girl');
console.log(child)

再来一个例子:

function Parent(name, sex){
    this.name = name;
    this.sex = sex
}

function Child (sex){
    this.sex = '女'
    Parent.call(this, 'child', sex)
    this.name = '我是子类'
}
var child = new Child('girl');
console.log(child)

有人要问了,为什么Child上的sex属性值为girl不是女,是因为this.sex = '女'被父类的sex属性覆盖了。this.name = '我是子类'相当于给自己定义了以一个属性。

我们修改一下父类的值,看别的实例会不会影响?

function Parent(name, sex){
    this.name = name;
    this.sex = sex;
    this.hobby = ['游泳','跑步'];
    this.desc = '我是描述'
}

function Child (name, sex){
    Parent.call(this, name, sex)
}
var lisa = new Child('lisa','girl');
lisa.hobby.push('唱歌')
lisa.desc= '我是lisa的描述'
var jack = new Child('jack','boy')

console.log(lisa)

console.log(jack)

看到没有?只修改了lisa的实例,没有影响到其他实例对象。

是不是瞬间感觉构造函数继承特爽,不会共享实例。

难道构造函数就没有缺点吗?

不不不?

你继续往下看


function Parent(name, sex){
    this.name = name;
    this.sex = sex;
    this.hobby = ['游泳','跑步'];
    this.desc = '我是描述'
}
Parent.prototype.getInfo=function(){
    console.log(this.name, this.sex)
}
function Child (name, sex){
    Parent.call(this, name, sex)
}
var lisa = new Child('lisa','girl');
lisa.hobby.push('唱歌')
lisa.desc= '我是lisa的描述'
var jack = new Child('jack','boy')
console.log(lisa)
console.log(jack)
lisa.getInfo() //报错


子类的原型上根本没有getInfo方法,所以就报错喽。

这说明一个问题: 构造继承只能继承父类的实例属性和方法,不能继承父类原型的属性和方法。

总结构造函数继承

实现方法: 在子类构造函数内部使用call或apply来调用父类构造函数

function Child () {
    Parent.call(this, ...arguments)
}

✅优点:

  1. 保证了原型链中引用类型值的独立,不再被所有实例共享;

  2. 子类可以向父类传递参数;

❌缺点: 构造函数继承只能继承父类的属性和方法,无法继承父类的原型。

组合继承

实现方法: 使用原型链实现对原型属性和方法的继承,通过借用构造函数来实现对实例属性的继承.

function Parent(name, sex){
  this.name = name;
  this.sex = sex;
  this.hobby = ['游泳','跑步'];
  this.desc = '我是描述';
  console.log('我是parent')
}
Parent.prototype.getInfo=function(){
  console.log(this.name, this.sex)
}
function Child (name, sex){
  Parent.call(this, name, sex)
  //继承父类构造函数的属性和方法
  
}

Child.prototype = new Parent();
//继承父类的原型

var lisa = new Child('lisa','girl');
console.log(lisa)
lisa.getInfo() //lisa girl

//修复constructor的指向
 Child.prototype.constructor = Child;

constructor它不过给我们一个提示,用来标识实例对象是由那个构造函数创建的。

有没有看出问题? 我们想要继承父类构造函数里的属性和方法采用的是构造继承,也就是复制一份到子类实例对象中,而此时由于调用了new Parent(),所以Child.prototype中也会有一份一模一样的属性,正常情况下,我们自己没有该属性的时候才会使用原型的属性和方法,但是原型的属性和方法和自己的一模一样。也就是说不可能使用原型的属性和方法。所以就会造成一种浪费资源。

总结组合继承 实现方法:

// 构造继承
function Child () {
  Parent.call(this, ...arguments)
}
// 原型链继承
Child.prototype = new Parent()

✅优点:

  1. 可以继承父类实例属性和方法,也能够继承父类原型属性和方法;
  2. 弥补了原型链继承中引用属性共享的问题;
  3. 可传参,可复用

❌缺点:

  1. 使用组合继承时,父类的构造函数被调用了两次。
  2. 并且生成了两个实例,子类实例中的属性和方法会覆盖子类原型(父类实例)上的属性和方法,所以增加了不必要的内存。

寄生组合式继承

为了解决组合继承中的不足,寄生组合式继承应运而出了。

原理: 使用Object.create()方法创建一个新的对象。

我们先复习一下Object.create()方法。🔻

语法: Object.create(proto, [propertiesObject]) //方法创建一个新对象,使用现有的对象来提供新创建的对象的proto。

参数:

  • proto : 必须。表示新建对象的原型对象,即该参数会被赋值到目标对象(即新对象,或说是最后返回的对象)的原型上。该参数可以是null, 对象, 函数的prototype属性 (创建空的对象时需传null , 否则会抛出TypeError异常)。

  • propertiesObject : 可选。 添加到新创建对象的可枚举属性(即其自身的属性,而不是原型链上的枚举属性)对象的属性描述符以及相应的属性名称。这些属性对应Object.defineProperties()的第二个参数。

返回值: 在指定原型对象上添加新属性后的对象。

function Person(desc){
  this.color = ['red'];
  this.desc = desc;
  console.log('哈哈')
}

Person.prototype.getName = function(){
  console.log(this.name);
}

Child.prototype = Object.create(Person.prototype);

function Child(name, age, desc) {
  this.name = name;
  this.age = age;
  Person.call(this,desc);
}

const Jack = new Child('Jack', 23, '我是Jack');

Jack.color.push('pink')

const Iric = new Child('Iric', 20, '我是Iric');

Iric.color.push('orange')

console.log(Jack);

Jack.getName();

console.log(Iric);

Iric.getName()

//修复constructor的指向
Child.prototype.constructor = Child;

总结组合继承

实现方法:Child.prototype = Object.create(Person.prototype);

✅优点:

  1. 公有的写在原型;
  2. 私有的写在构造函数;
  3. 可以向父类传递参数
  4. 不会重复调用父类;

❌缺点:

  1. 需要手动绑定 constructor (如果重写 prototype)

总结: ES5 的继承,实质是先创造子类的实例对象this,然后再将父类的方法添加到this上面(Parent.apply(this)).

ES5的继承可以用下图来概括:

ES6继承

Class 可以通过extends关键字实现继承,这比 ES5 的通过修改原型链实现继承,要清晰和方便很多 主要利用class配合extends与super实现继承;

class Person{
  constructor(name, age){
    this.name = name;
    this.age = age;
    this.color = ['red'];
  }
  getName(){
    console.log(this.name);
  }
}

class Child extends Person{
  constructor(name, age){
    super(name, age)    
  }
}

const Jack = new Child('Jack',20);
const Iric = new Child('Iric',23);
Jack.color.push('pink');
Iric.color.push('orange');
Jack.getName();
Iric.getName();
console.log(Jack);
console.log(Iric);

super super这个关键字,既可以当作函数使用,也可以当作对象使用。在这两种情况下,它的用法完全不同。

    1. super作为函数调用时,代表父类的构造函数。ES6 要求,子类的构造函数必须执行一次super函数。
class Person {}

class Child extends Person {
  constructor() {
    super(); //代表调用父类的构造函数
  }
}

注意,super虽然代表了父类Person的构造函数,但是返回的是子类Child的实例,即super内部的this指的是Child的实例,因此super()在这里相当于Person.prototype.constructor.call(this)。

作为函数时,super()只能用在子类的构造函数之中,用在其他地方就会报错。

    1. super作为对象时,在普通方法中,指向父类的原型对象;在静态方法中,指向父类。

super作为对象时,在普通方法中,指向父类的原型对象

class Person{
  p() {
    return 2;
  }
}

class Child extends Person{
  constructor(){
    super();
    console.log(super.p());  //2
  }
}
const c = new Child();

由于super指向父类的原型对象,所以定义在父类实例上的方法或属性,是无法通过super调用的。

在静态方法中,指向父类。

class Person{
  print() {
    console.log('haha');
  }
}

class Child extends Person{
  constructor(){
    super();
    
  }
  getName(){
    super.print();  //haha
  }
}
const c = new Child();
c.getName() 

注意:

  1. 在子类的构造函数中,只有调用super之后,才可以使用this关键字,否则会报错。这是因为子类实例的构建,基于父类实例,只有super方法才能调用父类实例。
  2. 使用了extends实现继承不一定要constructor和super,因为没有的话会默认产生并调用它们

ES6继承总结

核心: ES6继承的实质是先将父类实例对象的属性和方法,加到this上面(所以必须先调用super方法),然后再用子类的构造函数修改this。

ES6的继承可以用下图来概括:

参考文档:

  1. MDN 继承与原型链

  2. MDN 对象原型

  3. Javascript面向对象编程(二):构造函数的继承