JavaScript原型链系列3: 继承和属性屏蔽

899 阅读5分钟

《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,里面有三种继承关系

  1. 实例属性继承:首先是在子类中调用super(),这与拷贝式继承对应(不完全相同
  2. 原型方法的继承:我们在子类实例中能够调用父类中定义的原型方法,是两个prototype之间的继承,Son.prototype = Object.create(Father.prototype)
  3. 静态方法的继承:可以通过子类直接调用父类中的静态方法,是两个函数之间的继承: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):

  1. 如果源对象有目标属性,无论该属性是否可写,直接修改该属性
  2. 如果源对象没有目标属性
    1. 如果原型上也没有该属性,直接在源对象上新增该属性
    2. 如果原型上有该属性
      1. 如果该属性为数据属性(不是gettersetter)
        1. 如果该属性可写,就在源对象上添加该属性
        2. 如果该属性不可写,赋值语句静默失败
      2. 如果该属性为访问器属性,直接调用setter

在来分析一下上面的题目,源对象obj上没有num属性,但是原型对象proto上有num属性,并且该属性可写,因此就会在源对象obj上添加该属性(注意,num++相当于num = num + 1),在读取num时会查找proto.name,之后加1,因此obj.num变为2