知识是零碎的,整理归纳更方便学习,工作及面试。
本篇涉及到的所有理论知识均来自于《JavaScript高级程序设计》,仅为每种继承方式配备示例代码和应用场景分析。
前言
很多面向对象语言都支持两种继承:接口继承 和实现继承。
前者只继承方法签名,后者继承实际的方法。接口继承在 ECMAScript 中是不可能的,因为函数没有签名。实现继承是 ECMAScript 唯一支持的继承方式,而这主要是通过原型链实现的。
原型链
定义和关系
基本思路:通过原型继承多个引用类型的属性和方法。
首先要清楚构造函数、原型和实例的关系:
- 每个构造函数都有一个原型对象
- 原型有一个属性指回构造函数
- 实例有一个内部指针指向原型
示例
原型链继承,代码示例:
javascript
function Animal() {
this.isAnimal = true;
}
Animal.prototype.getAnimal = function () {
return this.isAnimal;
};
function Dog() {
this.canFly = false;
}
Dog.prototype = new Animal(); // Animal实例赋值给Dog原型
Dog.prototype.getDog = function () {
return this.canFly;
};
let dog = new Dog();
console.log(dog.getAnimal()); // true
上面代码定义了两个类型:Animal
和Dog
。
这两个类型分别定义了一个属性和一个方法,Animal
定义一个属性isAnimal
是否为动物,一个方法getAnimal
返回是否为动物的结果。Dog
定义了一个属性canFly
能不能飞,明显狗不能飞,getDog
方法返回不能飞的结果。
这两个类型的主要区别是,Dog
通过创建 Animal
的实例并将其赋给自己的原型 Dog.prototype
,实现了对Aniaml
的继承 。这个赋值重写了Dog
最初的原型,将其替换为Animal
的实例。这表明Animal
实例可以访问的所有属性和方法,也会存在于Dog
的原型上,因此Dog
的实例可以调用getAnimal
方法,并返回了属性isAnimal
。
默认情况下,所有引用类型都继承自 Object
,这也是通过原型链实现的。任何函数的默认原型都是一个 Object
的实例,这意味着这个实例有一个内部指针指向Object.prototype
。这也是为什么自定义类型能够继承包括 toString()
、valueOf()
在内的所有默认方法的原因。
子类有时候需要覆盖父类的方法,或者增加父类没有的方法,这些方法必须在原型赋值之后再添加到原型上。
javascript
function Animal() {
this.isAnimal = true;
}
Animal.prototype.getAnimal = function () {
return this.isAnimal;
};
function Dog() {
this.isDog = true;
}
Dog.prototype = new Animal();
Dog.prototype.getDog = function () {
return this.isDog;
};
// 覆盖原有方法
Dog.prototype.getAnimal = function () {
return "这是动物";
};
let dog = new Dog();
console.log(dog.getAnimal());
let animal = new Animal();
console.log(animal.getAnimal());
Dog
原型上覆写了getAnimal
方法,在Dog
实例化调用该方法时,调用的是覆写之后的方法。Animal
的实例仍然是调用原有的方法,因为方法的覆写是在把原型赋值为Animal
的实例之后定义的。
完整原型链图解关系:
问题
主要问题是出现在原型中包含引用值的时候。原型中包含的引用值会在所有实例间共享,这也是为什么属性通常会 在构造函数中定义而不会定义在原型上的原因。在使用原型实现继承时,原型实际上变成了另一个类型的实例。这意味着原先的实例属性摇身一变成为了原型属性。
例如:
javascript
function Colors() {
this.colorList = ['red', 'green', 'yellow']
}
function Rainbow() {}
Rainbow.prototype = new Colors()
let myRainbow = new Rainbow()
myRainbow.colorList.push('indigo')
let yourRainbow = new Rainbow()
yourRainbow.colorList.push('black')
上面代码中,构造函数Colors
定义了一个colorList
属性,是一个引用类型的数组。每次Colors
实例后都会把自己的color
加到数组中。但是Rainbow
是通过原型继承Colors
,Rainbow.prototype
变成了Colors
的一个实例,因此也获得了colorList
属性,那Rainbow
的每个实例都会共享colorList
这个属性。
经典继承
又称为盗用构造函数,为了解决原型包含引用值导致的继承问题。
基本思路:在子类构造函数中调用父类构造函数。因为毕竟函数就是在特定上下文中执行代码的简单对象,所以可以使用apply()
和 call()
方法以新创建的对象为上下文执行构造函数。
示例
csharp
function Colors() {
this.colorList = ["red", "green", "yellow"];
}
function Rainbow() {
Colors.call(this);
}
let myRainbow = new Rainbow();
myRainbow.colorList.push("indigo");
let yourRainbow = new Rainbow();
yourRainbow.colorList.push("black");
通过使用 call()
或apply()
方法,Colors
构造函数在为 Rainbow
的实例创建的新对象的上下文中执行了。这相当于新的 Rainbow
对象上运行了Colors
函数中的所有初始化代码。结果就是每个实例都会有自己的 colorList
属性。
相较于原型链继承,经典继承的优点还有一个就是支持在子类构造函数中向父类构造函数传参。
javascript
function Animal(lang) {
this.saying = function () {
console.log(lang);
};
}
function Dog() {
Animal.call(this, "汪汪汪");
}
const dog = new Dog();
const cat = new Animal('喵喵喵')
问题
必须在构造函数中定义方法,因此函数不能重用;子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。
例如:
组合继承
又称为伪经典继承。综合了原型链和盗用构造函数(经典继承),将两者的优点集中了起来。
基本思路:使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性。
示例
ini
function Animal(lang) {
this.lang = lang;
this.colors = ["white", "black"];
}
Animal.prototype.saying = function () {
console.log(this.lang);
};
function Dog(lang, age) {
Animal.call(this, lang);
this.age = age;
}
Dog.prototype = new Animal();
Dog.prototype.getAge = function () {
console.log(this.age);
};
const dog1 = new Dog("汪汪汪", 3);
dog1.colors.push("yellow");
const dog2 = new Dog("吠吠吠", 5);
dog2.colors.push("gray");
上面代码,Animal
构造函数定义了一个lang
属性,在它的原型上定义了个saying
方法。Dog
构造函数调用了Animal
构造函数,传入lang
属性,定义了自己的属性age
,Dog.prototype
被赋值为Animal
的实例,又在原型上上添加了新方法getAge
。
基于Dog
创建两个实例dog1
和dog2
,两个实例都有自己的属性,共享相同的方法。
问题
存在效率问题。最主要的效率问题就是父类构造函数始终会被调用两次:一次在是创建子类原型时调用,另一次是在子类构造函数中调用。本质上,子类原型最终是要包含超类对象的所有实例属性,子类构造函数只要在执行时重写自己的原型就行了。
原型式继承
适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。
Object.create()
方法将原型式继承的概念规范化。这个方法接收两个参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个可选)。
示例
当Object.create()
方法只有一个参数时。
ini
let animal = {
name: 'dog',
behavior: ['eat', 'run', 'jump']
}
let anotherAnimal = Object.create(animal)
anotherAnimal.name = 'cat'
anotherAnimal.behavior.push('climb')
let yetAnotherAnimal = Object.create(animal)
yetAnotherAnimal.name = 'bird'
yetAnotherAnimal.behavior.push('fly')
animal
对象定义了另一个对象也应该共享的信息,新对象的原型是animal
,意味着它的原型上既有原始值属性又有引用值属性。这也意味着animal.behavior
不仅是 animal
的属性,也会跟 anotherAnimal
和 yetAnotherAnimal
共享。这里实际上克隆了两个 animal
。
Object.create()
的第二个参数与Object.defineProperties()
的第二个参数一样:每个新增属性都通过各自的描述符来描述。以这种方式添加的属性会遮蔽原型对象上的同名属性。
css
let animal = {
name: 'dog',
behavior: ['eat', 'run', 'jump']
}
let anotherAnimal = Object.create(animal, {
name: {
value: 'cat'
},
behavior: {
value: ['eat', 'fly']
}
})
寄生式继承
基本思想:创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象。
寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。
示例
javascript
function createAnother(original){
let clone = object(original); // 通过调用函数创建一个新对象
clone.sayHi = function() { // 以某种方式增强这个对象
console.log("hi");
};
return clone; // 返回这个对象
}
let animal = {
name: "dog",
friends: ["eat", "run", "jump"]
};
let anotherAnimal = createAnother(animal);
anotherAnimal.sayHi(); // "hi"
上面代码基于animal
对象返回了一个新对象。新返回的anotherAnimal
对象具有 animal
的所有属性和方法,还有一个新方法叫 sayHi()
。
object()
函数不是寄生式继承所必需的,任何返回新对象的函数都可以在这里使用。
寄生式组合继承
旨在解决组合式继承的问题:多次调用构造函数。
寄生式组合继承通过盗用构造函数 继承属性,但使用混合式原型链继承方法。
基本思路:不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。说到底就是使用寄生式继承来继承父类原型,然后将返回的新对象赋值给子类原型。
基本模式:
ini
function inheritPrototype(subType, superType) {
let prototype = object(superType.prototype); // 创建对象
prototype.constructor = subType; // 增强对象
subType.prototype = prototype; // 赋值对象
}
这个 inheritPrototype()
函数实现了寄生式组合继承的核心逻辑。
这个函数接收两个参数:子类构造函数 和父类构造函数 。在这个函数内部,第一步是创建父类原型的一个副本。然后,给返回的prototype
对象设置constructor
属性,解决由于重写原型导致默认constructor
丢失的问题。最后将新创建的对象赋值给子类型的原型。
示例
javascript
// 定义父类
function Animal(name, lang) {
this.name = name;
this.lang = lang;
}
// 父类原型方法
Animal.prototype.saying = function () {
console.log(`${this.name},${this.lang}`);
};
// 继承方法
function inherit(superType, subType) {
function inheritFn() {
this.constructor = subType;
}
inheritFn.prototype = superType.prototype;
subType.prototype = new inheritFn();
}
// 子类Dog
function Dog(name, lang, age) {
Animal.call(this, name, lang); // 借用构造函数
this.age = age; // Dog类特有属性
}
// 继承父类
inherit(Animal, Dog);
Dog.prototype.dogAge = function () {
console.log(`${this.name}今年${this.age}岁了`);
};
// 子类Cat
function Cat(name, lang, color) {
Animal.call(this, name, lang);
this.color = color;
}
inherit(Animal, Cat);
Cat.prototype.catColor = function () {
console.log(`${this.name}是${this.color}的`);
};
const dog = new Dog("狗", "汪汪汪", "2");
const cat = new Cat("猫", "喵喵喵", "灰色");
上面代码定义了一个父类Animal
动物类,父类有两个属性分别是名称name
和语言lang
,并在父类原型上定义一个方法saying
输出这个动物是怎么叫的。
实现一个继承方法,在new inheritFn
时将构造函数指向子类,将子类的原型指向父类的一个副本。
定义两个子类Dog
和Cat
,在将子类Dog
的原型指向父类Animal
原型的副本之后,也就是调用了继承方法之后,才可以在子类Dog
的原型上定义自己的方法,Cat
类似。但是Dog
和Cat
都有自己的属性和自己的方法,同时继承了父类Animal
的属性和方法。
寄生式组合继承的高效体现在它只调用了一次Animal
构造函数。
类extends
以上都是使用ES5的特性模拟类,实现继承的代码也显得非常冗长和混乱。ES6新增的class
关键字,具有正式定义类的能力。
需要注意的是,类(class)是ES6中新的基础性语法糖结构,表面上看起来可以支持正式的面向对象编程,但实际上它背后使用的仍然是原型和构造函数的概念。
示例
类可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法,但这些都不是必需的,空的类定义照样有效。
javascript
class Animal {
constructor(name, lang) {
this._name = name;
this._lang = lang;
}
saying() {
console.log(`${this._name},${this._lang}`);
}
}
class Dog extends Animal {
constructor(name, lang, age) {
super(name, lang);
this._age = age;
}
dogAge() {
console.log(`${this._name}今年${this._age}岁了`);
}
}
const dog = new Dog("狗", "汪汪汪", 2);
dog.saying();
dog.dogAge();