🙌 最近回顾 js 继承的时候,发现还是对一些概念不是很清晰。这里再梳理一下 JS 中继承的几种主要的方式。想要更好的理解JS的继承方式,必须了解JS构造函数、原型对象、实例化对象、原型链等概念。
本文是小编在回顾JS的时候,经过查阅文档和自己的例子总结的,欢迎在评论区讨论。当然也希望点进来的盆友,不吝惜你们的大拇指,来个👍点赞➕收藏➕关注三连击,这是对小编继续分享文章最大的动力。前面我也分享了JS封装,感兴趣的可以看一下。这样看着更过瘾。
课前回顾:
对象:
想要搞清楚原型、原型链、继承这一堆概念之前首先要搞清楚对象是啥🌼
对象是单个食物的抽象
一本书、一辆汽车、一个人都可以是对象,一个数据库、一张网页、一个与远程服务器的连接也可以是对象。当实物被抽象成对象,实物之间的关系就变成了对象之间的关系,从而就可以模拟现实情况,针对对象进行编程。
对象是一个容器,封装了属性(property)和方法(method)
属性是对象的状态,方法是对象的行为。比如,我们可以把一个人抽象为Person对象,它有姓名、年龄、性别等属性,而跑步可以当作一个方法,属于人的一种行为。
var person = {
name: '马云',
age: 50,
sex: '男',
running: function(){
console.log('我要跑步')
}
}
对象原型和原型链
说到继承,就必须谈一谈原型和原型链
原型: 在javascript中,函数可以有属性。 每个函数都有一个特殊的属性:原型(prototype),属性值是一个对象。
- 每个函数上都有一个prototype属性;属性值是一个对象。
function Person() {}
console.log(Person.prototype)
- 在prototype原型对象中,默认存在一个constructor属性,属性值就是当前函数本身
获取实例对象obj的原型对象,有三种方法:
- obj.proto
- obj.constructor.prototype
- 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命令、直接调用构造函数?
-
构造函数内部使用严格模式,即第一行加上use strict。这样的话,一旦忘了使用new命令,直接调用构造函数就会报错。
-
构造函数内部判断是否使用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) //["跑步", "游泳", "画画"]
解析:
- lisa.sex = 'girl';相当于lisi给自己的实例添加了一个sex属性,不管之前有没有sex属性,所以打印出来都是girl;如果之前存在改属性的话,新添加属性的时候会覆盖掉之前的。
- jack.hobby.push('画画') 先找该属性,发现自己没有该属性,找到原型上的hobby属性,新增了一个。就导致lisa.hobby值也跟着改变了。因为lisa和jack共用一个原型对象。
- 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('马云','男');
✅优点: 可以继承父类所有的属性和方法;
❌缺点:
- 子类无法向父类传参;
- 如果要给子类的原型上新增属性和方法,就必须放在Child.prototype = new Parent()这样的语句后面;
- 父类原型链上的属性会被多个实例共享,这样会造成一个实例修改了原型,其他的也会改变。
构造函数继承
在子类构造函数内部使用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)
}
✅优点:
-
保证了原型链中引用类型值的独立,不再被所有实例共享;
-
子类可以向父类传递参数;
❌缺点: 构造函数继承只能继承父类的属性和方法,无法继承父类的原型。
组合继承
实现方法: 使用原型链实现对原型属性和方法的继承,通过借用构造函数来实现对实例属性的继承.
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()
✅优点:
- 可以继承父类实例属性和方法,也能够继承父类原型属性和方法;
- 弥补了原型链继承中引用属性共享的问题;
- 可传参,可复用
❌缺点:
- 使用组合继承时,父类的构造函数被调用了两次。
- 并且生成了两个实例,子类实例中的属性和方法会覆盖子类原型(父类实例)上的属性和方法,所以增加了不必要的内存。
寄生组合式继承
为了解决组合继承中的不足,寄生组合式继承应运而出了。
原理: 使用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);
✅优点:
- 公有的写在原型;
- 私有的写在构造函数;
- 可以向父类传递参数
- 不会重复调用父类;
❌缺点:
- 需要手动绑定 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这个关键字,既可以当作函数使用,也可以当作对象使用。在这两种情况下,它的用法完全不同。
-
- super作为函数调用时,代表父类的构造函数。ES6 要求,子类的构造函数必须执行一次super函数。
class Person {}
class Child extends Person {
constructor() {
super(); //代表调用父类的构造函数
}
}
注意,super虽然代表了父类Person的构造函数,但是返回的是子类Child的实例,即super内部的this指的是Child的实例,因此super()在这里相当于Person.prototype.constructor.call(this)。
作为函数时,super()只能用在子类的构造函数之中,用在其他地方就会报错。
-
- 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()
注意:
- 在子类的构造函数中,只有调用super之后,才可以使用this关键字,否则会报错。这是因为子类实例的构建,基于父类实例,只有super方法才能调用父类实例。
- 使用了extends实现继承不一定要constructor和super,因为没有的话会默认产生并调用它们
ES6继承总结
核心: ES6继承的实质是先将父类实例对象的属性和方法,加到this上面(所以必须先调用super方法),然后再用子类的构造函数修改this。
ES6的继承可以用下图来概括:
参考文档: