JS 继承

200 阅读9分钟

JS 继承

最近这段时间空闲,再翻了翻书架上那本沾上灰尘的红宝书,刚好又翻到了继承这部分,又算是有一些新的感悟,所以做个记录,方便以后查阅。

1. 原型链

说到 JS 的继承,就不得不提到这个原型链,它是实现继承的主要方法。基本的实现思路是让一个引用类型继承另一个引用类型的属性和方法。

构造函数、原型和实例的关系: 每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型对象的内部指针。那么,假如我们让原型对象等于另一个类型的实例,结果会怎么样呢?显然,此时的原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数的指针。假如另一个原型又是另一个类型的实例,那么上述关系依然成立,如此层层递进,就构成了实例与原型的链条。这就是所谓原型链的基本概念。 (摘要至 《JavaScript 高级程序设计》)

实现原型链的一种基本模式:

function Father() {
  this.surname = '杨'
  this.name = 'xx'
}
Father.prototype.getFullName = function () {
  console.log(this.surname + this.name)
}

function Child() {
  this.name = '某某'
}
// 不使用 Child 默认提供的原型,而是重写原型对象,以此来实现继承
Child.prototype = new Father()
Child.prototype.getSurname = function () {
  console.log(this.surname)
}

var c = new Child()

c.getFullName() // 输出:杨某某
/*
  调用 c.getFullName() 会经历 3 个搜索步骤:
  1. 搜索实例 c,发现没有 getFullName 这个方法
  2. 继续搜索 Child.prototype,还是没有 getFullName 这个方法
  3. 继续搜索 Father.prototype,找到了 getFullName 这个方法
*/

c.getSurname() // 输出:杨

console.log(c.toString()) // 输出:[object Object]
/*
  注意:所有函数的默认原型都是 Object 的实例,
  因此默认原型都会包含一个内部指针,它指向 Object.prototype。
  这也正是所有自定义类型都会继承 toString()、valueOf() 等默认方法的根本原因。
*/

// 确定原型和实例的关系
// 1. instanceof 操作符:
// 只要用这个操作符来测试实例与原型链中出现过的构造函数,结果就会返回 true
console.log(c instanceof Object) // true
console.log(c instanceof Father) // true
console.log(c instanceof Child) // true

// 2. isPrototypeOf() 方法:
// 只要是原型链中出现过的原型,都可以说是该原型链所派生的实例的原型
console.log(Object.prototype.isPrototypeOf(c)) // true
console.log(Father.prototype.isPrototypeOf(c)) // true
console.log(Child.prototype.isPrototypeOf(c)) // true

/*
  注意:

  如果子类要重写超类的某些方法,或者子类要添加一些自己的方法时,
  给原型添加方法的代码一定要放在替换原型的语句之后。

  在通过原型链实现继承时,不能使用对象字面量创建原型方法,因为这样会重写原型链。
*/

使用上述方法可能会出现的问题:

function Father() {
  this.fixedAssetsList = ['父亲的房子', '父亲的车']
}

function Child() {}
Child.prototype = new Father()

var c1 = new Child()
c1.fixedAssetsList.push('c1 的别墅')

var c2 = new Child()
// 按理来说,哥哥自己买的别墅,应该不会直接属于弟弟
console.log(c2.fixedAssetsList) // 输出:['房子', '车', 'c1 的别墅']

/*
  这也是这种方式实现继承的最主要的问题。

  我们知道,包含引用类型的原型属性会被所有实例共享,
  这也是为什么要在构造函数中定义属性而不在原型对象中定义属性的原因。
  
  在通过这种方法来实现继承的时候,原型实际上会变成另一个类型的实例,
  所以原来的实例属性(父类定义的实例属性)也就成了现在的原型属性(子类的原型属性)。
*/

2. 借用构造函数

有时候也叫伪造对象或者经典继承。

基本思想:在子类型构造函数的内部调用超类型构造函数

实现:

function Father() {
  this.fixedAssetsList = ['父亲的房子', '父亲的车']
}

function Child() {
  // “借调” 超类型的构造函数
  // 实际上是在新创建的 Child 实例的环境下调用了 Father 构造函数,
  // 这样就会在新 Child 对象上执行 Father 函数中定义的所有对象初始化代码
  Father.call(this)
}

var c1 = new Child()
c1.fixedAssetsList.push('c1 的别墅') // 输出:['父亲的房子', '父亲的车', 'c1 的别墅']
console.log(c1.fixedAssetsList)

var c2 = new Child()
console.log(c2.fixedAssetsList) // 输出:['父亲的房子', '父亲的车']

这种方式有什么好处:

相对于原型链来说,借用构造函数的一大优势在于子类型构造函数中可以向父类型构造函数传递参数。

function Father(name) {
  this.surname = '杨'
  this.name = name
}

function Child(name) {
  Father.call(this, name)
}
Child.prototype.getFullName = function () {
  console.log(this.surname + this.name)
}

var c = new Child('某某')
c.getFullName() // 输出:杨某某

借用构造函数存在的问题:

仅仅是借用构造函数,它所存在的问题是方法都在构造函数中定义,因此函数无法复用。而且在超类型的原型中定义的方法,对子类型是不可见的,结果所有类型都只能使用构造函数模式。

function Father() {
  this.sayHello = function () {
    console.log('你好')
  }
}
Father.prototype.sayGoodbye = function () {
  console.log('拜拜')
}

function Child() {
  Father.call(this)
}

var c = new Child()
c.sayHello() // 输出:你好
c.sayGoodbye() // 报错:c.sayGoodbye is not a function

3. 组合继承

也叫伪经典继承。

将原型链和借用构造函数的技术组合到一块,从而发挥二者之长的一种继承模式。 其背后的思路是使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承。 这样,既通过在原型上定义方法实现了函数复用,又能保证每个实例都有自己的属性。

function Father(name) {
  this.surname = '杨'
  this.name = name
  this.fixedAssetsList = ['父亲的房子', '父亲的车']
}
Father.prototype.getFullName = function () {
  console.log(this.surname + this.name)
}

function Child(name, age) {
  // 继承属性
  Father.call(this, name)
  this.age = age
}
// 继承方法
Child.prototype = new Father()
Child.prototype.sayAge = function () {
  console.log('我今年' + this.age + '岁。')
}

var c1 = new Child('哥', 30)
c1.fixedAssetsList.push('别墅')
console.log(c1.fixedAssetsList) // 输出:['父亲的房子', '父亲的车', '别墅']
c1.getFullName() // 输出:杨哥
c1.sayAge() // 输出:我今年30岁。

var c2 = new Child('弟', 22)
console.log(c2.fixedAssetsList) // 输出:['父亲的房子', '父亲的车']
c2.getFullName() // 输出:杨弟
c2.sayAge() // 输出:我今年22岁。

组合继承的优点:

组合继承避免了原型链和借用构造函数的缺陷,而且 instanceof 和 isPrototypeOf() 也能用于识别基于组合继承创建的对象。

4. 原型式继承

这种方法没有使用严格意义上的构造函数。他的想法是借助原型可以基于已有的对象创建新对象,同时还不必因此创建自定义类型。

道格拉斯·克罗克福德给出了如下函数(2006年):

function object(o){
  function F(){}
  F.prototype = o;
  return new F();
}

在 object() 函数内部,先创建了一个临时性的构造函数,然后将传入的对象作为这个构造函数的原型,最后返回了这个临时类型的一个新实例。 从本质上讲,object() 对传入其中的对象执行了一次浅复制。

function object(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

var Father = {
  trustPartnerList: ['老李']
}

var child1 = object(Father)
child1.trustPartnerList.push('小李')

var child2 = object(Father)
child2.trustPartnerList.push('小丽')

console.log(Father.trustPartnerList) // 输出:['老李', '小李', '小丽']
console.log(child1.trustPartnerList)
console.log(child2.trustPartnerList)

ECMAScript 5 通过新增 Object.create() 方法规范化了原型式继承。 这个方法接收两个参数:一个用作新对象原型的对象和(可选的)一个为新对象定义额外属性的对象。 在传入一个参数的情况下,Object.create() 与 object() 方法的行为相同。

Object.create() 方法的第二个参数与 Object.defineProperties() 方法的第二个参数相同:每个属性都是通过自己的描述符定义的。 以这种方式指定的任何属性都会覆盖原型对象上的同名属性。

var Father = {
  trustPartnerList: ['老李']
}

var child = Object.create(Father, {
  trustPartnerList: {
    value: ['老赵']
  }
})

console.log(child.trustPartnerList) // 输出:['老赵']

5. 寄生式继承

寄生式继承是与原型式继承紧密相关的一种思路,并且同样也是由克罗克福德推而广之的。 寄生式继承的思路与寄生构造函数和工厂模式类似,即创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后再像真地是它做了所有工作一样返回对象。

function object(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

function createAnother(original) {
  var clone = object(original); // 通过调用函数创建一个新对象
  clone.sayHi = function() { // 以某种方式来增强这个对象
    alert("hi")
  };
  return clone; // 返回这个对象
}

var person = {
  name: "Nicholas",
  friends: ["Shelby", "Court", "Van"]
};

var anotherPerson = createAnother(person);
anotherPerson.sayHi(); // "hi"

在主要考虑对象而不是自定义类型和构造函数的情况下,寄生式继承也是一种有用的模式。前面示范继承模式使用的 object() 函数不是必需的;任何能返回新对象的函数都适用于此模式。

使用寄生式继承来为对象添加函数,会由于不能做到函数复用而降低效率。

6. 寄生组合式继承

组合继承存在的问题:无论什么情况下,都会调用两次超类型构造函数,一次是在创建子类型原型的时候,另一次是在子类型构造函数内部。解决这个问题的方法就在于寄生组合式继承。

所谓寄生组合式继承,即通过借用构造函数的原型来继承属性,通过原型链的混成形式来继承方法。 其背后的基本思路是:不必为了指定子类型的原型而调用超类型的构造函数,我们所需要得无非就是超类型原型的一个副本而已。 本质上,就是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型。

function object(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

function inheritPrototype(subType, superType) {
  var prototype = object(superType.prototype); // 创建对象
  prototype.constructor = subType; // 增强对象
  subType.prototype = prototype; // 指定对象
}

function SuperType(name) {
  this.name = name;
  this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function() {
  alert(this.name);
}
function SubType(name, age) {
  SuperType.call(this, name);
  
  this.age = age;
}
inheritPrototype(SubType, SuperType);

SubType.prototype.sayAge = function() {
  alert(this.age);
}

var s = new SubType('小明', 20);
s.sayName(); // 输出:小明
s.sayAge(); // 输出:20

它的高效率体现在它只调用了一次 SuperType 构造函数,并且因此避免了在 SubType.prototype 上面创建不必要的、多余的属性。与此同时,原型链还能保持不变;因此,还能正常使用 instanceof 和 isPrototypeOf()。