《JavaScript高级程序设计》中提到过多种继承方法:原型链继承,借用构造函数,组合式继承,原型式继承等等,方式很多,让人傻傻 分不清楚。那么,这篇文章将从宏观角度对这些继承方式做一个梳理。
我个人认为,JS继承从继承手段上可以分为三类:
- 拷贝式继承
- 修改
prototype - 修改
__proto__指针
拷贝式继承
在我的这篇文章中提到过类似的概念,通过将父类的属性和方法拷贝到子类实例上来实现继承,在js中的实现方式就是借用构造函数。可以看下面的例子
function Father(a, b) {
this.a = a
this.b = b
this.fn = function() {}
}
const f = new Father(1, 2)
function Son(aa, bb) {
Father.call(this, 10, 20)
this.aa = aa
this.bb = bb
}
const s1 = new Son(11, 22)
const s2 = new Son(33, 44)
红宝书中也提到过,这种方法的问题在于每个子类实例上都有父类属性的一份独立拷贝,浪费空间,此外,各个子类上的属性和方法是独立的,不能实现函数的重用
// s1和s2两个实例的方法不是同一个引用
s1.fn === s2.fn // false
这种借用构造函数的继承方式实质上是一种对父类属性的拷贝,可以理解为一种拷贝式继承。
修改prototype
第二种继承方式是修改构造函数的prototype属性,在js中,这种方式有两种实现方法
1. Son.prototype = new Father()
这种方式被称为原型链继承
function Father() {
this.a = 'father'
}
Father.prototype.b = 'father prototype'
function Son() {
this.c = 'son'
}
Son.prototype = new Father() // 修改了Son.prototype
const son = new Son()
可以看出,子类构造函数的prototype属性被重写了,该属性被重写为一个Father实例,这样就实现了继承。
我们要注意一点,由于用父类的实例重写了子类构造函数的prototype属性,因此子类构造函数的prototype是有父类的实例属性的,因此一个子类实例能够访问子类的实例属性,父类的实例属性和父类的原型属性。此外,由于子类构造函数的prototype被重写,因此contructor属性会失真。
2. Object.create()
function Father() {
this.a = 'father'
}
Father.prototype.b = 'father prototype'
function Son() {
this.c = 'son'
}
Son.prototype = Object.create(Father.prototype)
const son = new Son()
从上面的代码可以看出,Object.create()和传统的原型链方式都通过修改prototype来实现继承,但是区别在于,Object.create(Father.prototype)创造的对象是一个空对象,没有任何属于自身的属性,这个空对象继承自Father.prototype,并用这个空对象重写了Son.prototype。
这种方式与原型链继承的区别在于,子类的prototype不再是父类的实例,因此子类实例不再能访问父类的实例方法。此外,由于prototype被重写,因此子类的contructor也会失真。
如果直接使用Object.create来创建一个对象,其实也有一个重写prototype属性的过程,这个方法的pollyfill如下
if (!Object.create) {
Object.create = function(proto) {
function F(){}
F.prototype = proto // 对中间函数prototype的重写
return new F()
}
}
修改__proto__
ES6后,我们可以使用Object.setPrototypeOf来修改__proto__属性,通过该方法,我们可以直接让子类原型继承自父类原型,不需要重写子类原型对象,因此constructor也不会失真。
ES6 extends
看完了前面几种基础的继承方式,我们来看看ES6中的extends关键字
class Father {
constructor() {
this.name = 'Father'
}
static log() {
console.log('static', this.name)
}
say() {
console.log('method', this.name)
}
}
class Son extends Father {
constructor() {
super()
this.name = 'Son'
}
}
const son = new Son()
一个简单的extends,里面有三种继承关系
- 实例属性继承:首先是在子类中调用
super(),这与拷贝式继承对应(不完全相同) - 原型方法的继承:我们在子类实例中能够调用父类中定义的原型方法,是两个
prototype之间的继承,Son.prototype = Object.create(Father.prototype) - 静态方法的继承:可以通过子类直接调用父类中的静态方法,是两个函数之间的继承:
Object.setPrototypeOf(Son, Father)
属性屏蔽
其实说到继承,就不得不提一下属性屏蔽的问题,但是很多博客上并没有提到过这部分内容。
我们都知道,在查找一个对象的某个属性时,如果源对象没有目标属性,就会沿着__proto__链,一直向上查找。在读属性时,属性屏蔽很容易理解:对象上的属性会屏蔽原型上的同名属性,但是在写属性时情况就比较复杂了。先来看一个问题吧:
let proto = {
num: 1
}
let obj = Object.create(proto)
obj.num++
console.log(obj)
console.log(proto)
答案是obj: { num: 2 }, proto: { num: 1 },你答对了吗?
如果没有专门了解过这部分的知识的话,这道题很容易出错。很多人可能会认为,读取的是原型上的属性,那么赋值是也就会修改原型上的属性,其实没有这么简单,属性屏蔽符合下面的规则(以下源对象指obj,原型对象指proto):
- 如果源对象有目标属性,无论该属性是否可写,直接修改该属性
- 如果源对象没有目标属性
- 如果原型上也没有该属性,直接在源对象上新增该属性
- 如果原型上有该属性
- 如果该属性为数据属性(不是
getter和setter)- 如果该属性可写,就在源对象上添加该属性
- 如果该属性不可写,赋值语句静默失败
- 如果该属性为访问器属性,直接调用
setter
- 如果该属性为数据属性(不是
在来分析一下上面的题目,源对象obj上没有num属性,但是原型对象proto上有num属性,并且该属性可写,因此就会在源对象obj上添加该属性(注意,num++相当于num = num + 1),在读取num时会查找proto.name,之后加1,因此obj.num变为2