JavaScript 继承的六种方式
很多面向对象语言都支持接口继承和实现继承两种继承方式,前者只继承方法签名,后者继承实际的方法。接口继承在 ECMAScript 中是不可能的,因为函数没有签名。实现继承是 ECMAScript 唯一支持的继承方式,这主要是通过原型链实现的。
1. 原型链
1.1 思路
ECMAScript-262 把原型链定义为 ECMAScript 的主要继承方式。其基本思想是通过原型继承多个引用类型的属性和方法。
构造函数、原型和实例的关系:每个构造函数都有一个原型对象,原型有一个属性指回与之关联的构造函数,而实例有一个内部指针指向原型。
原型链:若原型是另一个类型的实例,则这个原型本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另一个构造函数。这样在实例和原型之间就构造了一条原型链。
function SuperType () {
this.property = true;
}
SuperType.prototype.getSuperValue = function () {
return this.property;
}
function SubType () {
this.subproperty = false;
}
// 继承 SuperType
SubType.prototype = new SuperType();
SubType.prototype.getSubValue = function () {
return this.subproperty;
}
let instance = new SubType();
console.log(instance.getSuperValue()); // true
SubType
通过创建SuperType
的实例并将其赋值给自己的原型SubType.prototype
实现了对SuperType
的继承,这一赋值重写了Subtype
最初的原型,将其替换为SuperType
的实例,即SuperType
实例可以访问的所有属性和方法也会存在于SubType.prototype
,同时于是instance
(通过内部的[[Prototype]]
)指向SubType.prototype
,而SubType.prototype
(作为SuperType
的实例又通过内部的[[Prototype]]
)指向SuperType.prototype
。
注意:
getSuperValue
方法还在SuperType.prototype
对象上,而property
属性则在SubType.prototype
上。因为getSuperValue
是一个原型方法,而property
是一个实例属性。SubType.prototype
现在是SuperType
的一个实例,因此其上会存储property
属性。- 由于
SubType.prototype
的constructor
属性被重写为指向SuperType
,所以instance.constructor
也指向SuperType
。 - 在通过原型链实现继承时,搜索过程会沿着原型链继续向上,调用
instance.getSuperValue
会依次搜索instance
、SubType.prototype
和SuperType.prototype
。在找不到属性或方法时,搜索过程总是要到原型链末端才会停下。
1.2 扩展
默认原型
默认情况下,所有引用类型都继承自
Object
,这也是通过原型链实现的。任何函数的默认原型都是一个Object
的实例,即这个实例有一个内部指针指向Object.prototype
。因此自定义类型能够继承包括toString
、valueOf
在内的所有默认方法。原型与继承的关系
原型与继承的关系可以通过两种方式确定:
instanceof
操作符:若一个实例的原型链中出现过相应的构造函数,则instanceof
操作符返回true
。console.log(instance instanceof Object); // true console.log(instance instanceof SuperType); // true console.log(instance instanceof SubType); // true
isPrototypeOf
方法:原型链中的原型调用这个方法并传入实例时返回true。
console.log(Object.prototype.isPrototypeof(instance)); // true console.log(SuperType.prototype.isPrototypeof(instance)); // true console.log(SubType.prototype.isPrototypeof(instance)); // true
关于方法
若希望覆盖父类的方法或增加父类没有的方法时,必须在原型赋值之后再添加到原型上。
function SuperType () { this.property = true; } SuperType.prototype.getSuperValue = function () { return this.property; } function SubType () { this.subproperty = false; } // 继承 SuperType SubType.prototype = new SuperType(); // 新方法 SubType.prototype.getSubValue = function () { return this.subproperty; } // 覆盖已有方法 SubType.prototype.getSuperValue = function () { return false; } let instance = new SubType(); console.log(instance.getSuperValue()); // false
以对象字面量方式创建原型方法会破坏之前的原型链,相当于重写了原型链,将原型设置为一个
Object
的实例。function SuperType () { this.property = true; } SuperType.prototype.getSuperValue = function () { return this.property; } function SubType () { this.subproperty = false; } // 继承 SuperType SubType.prototype = new SuperType(); SubType.prototype = { getSubValue () { return this.subproperty; } someOtherMethod () { return false; } } let instance = new SubType(); console.log(instance.getSuperValue()); // error
原型链的问题
主要问题是当原型中包含引用值时,该引用值会在实例间共享,这也是属性通常在构造函数中定义而不会在原型上定义的原因。
function SuperType () { this.colors = ['red', 'blue', 'green']; } function SubType () {} SubType.prototype = new SuperType(); let instance1 = new SubType(); instance1.colors.push('black'); let instance1 = new SubType(); console.log(instance1.colors); // 'red', 'blue', 'green', 'black' console.log(instance2.colors); // 'red', 'blue', 'green', 'black'
当
SubType
通过原型继承SuperType
之后,SubType.prototype
变成SuperType
的一个实例,因而获得了自己的colors
属性,类似于创建了SubType.prototype.colors
属性,故SubType
的所有实例都会共享这个colors
属性。- 在实例化子类型时不能给父类型的构造函数传参。
2. 盗用构造函数
盗用构造函数(constructor stealing)技术也称为对象伪装或经典继承。
2.1 思路
基本思路是在子类构造函数中调用父类构造函数。因为函数就是在特定上下文中执行代码的简单对象,所以可以使用apply
和call
方法以新创建的对象为上下文执行构造函数。
function SuperType () {
this.colors = ['red', 'blue', 'green'];
}
function SubType () {
// 继承 SuperType
SuperType.call(this);
}
let instance1 = new SubType();
instance1.colors.push('black');
let instance1 = new SubType();
console.log(instance1.colors); // 'red', 'blue', 'green', 'black'
console.log(instance2.colors); // 'red', 'blue', 'green'
SuperType
构造函数在为SubType
的实例创建的新对象的上下文中执行,相当于新的SubType
对象上运行了SuperType
构造函数中的所有初始化代码,即每个实例都会有自己的colors
属性。
2.2 扩展
传递参数
盗用构造函数的优点是可以在子类构造函数中向父类构造函数传参。
function SuperType (name) { this.name = name; } function SubType () { SuperType.call(this, 'Stan'); this.age = 24; } let instance = new SubType(); console.log(instance.name); // 'Stan' console.log(instance.age); // 24
在
SubType
构造函数中调用SuperType
构造函数时传入参数,实际上会在SubType
的实例上定义name
属性。为确保
SuperType
构造函数不会覆盖SubType
定义的属性,可以在调用父类构造函数之后再给子类实例添加额外的属性。问题
盗用构造函数的主要缺点也是使用构造函数模式自定义类型的问题,即必须在构造函数中定义方法,因此函数不能重用。此外,子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。
3. 组合继承
组合继承也称为伪经典继承,综合了原型链和盗用构造函树的优点。组合继承弥补了原型链和盗用构造函数的不足,是 JavaScript 中使用最多的继承模式。同时,组合继承也保留了instanceof
操作符和isPrototypeOf
方法识别合成对象的能力。
3.1 思路
组合继承的基本思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。
function SuperType (name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}
SuperType.prototype.sayName = function () {
console.log(this.name);
}
function SubType (name, age) {
// 继承属性
SuperType.call(this, name);
this.age = age;
}
// 继承方法
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function () {
console.log(this.age);
}
let instance1 = new SubType('Stan', 24);
instance1.colors.push('black');
console.log(instance1.colors); // 'red', 'blue', 'green', 'black'
instance1.sayName(); // 'Stan'
instance1.sayAge(); // 24
let instance2 = new SubType('Greg', 20);
console.log(instance2.colors); // 'red', 'blue', 'green'
instance2.sayName(); // 'Greg'
instance2.sayAge(); // 20
3.2 扩展
组合继承也存在效率问题,最主要的是父类构造函数会被调用两次,第一次是在创建子类原型时调用,第二次是在子类构造函数中调用。本质上,子类原型最终要包含超类对象的所有实例属性,只要在子类构造函数执行时重写其原型即可。
function SuperType (name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}
SuperType.prototype.sayName = function () {
console.log(this.name);
}
function SubType (name, age) {
// 第二次调用
SuperType.call(this, name);
this.age = age;
}
// 第一次调用
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function () {
console.log(this.age);
}
以上代码执行后,SubType.prototype
上会有name
和colors
两个属性,二者是SuperType
的实例属性,现在成为了SubType
的原型属性。在调用SubType
构造函数时,也会调用SuperType
构造函数,此时会在新对象上创建name
和colors
两个实例属性,此时将遮蔽原型上同名的两个属性。
解决组合继承效率问题的方案是寄生组合继承。
4. 原型式继承
4.1 思路
最初,原型式继承(prototypal inheritance)是一种不涉及严格意义上构造函数的继承方法,出发点是即使不自定义类型也可以通过原型实现对象之间的信息共享。
function object (o) {
function F () {}
F.prototype = o;
return new F();
}
object
函数通过创建一个临时构造函数,并将传入的对象赋值给这个构造函数的原型,最后返回这个临时类型的一个实例,实现继承。本质上object
函数对传入的对象执行了一次浅拷贝。
let person = {
name: 'Stan',
friends: ['xiaoming', 'xiaohong']
};
let anotherPerson = object(person);
anotherPerson.name = 'xiaobai';
anotherPerson.friends.push('xiaohei');
let yetAnotherPerson = object(person);
yetAnotherPerson.name = 'xiaohei';
yetAnotherPerson.friends.push('xiaohei');
console.log(person.friends); // 'xiaoming', 'xiaohong', 'xiaobai', 'xiaohei'
原型式继承适用于有一个对象并希望在其基础上再创建一个新对象的情况。把这个对象传给object
函数,然后再对返回的对象进行适当修改。
4.2 扩展
Object.create
方法ECMAScript5 增加了
Object.create
方法对原型式继承的概念进行了规范。Object.create
方法接收两个参数,作为新对象原型的对象,以及给新对象定义额外属性的对象(可选)。在只有第一个参数时,Object.create
方法与之前的object
方法效果相同。let person = { name: 'Stan', friends: ['xiaoming', 'xiaohong'] } let anotherPerson = Object.create(person); anotherPerson.name = 'xiaobai'; anotherPerson.friends.push('xiaohei'); let yetAnotherPerson = Object.create(person); yetAnotherPerson.name = 'xiaohei'; yetAnotherPerson.friends.push('xiaohei'); console.log(person.friends); // 'xiaoming', 'xiaohong', 'xiaobai', 'xiaohei'
Object.create
方法的第二个参数与Object.defineProperties
方法的第二个参数一样,每个新增属性都通过各自的描述符来描述。let person = { name: 'Stan', friends: ['xiaoming', 'xiaohong'] } let anotherPerson = Object.create(person, { name: { value: 'Greg' } }); console.log(anotherPerson.name); // 'Greg'
问题
原型式继承适用于不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。但与原型链一样,属性中包含的引用值始终会在相关对象间共享。
5. 寄生式继承
寄生式继承(parasitic inheritance)是一种与原型式继承比较接近的继承方式。
5.1 思路
寄生式继承的思路类似于寄生构造函数和工厂模式,即创建一个实现继承的函数,以某种方式增强对象,最后返回这个对象。
function createAnother (original) {
// 创建一个新对象
let clone = object(original);
// 以某种方式增强对象
clone.sayHi = function () {
console.log('hi');
};
return clone;
}
let person = {
name: 'Stan'
}
let anotherPerson = createAnother(person);
anotherPerson.sayHi(); // 'hi'
5.2 扩展
- 寄生式继承同样适用于主要关注对象,而不在乎类型和构造函数的场景。
object
函数不是寄生式继承所必需的,可以使用任何返回新对象的函数。- 通过寄生式继承给对象添加函数会导致函数难以重用,与构造函数模式类似。
6. 寄生组合继承
寄生组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法。
寄生组合继承的基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本,即使用寄生式继承来继承父类原型,再将返回的新对象赋值给子类原型。
function inheritPrototype (SubType, SuperType) {
let prototype = object(SuperType.prototype);
prototype.constructor = SubType;
SubType.prototype = prototype;
}
inheritPrototype
函数实现了寄生组合继承的核心逻辑。函数接收子类构造函数和父类构造函数两个参数。在函数内部,首先创建父类原型的副本;其次给返回的prototype
对象设置constructor
属性,解决由于重写原型导致默认constructor
丢失的问题;将新创建的对象赋值给子类型的原型。
function SuperType (name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}
SuperType.prototype.sayName = function () {
console.log(this.name);
};
function SubType (name, age) {
SuperType.call(this, name);
this.age = age;
}
inheritPrototype(SubType, SuperType);
SubType.prototype.sayAge = function () {
console.log(this.age);
};
使用寄生组合继承时,原型链仍然保持不变,instanceof
操作符和isPrototypeOf
方法正常有效。寄生组合继承是引用类型继承的最佳模式。