继承的4个主要目标
只要满足以下4个主要目标,就是完成了继承:
- 继承实例属性
且每个子类的实例应该创建自己的实例属性。
- 继承静态属性
- 继承原型属性
子类可以访问和使用父类原型及其原型链上的属性。
- 保持子类的类型
- 子类实例应该被认为是子类类型
- 同时也能够被正确地识别为父类的实例。
所谓继承,就是想法设法实现以上4个目标。因此理解一种继承方式的时候,可从这4点入手,看其是如何实现的,从而理解其原理。
常见的继承方式
如果语言本身对于继承有良好的支持,当然最好,否则的话,开发者只能自己hack各种方式。在JS引入extends关键字之前,开发者们hack了很多种继承方式。下面我们用上面的4个条件来检验几个继承方式,并指出其中存在的问题。
Extends继承
这是目前最推荐的方式,因为它有语言层面的支持,优雅好理解。
class Parent {
static attr = "parent";
constructor(type) {
this.type = type;
this.colors = ["red"];
}
sayType() {
return this.type;
}
}
class Child extends Parent {
constructor(name) {
super(name);
}
}
const parent = new Parent("parent");
const child = new Child("child");
const child2 = new Child("child2");
// 1. 测试静态属性
console.log(Child.attr === "parent");
// 2. 测试实例属性
console.log(child.type === "child");
// 3. 测试原型属性
console.log(child.sayType() === "child");
console.log(child.__proto__ === Child.prototype);
console.log(Child.prototype.__proto__ === Parent.prototype);
// 4. 测试类型
console.log(child instanceof Child); // 是 Child 类型
console.log(child instanceof Parent); // 原型链中有 Parent
console.log(child.__proto__.constructor === Child);
// 5. 测试是否共享 colors 数组
console.log(child.colors === child2.colors); // false
达成的继承目标: 4个
通过上面的测试代码可知,5个继承目标全部达成。下面解释下如何达成的目标:
- 继承静态属性
Child.__proto__ = Parent
要时刻牢记:类本质上是函数,函数本质上是对象,对象一般都有 __proto__ 属性。extends背后做的工作之一就是将Child的__proto__指向Parent对象。 这样Child上没有attr属性时,就会接着查找Child.__proto__.attr,即Parent.attr。
另外注意:Child.__proto__原本是指向Function.prototype。因为Child是一个对象,其__proto__指向其构造函数的prototype。
- 继承实例属性
这一点是通过调用Parent构造函数,并以Child的实例对象作为this实现的。调用super函数的过程也是如此。
- 继承原型属性
这一点通过Child原型对象的原型来实现的:
- 实例对象
child上没有sayType方法 - 然后沿着
child.__proto__找到Child.prototy,也没有 - 接着沿着
child.__proto__.__proto__找到Child.prototy.__proto__,即Parent.prototype,找到了!
- 保持子类的类型
- 是
Child类型 - 是
Parent的实例
原型链中有Parent,instanceof就会判断实例对象child是Parent的实例。对此有疑问的读者可查看MDN文档。
小结
extends继承因为是语言层面的支持,因此是目前最简洁、优雅的继承方式,也是最推荐的方式,没什么明显缺点。其本身是一个“语法糖”,背后的继承机制就是我们上面介绍的这4点:在原型链的基础上各种补丁操作,其中很多操作开发者们早已hack出来了。
原型链继承
将子类的原型指向父类的实例来实现继承
// 父类
function Parent() {
this.name = "Parent";
this.colors = ["red", "blue", "green"];
}
Parent.prototype.sayHello = function () {
console.log("Hello");
};
Parent.type = "parent";
// 子类
function Child() {}
// 将子类的原型指向父类的实例
Child.prototype = new Parent();
// 将子构造函数对象指向父构造函数对象
Child.__proto__ = Parent;
// 创建子类实例
let child1 = new Child();
let child2 = new Child();
// 1. 测试实例属性
console.log(child1.name); // 输出: "Parent"
child1.sayHello(); // 输出: "Hello"
// 2. 测试原型属性 & 测试共享原型属性
child1.colors.push("black");
console.log(child2.colors); // 输出: ["red", "blue", "green", "black"]
// 3. 测试类型
console.log(child1 instanceof Child); // 输出: true
console.log(child1 instanceof Parent); // 输出: true,因为子类的原型链上有父类的原型
console.log(child1 instanceof Object); // 输出: true,因为所有对象最终都是 Object 的实例
// 4. 测试静态属性
console.log(Child.type === "parent");
达成的继承目标:3.5个
- 继承实例属性(有缺陷)
实现机制是,访问child1.name没找到,尝试查找child1.__proto__.name,即Child.prototypr.name,即Parent的一个实例对象。如此实现实例属性的继承。
缺陷是: 像colors这样的对象属性,在父实例中,是每个实例单独有一个。而在子类实例中,所有实例通过原型链的方式共享同一个对象属性,一旦其中一个实例更改,其他实例都受影响。
- 继承原型属性
原型属性的查找比上面“继承实例属性”多一步查询,机制类似。
- 保持对象的类型
实现机制与extends继承一样。
- 继承静态属性
与extends继承一样,通过Child.__proto__ = Parent实现(一般没有静态属性时,这一步省略不写)。
问题
- 本应是实例属性,却通过原型链继承:如上第1点所述,
colors属性,本应所有子类实例单独有一个,但是却成为通过原型链继承的属性,很容易相互影响。
构造函数继承(经典继承)
通过在子类构造函数中调用父类的构造函数来实现属性继承
// 父类
function Parent(name) {
this.name = name || 'Parent';
this.colors = ['red', 'blue', 'green'];
}
Parent.prototype.sayHello = function() {
console.log('Hello');
};
// 子类
function Child(name) {
Child.__proto__ = Parent;
Parent.call(this, name);
}
// 创建子类实例
let child1 = new Child('Child 1');
let child2 = new Child('Child 2');
// 测试1:继承实例属性
console.log(child1.name); // 输出: "Child 1"
console.log(child2.name); // 输出: "Child 2"
// 测试2:不继承原型属性和方法
child1.sayHello(); // 报错: child1.sayHello is not a function
// 测试3:子对象类型
console.log(child1 instanceof Child); // true
console.log(child1 instanceof Parent); // false
console.log(child1 instanceof Object); // true
达成的继承目标:2.5个
- 继承实例属性
与extend继承一样,通过执行父类函数,并将this指向子类实例实现。
- 保持子类实例类型(部分达成)
子对象被识别为Child的实例,不能被识别为Parent的实例,如测试3。原因是Parent类不在Child类的原型链上。
- 继承静态属性
通过Child.__proto__ = Parent实现(没有静态属性时,可省略)
问题
- 不能继承原型属性
- 子类实例不能被识别为父类的实例
组合继承
组合继承结合了原型链继承和构造函数继承的优点,解决了单一继承的问题。通过调用父类构造函数继承实例属性,通过将子类的原型对象设置为父类的实例,实现原型属性的继承。
// 父类
function Parent(name) {
this.name = name || 'Parent';
this.colors = ['red', 'blue', 'green'];
}
Parent.prototype.sayHello = function() {
console.log('Hello');
};
// 子类
function Child(name) {
// 第2次调用父构造函数
Parent.call(this, name); // 继承实例属性
}
// 第一次调用父构造函数
Child.prototype = new Parent(); // 继承原型属性和方法
Child.prototype.constructor = Child; // 修复构造函数指向
// 创建子类实例
let child1 = new Child('Child 1');
let child2 = new Child('Child 2');
// 测试1:继承实例属性
console.log(child1.name); // 输出: "Child 1"
child1.sayHello(); // 输出: "Hello"
// 测试2:继承原型属性
child1.colors.push('black');
console.log(child2.colors); // 输出: ["red", "blue", "green"]
// 测试3:子对象类型
console.log(child1 instanceof Child); // true
console.log(child1 instanceof Parent); // true
console.log(child1 instanceof Object); // true
// 测试4:静态属性
// 这一点最容易达成,不再赘述
达成的继承目标:4个
- 继承实例属性
通过调用父类函数,并指定this实现。(解决了原型链继承将实例属性作为原型属性继承的缺陷)
- 继承原型属性
通过Child.prototype = new Parent(),将Parent的原型链嵌入到Child上。(解决了构造函数继承无法继承原型属性的问题)
- 继承静态属性
这一点最容易达成,不再赘述。
- 保持子类类型
通过原型链来保证子类实例的类型,Child和Parent都在子类实例的原型链上。(解决了构造函数继承,子类实例不是父类实例的问题)
问题
调用了2次父构造函数: 一次是为了继承实例属性,一次是为了继承原型属性。
原型式继承
通过一个已有对象创建新对象,并继承其属性和方法
// 原型式继承函数
function inheritObject(o) {
function F() {}
F.prototype = o;
return new F();
}
// 父对象
function Parent() {
this.name = "Parent";
this.colors = ["red", "blue", "green"];
}
const parent = new Parent();
// 子对象
let child = inheritObject(parent);
// 测试1:继承实例属性
console.log(child.name); // 输出: "Parent"
// 测试2:继承原型属性
parent.sayHello = function () {
console.log("Hello");
};
child.sayHello(); // 输出: "Hello"
// 测试3:实例类型
console.log(child instanceof Parent); // true
console.log(child instanceof Object); // true
达成的继承目标:3.5个
- 实例属性(有缺陷)
跟原型链继承一样,本来实例属性,每个实例应该各自创建一套,但是这种继承方式,是将实例属性放到了原型链上。
- 原型继承
通过原型链机制实现。
- 实例类型
通过原型链机制实现。
- 静态属性
问题
- 无法传递参数
- 实例属性是用原型属性的继承方式继承的
引入F函数的作用
主要作用是子实例对象,不必再有自己的构造函数(父对象实例也不必有)。这是“对象实例”与“对象实例”之间的继承,而非构造函数之间。更符合JS的原型链继承的本质。
与原型链继承的区别
更推荐的办法
- 使用
Object.create。
function inheritObject(o) {
return Object.create(o);
}
总结
本文用继承的4个目标,对常见的继承方式做了衡量,并说明了背后的机制,希望可以帮助读者更好地理解、记忆继承。
JS的继承是建立在原型之上的,所以读者们理解继承时,要牢记以下几点:
- 原型式语言的继承,总是围绕原型链做文章
而原型链本质是链表,从数据结构上就表明,我们可以随意“打断”“连接”原型链,__proto__就是指针。继承的过程,往往就是如何“连接”原型链。而方式就是修改一个对象的__proto__属性。原型链是对象实例类型,及继承原型属性的关键。
- 万物皆对象,皆可
__proto__
构造函数也是对象,那么就可以/应该有__proto__属性。一般情况下,函数的__proto__属性指向Function.prototype,但我们可以“断链、改链”,将其指向父构造函数,从而完成静态属性的继承。
遇到一个对象,应该条件反射地想一想:它的__proto__是什么,__proto__.__proto__又是什么。
目前推荐的继承方式是:extends继承和Object.create继承。
对于我们大多数人来说,限制是好事,真有多种选择, 反而让人无所适从。