理性分析 JavaScript 中的继承

理性分析 JavaScript·发布于 7 年前
3 分钟阅读

本文将从最简单的例子开始,从零讲解在 JavaScript 中如何实现继承。

小例子

现在有个需求,需要实现 Cat 继承 Animal ,构造函数如下:

function Animal(name){
    this.name = name
}

function Cat(name){
    this.name = name
}

注:如对继承相关的 prototype、constructor、__proto__、new 等内容不太熟悉,可以先查看这篇文章:理性分析 JavaScript 中的原型

继承

在实现这个需求之前,我们先谈谈继承的意义。继承本质上为了提高代码的复用性。

对于 JavaScript 来说,继承有两个要点:

  1. 复用父构造函数中的代码
  2. 复用父原型中的代码

下面的内容将围绕这两个要点展开。

第一版代码

复用父构造函数中的代码,我们可以考虑调用父构造函数并将 this 绑定到子构造函数。

复用父原型中的代码,我们只需改变原型链即可。将子构造函数的原型对象的 __proto__ 属性指向父构造函数的原型对象。

第一版代码如下:

function Animal(name){
    this.name = name
}

function Cat(name){
    Animal.call(this,name)
}

Cat.prototype.__proto__ = Animal.prototype

检验一下是否继承成功:我们在 Animal 的原型对象上添加 eat 函数。使用 Cat 构造函数生成一个名为 'Tom' 的实例对象 cat 。代码如下:

function Animal(name){
    this.name = name
}

function Cat(name){
    Animal.call(this,name)
}

Cat.prototype.__proto__ = Animal.prototype

// 添加 eat 函数
Animal.prototype.eat = function(){
    console.log('eat')
}

var cat = new Cat('Tom')
// 查看 name 属性是否成功挂载到 cat 对象上
console.log(cat.name) // Tom
// 查看是否能访问到 eat 函数
cat.eat() // eat 
// 查看 Animal.prototype 是否位于原型链上
console.log(cat instanceof Animal) // true
// 查看 Cat.prototype 是否位于原型链上
console.log(cat instanceof Cat) //true

经检验,成功复用父构造函数中的代码,并复用父原型对象中的代码,原型链正常。

图示

弊端

__proto__ 属性虽然可以很方便地改变原型链,但是 __proto__ 直到 ES6 才添加到规范中,存在兼容性问题,并且直接使用 __proto__ 来改变原型链非常消耗性能。所以 __proto__ 属性来实现继承并不可取。

第二版代码

针对 __proto__ 属性的弊端,我们考虑使用 new 操作符来替代直接使用 __proto__ 属性来改变原型链。

我们知道实例对象中的 __proto__ 属性指向构造函数的 prototype 属性的。这样我们 Animal 的实例对象赋值给 Cat.prototype 。不就也实现了Cat.prototype.__proto__ = Animal.prototype 语句的功能了吗?

代码如下:

function Animal(name){
    this.name = name
}

function Cat(name){
    Animal.call(this,name)
}

Cat.prototype = new Animal()
Cat.prototype.constructor = Cat

使用这套方案有个问题,就是在将实例对象赋值给 Cat.prototype 的时候,将 Cat.prototype 原有的 constructor 属性覆盖了。实例对象的 constructor 属性向上查询得到的是构造函数 Animal 。所以我们需要矫正一下 Cat.prototype 的 constructor 属性,将其设置为构造函数 Cat 。

图示

优点

兼容性比较好,并且实现较为简单。

弊端

使用 new 操作符带来的弊端是,执行 new 操作符的时候,会执行一次构造函数将构造函数中的属性绑定到这个实例对象。这样就多执行了一次构造函数,将原本属于 Animal 实例对象的属性混到 prototype 中了。

第三版代码

考虑到第二版的弊端,我们使用一个空构造函数来作为中介函数,这样就不会将构造函数中的属性混到 prototype 中,并且减少了多执行一次构造函数带来的性能损耗。

代码如下:

function Animal(name){
    this.name = name
}

function Cat(name){
    Animal.call(this,name)
}
function Func(){}
Func.prototype = Animal.prototype

Cat.prototype = new Func()
Cat.prototype.constructor = Cat

图示

ES6

使用 ES6 就方便多了。可以使用 extends 关键字实现继承, 复用父原型中的代码。使用 super 关键字来复用父构造函数中的代码。

代码如下:

class Animal {
    constructor(name){
        this.name = name
    }
    eat(){
        console.log('eat')
    }
}
class Cat extends Animal{
    constructor(name){
        super(name)
    }
}

let cat = new Cat('Tom')
console.log(cat.name) // Tom
cat.eat() // eat

相关知识点


参考书籍

  • 《JavaScript高级程序设计(第3版)》
  • 《Java核心技术 卷Ⅰ(第9版)》
  • 小例子
  • 继承
    • 第一版代码
      • 图示
      • 弊端
    • 第二版代码
      • 图示
      • 优点
      • 弊端
    • 第三版代码
      • 图示
    • ES6
  • 相关知识点
  • 参考书籍