JS 实现「继承」的各种写法

了解理解各种继承的写法前,应该对 JavaScript 的 this、call、apply、new 、原型链等知识。我们还是用《JavaScript 创建对象的几种方式》中创建「美国大兵」的例子来理解每种写法的优缺点,新增加一个 Human 的类,来看看如何通过各种模式,让 Soldier 继承人类的属性和方法。

原型链

function Human(){
  this.name = 'Jack',
  this.age = 18,
  this.家庭成员 = ['爸爸','妈妈']
}
Human.prototype.walk = function(){console.log("走一步")}

function Soldier(id){
  this.ID = '001'
this.生命值 = 50
}

Soldier.prototype = new Human()
Soldier.prototype.攻击 = function(){/* 🗡砍一下 */}

var soldier1 = new Soldier
var soldier2 = new Soldier
soldier1.walk() // 走一步
// 我们想单独给 `soldier1.家庭成员`添加一个值
soldier1.家庭成员.push('妹妹')
// 但依然被共享了
soldier2.家庭成员

上面的继承实现原理是:通过创建 Human 的实例,并将实例赋值给 Soldier.prototype。这样Soldier创建的实例对象的内部属性__proto__就指向了 Human.prototype

但是单独使用原型链来实现继承会存在两个问题:

  1. 父类中引用类型的属性被所有实例共享
  2. 创建子类的实例时,不能向父类的构造函数中传递参数

借用构造函数

function Human(name, age){
  this.name = name
  this.age = age
  this.家庭成员 = ['爸爸','妈妈']
}

function Soldier(name, age, id){
  Human.call(this, name, age)
  this.ID = id
  this.生命值 = 50
}
Soldier.prototype.攻击 = function(){/* 🗡 砍一下 */}
Soldier.prototype.防御 = function (){/* 🛡 举起盾牌 */}

var soldier1 = new Soldier('Frank', 19, '001')

这种实现方法的思想是,在子类的构造函数的内部。把父类的构造函数当作普通函数使用 callapply 方法来调用。并指定父构造函数执行时的 this 为当前子类的实例化的对象。这样子类实例就具有了父类构造函数中引用类型的副本,从而解决了上面所说的「父类中引用类型的属性被所有实例共享」的问题。

而且传参的问题也得到了解决,但我们需要注意一点:在传参时为了确保父类构造函数执行时不会重写子类的属性,应该让调用父类构造函数放在前面。借用构造函数的问题:

  1. 父类在构造函数中定义的方法是每个子类实例重新创建一个新方法,无法复用该方法
  2. 父类原型中的属性,子类的实例无法访问到

⭐️ 组合式继承

function Human(name, age){
  this.name = name
  this.age = age
  this.家庭成员 = ['爸爸','妈妈']
}
Human.prototype.walk = function(){/* 走一步 */}

function Soldier(name, age, id){
  // 继承属性
  Human.call(this, name, age)
  this.ID = id
  this.生命值 = 50
}
// 继承方法
Soldier.prototype = new Human()
Soldier.prototype.攻击 = function (){/* ⚔️ 砍一刀 */}
Soldier.prototype.防御 = function (){/* 🛡 举起盾牌 */}

var soldier1 = new Soldier('Jack', 18, '001')

构造函数模式用于定义实例属性,原型模式用于定义方法和共享的属性。结果,每个实例都会有自己的一份实例属性的副本,但同时有共享着对方的引用,最大限度地节省了内存。另外,这种方式还支持向构造函数传递参数。这种模式是目前 ECMAScirpt 中使用最广泛、认同度最高的一种创建自定义类型的方法。

原型式继承

Human = {
  name: 'Jack',
  age: 18,
  friends: ['Nick','Lucy'],
  walk: function(){/* 走一步 */}
}

function 复制器(obj){
  function 临时函数(){}
  临时函数.prototype = obj
  return new 临时函数()
}

var soldier1 = 复制器(Human)
var soldier2 = Object.create(Human)
soldier1.ID = "002"

ES5 为了规范化原型式继承给我们提供Object.create()这个方法。创建一个新对象,使用现有的对象来提供新创建的对象的__proto__ 在指向让一个对象于另一个对象保持类型的情况下,原型式继承是完全可以胜任的。就没必要兴师动众地创建构造函数了。不过别忘了,包含引用类型值的属性始终都会共享相应的值。

寄生式继承

Human = {
  name: 'Jack',
  age: 18,
  friends: ['Nick','Lucy'],
  walk: function(){/* 走一步 */}
}
function 复制器(obj){
  var clone = Object.create(obj)
  clone.ID = '001'
  clone.攻击 = function(){/* 🗡 砍一下 */}
  return clone
}

var soldier1 = 复制器(Human)

寄生组合式继承

先回头看看这个模式的写法

function Human(name, age){
  this.name = name
  this.age = age
  this.家庭成员 = ['爸爸','妈妈']
}
Human.prototype.walk = function(){/* 走一步 */}

function Soldier(name, age, id){
  // 继承属性(第二次调用)
  Human.call(this, name, age)
  this.ID = id
  this.生命值 = 50
}
// 继承方法(第一次调用)
Soldier.prototype = new Human()
Soldier.prototype.攻击 = function (){/* ⚔️ 砍一刀 */}
Soldier.prototype.防御 = function (){/* 🛡 举起盾牌 */}

var soldier1 = new Soldier('Jack', 18, '001')

在组合式继承中我们会发现两次调用父类型的构造函数,一次是在继承属性时、另一个次在继承方法时。这会给我们带了一个什么问题呢?在第一次调用时 new Human(),会执行 Human构造函数中的代码把nameage家庭三个属性将添加到实例对象中,而这个实例对象又作为子类的原型对象。这会导致每子类实例的原型对象中有这三个属性存在。我们只不过是在第二次调用时给每个子类实例实例重写了这三个属性的值。在控制台把 soldier1的打出来就能看到了:

知道了问题所在,我们解决的思路是: ⭐️ 不必为了子类型的原型而调用父类型的构造函数,我们所需要的无非就是父类原型对象的一个副本而已。本质上,就是使用寄生式继承来继承父类型的原型,然后再将结果指定给子类型的原型。

  • 在实现这个思想时我们有两个方法,也就是寄生式继承中讲的两种方法
  • 利用临时函数,将临时函数的原型对象指向父类的原型对象,再用 new 调用临时函数得到临时函数的实例对象,这个实例的原型指向了父类型的原型对象。

    ```js
    function 临时函数(){}
    临时函数.prototype = 父类构造函数.prototype
    子类构造函数.原型对象  = new 临时函数()
    ```
    
  • 直接使用 ES5 的方法 Object.create() ,把父类的原型对象作为参数传进去,拿到一个新对象这个对象的原型指向父类型的原型对象

    ```js
    子类构造函数.原型对象 = Object.create(父类构造函数.prototype)
    ```
    

看看我们的代码如何来写:

function Human(name, age){
  this.name = name
  this.age = age
  this.家庭 = ['爸爸','妈妈']
}
Human.prototype.walk = function(){/* 走一步 */}

function Soldier(name, age, id){
  Human.call(this, name, age)
  this.ID = id
  this.生命值 = 50
}

function 临时函数(){}
临时函数.prototype = Human.prototype
Soldier.prototye = new 临时函数()
// Soldier.prototype = Object.create(Human.prototype)

Soldier.prototype.攻击 = function (){/* ⚔️ 砍一刀 */}
Soldier.prototype.防御 = function (){/* 🛡 举起盾牌 */}

var soldier1 = new Soldier('Jack', 18, '001')

最后我们封装一下这个继承方法:

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

function prototype(child, parent) {
    var prototype = object(parent.prototype);
    prototype.constructor = child;
    child.prototype = prototype;
}

// 当我们使用的时候:
prototype(Child, Parent);

这种方式的高效率体现它只调用了一次 Parent 构造函数,并且因此避免了在 Parent.prototype 上面创建不必要的、多余的属性。与此同时,原型链还能保持不变;因此,还能够正常使用 instanceof 和 isPrototypeOf。开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式

参考链接