JavaScript 继承的实现

在所有面向对象的编程语言中,继承都是一个非常重要的特性,继承有很多优点,比如实现逻辑复用,减少重复代码,节省内存空间,利于整体维护等。在 JS 中,也可以实现继承,对于有基于类的语言开发经验的人来说,JS 的继承可能会让人有点困惑,因为 JS 本身不提供类继承的实现,虽然在 ES6 中引入了 class 关键字,但只是语法糖,JavaScript 继承实际上仍然是基于原型的,那么原型是什么,继承又是如何实现的,今天我们来详细探讨下。

JavaScript 原型和原型链

在理解原型之前,我们先看一下为什么需要原型,假设我们有一个 Person 函数:

function Person(name) {
  this.name = name
  this.sayName = function () {
    console.log(this.name)
  }
}

const P1 = new Person('John')
const P2 = new Person('Amy')

这在段代码中,我们每 new 一个实例,sayName() 方法都会在内存中被拷贝一份,这就会造成内存的浪费,我们实际上希望所有实例都共用同一个 sayName() 方法,JavaScript 中的原型就是用来帮我们实现共享属性或方法的。

将以上代码替换成原型的写法:

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

Person.prototype.sayName = function () {
  console.log(this.name)
}

这样,无论我们 new 多少次,sayName() 在内存中始终只存在一份。

我们知道,在 JavaScript 中只有一种结构,即对象,每个实例对象(object)都有一个私有属性(__proto__)指向它的构造函数的原型对象(prototype),该原型对象也有一个自己的原型对象(__proto__),这种一层一层往上的链接关系被称作原型链,原型链的最后一个对象是 null,根据定义 null 没有原型。

对于 prototype__proto__ 我们需要搞懂的是:

  • prototype 是构造函数才有的属性,prototype 所指向的对象,就是我们常说的原型对象;
  • __proto__ 是实例对象才有的属性,它相当于提供了一个可以让我们通过实例访问原型的方式;
  • JavaScript 中函数也是对象,所以函数不但拥有 prototype,还有 __proto__ 属性。

它们之间的关系,可以参考下图:

JavaScript prototype

实例对象除了 prototype 还有一个属性是 constructor,它指向实例对象的构造函数,一定要区分清楚构造函数和实例对象的区别。

知道了原型和原型链,我们来看下如何利用原型实现继承。

继承

在 JavaScript 中,继承是基于原型实现的,当试图访问一个对象的属性时,它不仅仅在该对象上搜寻,还会搜寻该对象的原型,以及该对象的原型的原型,依次层层向上搜索,直到找到一个名字匹配的属性或到达原型链的末尾,所以我们只需要将一个对象的原型指向另一个实例对象,就能实现继承。

接下来我们试着用 ES5 语法实现继承的几种不同写法,并探讨它们的优缺点。

一、原型链继承

我们先看最简单的继承方式,原型链继承,其方式是将子类构造函数的 prototype 指向父类对象的实例。

如下代码实现了一个简单的原型链继承:

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

Person.prototype.sayName = function () {
  console.log(this.name)
}

function Employee(title) {
  this.title = title
}

// 将子类 Employee 的 prototype 指向父类 Person 的实例
Employee.prototype = new Person('John')

const employee = new Employee('Manager')

employee.sayName()

Person 类的原型上有一个 sayName() 方法,我们希望 Employee 也能继承这个方法,所以我们将 Employee 的 prototype 指向 Person 类的一个实例。

这种方式的优点是简单,但缺点也很明显,就在继承的同时,不能传递参数 name。

二、构造函数继承

如何将参数传递给父类,你可能会想到 call、apply,构造函数继承即是利 call、apply 调用父类的构造函数。

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

Person.prototype.sayName = function () {
  console.log(this.name)
}

function Employee(name, title) {
  // 利用 call 调用父类构造函数
  Person.call(this, name)
  this.title = title
}

const employee = new Employee('John', 'Manager')

console.log(employee.name)
console.log(employee.sayName) // undefined

与原型继承相比,虽然实现了参数传递,但不能继承父类原型上的方法。你可能会想到,能不能将两种方式结合起来呢?这就是第三种继承方式:组合继承

三、组合继承

组合继承的思想是使用 call 调用父类构建函数的同时,将 prototype 指向父类对象的实例,具体代码如下:

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

Person.prototype.sayName = function () {
  console.log(this.name)
}

function Employee(name, title) {
  Person.call(this, name)
  this.title = title
}

Employee.prototype = new Person()

const employee = new Employee('John', 'Manager')
employee.sayName()
console.log(employee.constructor) // Person

将前面两种方式结合后,不仅可以传递参数,还能继承原型链中的方法。

当我们打印 employee.constructor 的时候,发现指向的却是父类构造函数,所以我们需要手动修复下。

Employee.prototype.constructor = Employee

但是上面这种继承方式还是有些小问题,细心的你可能已经发现,构造函数 Person 被调用了两次。

四、寄生继承

寄生继承的思想是创建一个空对象,将父类对象上的属性和方法浅拷贝到这个空对象上,然后将子类的 prototype 指向这个新创建的对象。

// 这里省略了其它代码

function objectCreate(obj) {
  function F() {}
  F.prototype = obj
  return new F()
}

Employee.prototype = objectCreate(Person.prototype)

通过一个中间对象避免了构造函数两次调用的问题,在 JavaScript 中,Object 有个 create() 方法可以基于现有对象创建一个新对象,我们可以使用它来代替 objectCreate()

寄生继承的完整代码如下:

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

Person.prototype.sayName = function () {
  console.log(this.name)
}

function Employee(name, title) {
  Person.call(this, name)
  this.title = title
}

Employee.prototype = Object.create(Person.prototype)
Employee.prototype.constructor = Employee

const employee = new Employee('John', 'Manager')

employee.sayName()

结束语

寄生继承是一种较为完善的继承方式,解决了其它继承方式不足的地方,虽然在日常开发中,我们主要是使用 ES6 的 class、extends 关键字来实现继承,但了解其背后的原理能够避免出现很多不必要的 bug。

参考

Keywords

JavaScript 继承 原型继承 构造函数继承 组合继承 寄生继承