JavaScript的7种继承方式和各自的优缺点
前言:
面向对象编程很重要的一个方面,就是对象的继承。A 对象通过继承 B 对象,就能直接拥有 B 对象的所有属性和方法。这对于代码的复用是非常有用的。大部分面向对象的编程语言,都是通过“类”(class)实现对象的继承。传统上,JavaScript 语言的继承不通过 class(ES6 引入了class 语法),而是通过“原型对象”(prototype)实现。
ES5继承
构造函数、原型和实例的关系:每一个构造函数都有一个原型对象,每一个原型对象都有一个指向构造函数的指针,而每一个实例都包含一个指向原型对象的内部指针。
1、原型链实现继承
基本思想:利用原型让一个引用类型继承另一个引用类型的属性和方法,即让原型对象等于另一个类型的实例。子类型的原型为父类型的一个实例对象。
function Parent () {
this.names = ['one', 'two'];
}
function Child () {
}
Child.prototype = new Parent();
var child1 = new Child();
console.log(child1.names); // ["one", "two"]
child1.names.push('three');
console.log(child1.names); // ["one", "two", "three"]
var child2 = new Child();
console.log(child2.names); // ["one", "two", "three"]
这种方式实现的本质是通过将子类的原型指向了父类的实例,所以子类的实例就可以通过__proto__访问到 Student.prototype 也就是Person的实例,这样就可以访问到父类的私有方法,然后再通过__proto__指向父类的prototype就可以获得到父类原型上的方法。于是做到了将父类的私有、公有方法和属性都当做子类的公有属性。
优点:
- 父类新增原型方法/原型属性,子类都能访问到;
- 简单,易于实现。
缺点:
- 无法实现多继承;
- 来自原型对象的所有属性被所有实例共享;
- 创建子类实例时,无法向父类构造函数传参。
2.借用构造函数(经典继承)
这种方式关键在于:在子类型构造函数中通用call()调用父类型构造函数。
//例子一
function Parent () {
this.names = ['one', 'two'];
}
function Child () {
Parent.call(this);
}
var child1 = new Child();
child1.names.push('three');
console.log(child1.names); // ["one", "two", "three"]
var child2 = new Child();
console.log(child2.names); // ["one", "two"]
//例子二
function Parent (name) {
this.name = name;
}
function Child (name) {
Parent.call(this, name);
}
var child1 = new Child('one');
console.log(child1.name); // one
var child2 = new Child('two');
console.log(child2.name); // two
优点:
- 解决了原型链继承中子类实例共享父类引用属性的问题
- 创建子类实例时,可以向父类传递参数
- 可以实现多继承(call多个父类对象)
缺点:
- 实例并不是父类的实例,只是子类的实例
- 只能继承父类的实例属性和方法,不能继承原型属性和方法
- 无法实现函数复用,每个子类都有父类实例函数的副本,影响性能
3、组合继承( 原型链+借用构造函数 )
function Parent (name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}
Parent.prototype.getName = function () {
console.log(this.name)
;
}
function Child (name, age) {
Parent.call(this, name);
this.age = age;
}
Child.prototype = new Parent();
Child.prototype.constructor = Child;
var child1 = new Child('one', '18');
child1.colors.push('black');
console.log(child1.name); // one
console.log(child1.age); // 18
console.log(child1.colors); // ["red", "blue", "green", "black"]
var child2 = new Child('two', '20');
console.log(child2.name); // two
console.log(child2.age); // 20
console.log(child2.colors); // ["red", "blue", "green"]
这种方式融合原型链继承和构造函数的优点,是 JavaScript 中最常用的继承模式。不过也存在缺点就是无论在什么情况下,都会调用两次构造函数:一次是在创建子类型原型的时候,另一次是在子类型构造函数的内部,子类型最终会包含父类型对象的全部实例属性,但我们不得不在调用子类构造函数时重写这些属性。
优点:
- 可以继承实例属性/方法,也可以继承原型属性/方法
- 不存在引用属性共享问题
- 可传参
- 函数可复用
缺点:
- 调用了两次父类构造函数,生成了两份实例
4.原型式继承
不用严格意义上的构造函数,借助原型可以根据已有的对象创建新对象,还不必因此创建自定义类型,因此最初有如下函数:
function createObj(o) {
function F(){}
F.prototype = o;
return new F();
}
从本质上讲,createObj()对传入其中的对象执行了一次浅复制。
function createObj(o) {
function F(){}
F.prototype = o;
return new F();
}
var person = {
name: 'one',
friends: ['two', 'three']
}
var person1 = createObj(person);
var person2 = createObj(person);
person1.name = 'person1';
console.log(person2.name); // one
person1.friends.push('four');
console.log(person2.friends); // [ 'two', 'three', 'four' ]
ES5新增Object.create规范了原型式继承,接收两个参数,一个用作新对象原型的对象和(可选的)一个为新对象定义额外属性的对象,在传入一个参数的情况下,Object.create()和createObj()行为相同。
var person = {
name:'one',
friendes:['two', 'three']
};
var person1 = Object.create(person,{
name:{
value:"four"
}
});
//用这种方法指定的任何属性都会覆盖掉原型对象上的同名属性
console.log(person1.name); // four
person.friendes.push('four');
console.log(person1.friendes); // [ 'two', 'three', 'four' ]
缺点:
- 包含引用类型的属性值始终都会共享相应的值,这点跟原型链继承一样。
5.寄生式继承
寄生式继承是与原型式继承紧密相关的一种思路,它创造一个仅用于封装继承过程的函数,在函数内部以某种方式增强对象,最后再返回对象。
function createObj (o) {
var clone = Object.create(o);
clone.sayHi = function () {
console.log('hi');
}
return clone;
}
缺点:
- 跟借用构造函数模式一样,每次创建对象都会创建一遍方法,会因为做不到函数复用而降低效率。
6. 寄生组合式继承
通过借用构造函数来继承属性,通过原型链的混成形式来继承方法,不必为了指定子类型的原型而调用超类型的构造函数,只需要超类型的一个副本。本质上,就是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型。
function Parent (name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}
Parent.prototype.getName = function () {
console.log(this.name)
}
function Child (name, age) {
Parent.call(this, name);
this.age = age;
}
// 关键的三步
var F = function () {};
F.prototype = Parent.prototype;
Child.prototype = new F();
var child1 = new Child('one', '18');
console.log(child1);
//Parent { name: 'one', colors: [ 'red', 'blue', 'green' ], age: '18' }
最后我们封装一下这个继承方法:
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
function prototype(child, parent) {
var prototype = object(parent.prototype);
prototype.constructor = child;
child.prototype = prototype;
}
// 当我们使用的时候:
prototype(Child, Parent);
这种方式的高效率体现它只调用了一次 Parent 构造函数,并且因此避免了在 Parent.prototype 上面创建不必要的、多余的属性。与此同时,原型链还能保持不变;因此,还能够正常使用 instanceof 和 isPrototypeOf。开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。
ES6继承
7.ES6中class 的继承
ES6中引入了class关键字,class可以通过extends关键字实现继承,还可以通过static关键字定义类的静态方法,这比 ES5 的通过修改原型链实现继承,要清晰和方便很多。
ES5 的继承,实质是先创造子类的实例对象this,然后再将父类的方法添加到this上面(Parent.apply(this))。ES6 的继承机制完全不同,实质是先将父类实例对象的属性和方法,加到this上面(所以必须先调用super方法),然后再用子类的构造函数修改this。
需要注意的是,class关键字只是原型的语法糖,JavaScript继承仍然是基于原型实现的。
class Parent {
//调用类的构造方法
constructor(name, age) {
this.name = name
this.age = age
}
//定义一般的方法
showName() {
console.log("调用父类的方法")
console.log(this.name, this.age);
}
}
let p1 = new Parent('one', 36)
console.log(p1) // Parent { name: 'one', age: 36 }
//定义一个子类
class Child extends Parent {
constructor(name, age, salary) {
super(name, age)//通过super调用父类的构造方法
this.salary = salary
}
showName() {//在子类自身定义方法
console.log("调用子类的方法")
console.log(this.name, this.age, this.salary);
}
}
let s1 = new Child('two', 32, 28000)
console.log(s1) // Child { name: 'two', age: 32, salary: 28000 }
s1.showName()
// 调用子类的方法
// two 32 28000
优点:
- 语法简单易懂,操作更方便
缺点:
- 并不是所有的浏览器都支持class关键字