几乎所有语言都有面向对象的概念,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.prototype
和Animal.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]
其实继承还有很多种实现方式,就不一一举例了。并没有最好的方式,不同的实现有各自的优缺点,找到最适合的就是最好的。