1

几乎所有语言都有面向对象的概念,JavaScript 的面向对象实质是基于原型的对象系统。说到面向对象,不得不提的就是继承。

认识 new

一个没有其他语言经验的人要更容易理解 JavaScript 的继承。
不同于其他语言的类的继承,JavaScript 中使用的是原型继承,不过从表面上看更像是基于类的继承,原因可能是因为 new 关键字的使用。new 关键字是用来调用构造函数的,一个函数之所以称为构造函数,并不是因为函数本身有什么特性,而是因为 new 。也就是说只有通过 new 调用的函数才可能成为构造函数。
new 既然这么神奇,有必要探究下内部到底实现了什么。

  • 创建一个空对象,并让这个对象继承构造函数的prototype。
  • 将构造函数的this指向这个空对象,并执行构造函数。
  • 如构造函数执行后返回的是对象类型就直接返回,否则返回上面创建的对象。

下面是简易的实现代码:

function myNew(constructor, param) {
  // constructor 就是构造函数,param 模拟构造函数的参数,这里只用一个参数举例
  
  // 创建一个空对象,且这个空对象继承构造函数的 prototype 属性
  const obj = Object.create(constructor.prototype)

  // 将构造函数的 this 指向 obj,执行构造函数得到返回结果
  const result = constructor.apply(obj, param)

  // 如果造函数执行后,返回结果是对象类型,就直接返回,否则返回 obj 对象
  return (typeof result === 'object' && result != null) ? result : obj
}

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

const cat = myNew(Animal, '猫科动物')

console.log(cat)

// {species: "猫科动物"}

结合上面 new 实现的三步,再看代码就比较容易理解了。
需要注意的就是,如果构造函数有显式返回值,并且返回值类型为对象。那么构造函数返回的结果不再是目标实例,而是这个显式的返回值。

如何实现继承

JavaScript 实现继承的方式有多种,各有优缺点,下面就将常见的几种方式一一列出。

一、构造函数的继承方式

function Animal(species) {
    this.species = species || "动物"
}
function Cat(name, color, species) {
    Animal.call(this, species)
    this.name = name
    this.color = color
}
Animal.prototype.age = 10
var cat1 = new Cat("毛毛", "黑色", "猫科动物")
console.log(cat1.species) // 猫科动物
console.log(cat1.age) // undefined

这种继承的实现方式是在子类构造函数中执行父类构造函数,并将父类构造函数的this指向子类的实例。优点简单易懂,但是缺点也很明显。
==缺点==:无法继承父类原型上的属性和方法。

二、原型链继承模式

function Animal() {
    this.species = "动物"
    this.list = [1, 2, 3]
}
function Cat(name, color) {
    this.name = name
    this.color = color
}

// 将Cat的prototype对象指向一个Animal的实例
// 它相当于完全覆盖了 prototype 对象原先的值。
Cat.prototype = new Animal()
// 本行下面会有详细解释
Cat.prototype.constructor = Cat

var cat1 = new Cat("大黄", "黄色")
var cat2 = new Cat("小黄", "黑色")

//这种方式实现的继承,不同实例的原型对象都指向同一个Animal的实例,访问属性的时候,如果实例内没有该属性,就会向上找到Cat.prototype(Animal的一个实例)中。
//但是这里调用cat1.species去赋值,不会向上寻找,而只是在cat1实例中添加一个species属性,并不会影响cat1原型对象(Animal实例)中的属性,因此cat2.species的值没有变化,这种方式并不能说明问题,看下面的代码
cat1.species = "猫科动物"
console.log(cat1.species) // 猫科动物
console.log(cat2.species) // 动物

// 调用数组的push方法,就会顺着原型链搜索,找到原型对象中的list并修改值,上面说了不同实例的原型对象都指向同一个Animal的实例,所以 cat2.list 读取到的值也是改变后的。
cat1.list.push(4)
console.log(cat1.list) // [1,2,3,4]
console.log(cat2.list) // [1,2,3,4]

关于Cat.prototype.constructor = Cat ,是给Cat构造函数的原型对象上的constructor属性重新赋值。
因为任何一个prototype对象都有一个constructor属性,指向它的构造函数,如果没有Cat.prototype = new Animal()时,Cat.prototype.constructor是指向Cat的,但是执行了这句代码后Cat.prototype.constructor指向Animal
相当于

Cat.prototype.constructor === Animal //true

并且,构造函数创造出的每一个实例也有一个constructor属性,读取的是构造函数的prototype对象的constructor属性。
相当于

 cat1.constructor === Cat.prototype.constructor // true

因此,cat1.constructor也指向Animal

cat1.constructor === Animal // true

这样导致的结果是继承关系混乱
手动修改了constructor,虽然解决了这个关系混乱的问题,但是代码中也可以看到这种实现方式也是有缺点的。
==优点==:
实例是子类实例,同时也是父类的实例;
实例可以访问到父类新增的原型属性和原型方法;
子类原型共享父类原型,父类原型不共享子类原型;

==缺点==:
继承的实例属性,所有子类共享同一个父类实例的实例属性;
无法向父类构造函数传参;

三、原型链继承改版,直接继承prototype
基于第二种原型链方式的改进,想要解决之前方式的缺点。

function Animal() {
    this.age = 10
}
function Cat() {}
Animal.prototype.species = "动物"

Cat.prototype = Animal.prototype
Cat.prototype.constructor = Cat

var cat1 = new Cat()

console.log(cat1.species) // 动物
console.log(cat1.age) // undefined

Cat.prototype.gender = "formall"
var a = new Animal()
console.log(a.gender) // formall

这种方式跳过new Animal()直接继承Animal.prototype。想象的是不共享同一个父类实例属性,但是又导致一个问题,Cat.prototypeAnimal.prototype现在指向了同一个对象,那么任何对Cat.prototype的修改,都会体现到Animal.prototype上。同时子类实例无法访问父类实例属性。
==缺点==:
子类父类共享原型对象;
无法继承父类实例属性;

四、原型链继承改版,利用空对象
先来解决子类父类共享原型对象的问题,实用的办法是创建一个中间对象。

function Animal() { 
    this.age = 10 
}
function Cat() { }

var F = function () { }
F.prototype = Animal.prototype
Cat.prototype = new F()
Cat.prototype.constructor = Cat

Animal.prototype.species = "动物"
Cat.prototype.gender = "formall"
var cat1 = new Cat()
var a = new Animal()
console.log(cat1.species)  // 动物
console.log(cat1.age)  // undefined
console.log(a.gender) // undefined

显然这种方式解决了子父类共享原型对象的问题,但是无法继承父类实例属性的问题还在。依然不能访问父类的实例属性。
==优点==:
子类添加原型属性,父类不会更新;

==缺点==:
无法继承父类实例属性;

五、组合继承(构造函数+原型链)
实现了这么多种继承,但是每种都有缺点不足。能不能去其糟粕取其精华呢?实现一个较优的继承方式。

function Animal(species, age) {
   this.species = species || "动物"
   this.age = age || 10
   this.list = [1,2,3]
}
function Cat() {
   Animal.call(this)
}
Cat.prototype = Object.create(Animal.prototype)
Cat.prototype.constructor = Cat

var c1 = new Cat('cat1')
var c2 = new Cat('cat2')
var a1 = new Animal('ani',10)

// 验证子父类原型对象共享问题
Animal.prototype.area = "Asia"
Cat.prototype.gender = "formall"
console.log(c1.area) // Asia
console.log(a1.gender) // undeifined

// 验证无法访问父类实例属性问题
console.log(c1.species) // 动物

// 验证不同实例共享父类实例属性问题
c1.list.push(4)
console.log(c1.list) // [1,2,3,4]
console.log(c2.list) // [1,2,3]

其实继承还有很多种实现方式,就不一一举例了。并没有最好的方式,不同的实现有各自的优缺点,找到最适合的就是最好的。


巴斯光年
274 声望23 粉丝