再看 JavaScript 继承

763 阅读4分钟

学完了整个 JavaScript 基础篇章之后,发现自己对继承的理解有了好几次了翻天覆地的变化(扶额),于是就写了这样一篇文章阐述目前自己对继承的看法。其实是初到掘金,复制了一篇之前自己写的文章(笑)。

本文将着重讨论基于原型的继承,也会简单写一下如何用 class 继承。

Key Points

  • 在 JavaScript 中,函数 Function 也是一种 对象 Object
  • 关于函数
    • 所有函数都自带 prototype
    • prototype 中自带 constructor
    • constructor 里面的东西就是函数的内容
    • 构造函数首字母大写(约定俗成)
  • 对象.__proto__ === 其构造函数.prototype

〇、简单解释

首先,关于函数也是一种对象这个说法,我们在后面(也许是别的文章中)会有相关的说明,这里先记住这个结论即可;

其次是关于函数的几个描述,我们可以做几个实验来验证一下:

let arr = [1, 2, 3]
let obj = { name: 'Harvey', age: '22'}
let fn = function(){ console.log('hi') }

打印出 arr objfn 之后就可以看到,比起别的对象,函数确实是比较特别的,他天生就带有一个 prototype 属性,而且 prototype 中的 constructor 就是这个函数本身。

arr.prototype === undefined // true
obj.prototype === undefined // true
fn.prototype.constructor === fn // true

我们也可以再验证一下最后一句话:

function Person(){}
let me = new Person
me.__proto__ === Person.prototype // true

做了这几个小实验之后,我们进入正题来讨论。

一、原型链

原型链的精髓其实就是刚才已经提到过的一句话:

对象.__proto__ === 其构造函数.prototype

当然这样说比较抽象,我们可以展开对普通对象、数组(代表了比较特殊的对象,比如日期等),以及函数来分别进行讨论。

普通对象的原型链

普通对象的原型是 Object

这句话要从以下几点来理解:

  1. 创建一个对象可以按这种方式写: let obj = new Object({ name: 'Harvey', age: '22' })
  2. Object 实际上是一个构造函数,他构造了 obj
  3. obj.__proto__ === Object.prototype

因此我们可以简单表示一下这个普通对象的原型链:

obj -> Object.prototype

数组的原型链

数组的原型是 Array

这句话要从以下几点来理解:

  1. 创建一个数组可以按这种方式写: let arr = new Array(1, 2, 3)
  2. Array 实际上是一个构造函数,他构造了 arr
  3. arr.__proto__ === Array.prototype

事实上,我们还会发现:

arr.__proto__.__proto__ === Object.prototype // true
Array.prototype.__proto__ === Object.prototype // true

也就是说,一个数组的原型链要稍微复杂一些:

arr -> Array.prototype -> Object.prototype

函数的原型

函数的原型是 Function

这句话要从以下几点来理解:

  1. 创建一个数组可以按这种方式写: let fn = new Function( (), { console.log('hi') } )
  2. Function 实际上是一个构造函数,他构造了 fn
  3. fn.__proto__ === Function.prototype

也就是说,一个函数的原型链也要稍微复杂一些:

fn -> Function.prototype -> Object.prototype

修改原型链

通过直接修改 __proto__ 就可以达到修改原型链的目的

let obj1 = { a: 1 }
let obj2 = { b: 2 }
let obj3 = { c: 3 }
obj2.__proto__ = obj1
obj3.__proto__ = obj2

这样,他们的原型链就变成了: obj3 -> obj2 -> obj1 -> Object.prototype

但是这种方法是不推荐的,我们更推荐使用 Object.create() 方法,他的使用方法如下:

let obj1 = { a: 1 }
let obj2 = Object.create(obj1)
obj2.b = 2
let obj3 = Object.create(obj2)
obj3.c = 3

这样与上面直接修改 __proto__ 效果基本是一样的

二、继承

这里我们要明确一点,平时大家所说的继承(或者说类的继承),其实更多的是一种 狭义的继承。他指的 不是 我们按照上面的方式 单纯对原型链进行的修改而是 一种在 构造函数之间 的,在 prototype 之间的继承。

比如我们说 Array 继承了 Object

//  Array 继承了 Object:
Array.prototype.__proto === Object.prototype

而不说 arr 继承了 Array,哪怕出现了原型链:

// 我们不说 arr 继承了 Array
arr.__proto__ === Array.prototype

也不说 obj2 继承了 obj1,哪怕出现了 __proto__

// 我们也不说 obj2 继承了 obj1
obj2.__proto === obj1

那么问题来了,这种在 构造函数之间的继承 应该怎么写呢,怎样才能得到像 ArrayObject 的这种关系呢?

第一步:使用 call 来调用父类构造函数

// 定义父类
function Person(姓名) {
  this.姓名 = 姓名
}
Person.prototype.自我介绍 = function() {
  console.log(`你好,我是 ${this.姓名}`)
}
// 尝试定义一个子类,来继承 Person
function Student(姓名, 学号){
  Person.call(this, 姓名) // 调用父类构造函数
  this.学号 = 学号
}

这一步是为了让 new 子类 创建出来的对象拥有与 new 父类 一样的属性。

在这个例子中,就是为了让 new Studtent 创建出来的对象,拥有 Person 中的 姓名 属性。

好,我们现在来尝试创建一个 Student 对象 小明

let 小明 = new Student('小明', 123456)
小明.姓名 // '小明'
小明.学号 // 123456
小明.自我介绍() // Uncaught TypeError: 小明.自我介绍 is not a function

小明 如何拿到 学号

new Student('小明', 123456) 的时候,系统会去调用 Student 函数,并且把 小明 这个对象作为 this 传进去;

相当于在 Student 函数中执行了 小明.学号 = 123456

小明 如何拿到 姓名

同样,系统调用 Student 函数,看到了 Person.call(this, 姓名)(相当于 Person.call(小明, '小明')),意思是让 Person 中的 this小明,并且传一个参数 '小明'Person

然后将会调用 Person 函数,在 Person 中执行 this.姓名 = 姓名(相当于 小明.姓名 = '小明')。

为什么 小明 不能使用 自我介绍

因为可以看到,小明 这个对象中没有 自我介绍 属性,他的 __proto__(也就是 Student.prototype) 中也没有,因此他找不到 自我介绍

第二步:建立原型链

那么我们怎样才能让 小明 能够进行 自我介绍呢?想到了几种写法,我们来一一分析一下:

1、直接将 自我介绍 放在 小明 这个对象实例上

小明.自我介绍 = function(...){...}

但是既然每个人都需要 自我介绍 ,那么我们单独修改 小明 这样一个对象就没有意义。

2、将 自我介绍 放在 小明 这一类对象实例上

Student = function() {
  this.自我介绍 = Person.prototype.自我介绍
}

这样最终其实是可以实现相同的效果的。不过这样就需要每个函数都单独写一下,有时候也不太方便。

3、将 自我介绍 放在 小明 这一类对象实例的 __proto__

众所周知,小明 也可以访问到 小明.__proto__ 上的函数,所以这好像也是可行的。

既然 小明.__proto__ === Student.prototype,那要不我们这样写:

Student.prototype = Person.prototype

这样 小明就可以使用在 Person.prototype 上的 自我介绍 了。

但是!如果这个时候 Student 想要给自己的 prototype 加一个新方法,怎么办?我们知道因为只是复制了地址,如果修改了 Student.prototypePerson.prototype 也将被修改,这显然是我们不愿意看到的。难道要用深拷贝?orz

4、将 自我介绍 放在 小明 这一类对象实例的 __proto____proto__

众所周知,小明 也可以访问到 小明.__proto__.__proto__ 上的函数,所以这也是可行的。

既然 小明.__proto__ === Student.prototype,那么也就是说我们要实现:

Student.prototype.__proto__ = Person.prototype

这就是我们的终极解决方案啦,是不是看起来有点眼熟?

Array.prototype.__proto__ === Object.prototype // true

对了!这就是原型链!这就是我们所说的继承!是不是有点感觉了?

当然,刚才也说了,我们最好用下面这种写法:

Student.prototype = Object.create(Person.prototype)

回顾一下到现在我们做了什么?

  1. 首先我们通过 call 父级构造函数,来实现属性的继承,我们的 小明 有了 姓名
  2. 然后我们通过建立原型链,来实现方法的继承,我们的 小明 可以 自我介绍

看似已经结束,但是实际上还有一个隐藏的 Bug,我们接下来来解决这个 Bug。

第三步:解决 constructor 的问题

细心的你会发现(我们在最开始也说过了),我们的对象实例和构造函数中是有一个 constructor 属性的,比如:

const arr = [1, 2]
arr.__proto__.constructor === Array // true
Array.prototype.constructor === Array // true

但是,Student.prototype 中的 constructor 被刚才的那一番操作给搞没了,我们需要把它弄回来:

Student.prototype.constructor = Student

这样就完成了一波类的继承。

三、总结

看一波完整的代码:

// 定义父类
function Person(姓名) {
  this.姓名 = 姓名
}

// 定义父类的方法
Person.prototype.自我介绍 = function() {
  console.log(`你好,我是 ${this.姓名}`)
}

// 定义子类
function Student(姓名, 学号){
  Person.call(this, 姓名) // 调用父级构造函数,继承父类的属性
  this.学号 = 学号
}

// 建立原型链,继承父类的方法
Student.prototype = Object.create(Person.prototype)

// 解决 constructor 问题
Student.prototype.constructor = Student

// 定义子类的新方法
Student.prototype.报数 = function() {
  console.log(`我的学号是 ${this.学号}`)
}



let 小红 = new Student('小红', 345678)
小红.自我介绍() // 你好,我是 小红
小红.报数() // 我的学号是 345678

四、用 class 继承

既然他本身就是语法糖,我个人认为没必要搞那么细,其实本质跟上面的使用原型链的继承是一样的,搞清楚是怎么写的就好啦:

// 定义父类
class Person {
  constructor(姓名) { // 定义属性
    this.姓名 = 姓名
  }
  自我介绍() { // 定义方法
    console.log(`你好,我是 ${this.姓名}`)
  }
}

// 定义子类
class Student extends Person {
  constructor(姓名, 学号) {
    super(姓名) // 这里的 姓名 两个字要与父类中的一样,继承属性和方法
    this.学号 = 学号 // 定义新属性
  }
  报数() { // 定义新方法
    console.log(`我的学号是 ${this.学号}`)
  }
}



let 小红 = new Student('小红', 345678)
小红.自我介绍() // 你好,我是 小红
小红.报数() // 我的学号是 345678