[TOC]

写作不易,Star是最大鼓励,感觉写的不错的可以给个Star⭐,请多多指教。本博客的Github地址

由于js不像java那样是真正面向对象的语言,js是基于对象的,它没有类的概念。所以,要想实现继承,可以用js的原型(prototype)机制或者用apply和call方法去实现。在js中,被继承的函数称为超类型(父类,基类也行),继承的函数称为子类型(子类,派生类)。 下面介绍一些js继承的方式:

原型链继承

基本思想:利用原型让一个引用类型继承另一个引用类型的属性和方法,对象间依靠原型链来实现继承。 需要注意:每个函数都有一个prototype属性,每个对象(包括原型对象)都有一个__proto__属性。

function Parent() {}
Parent.prototype.sayName = function() {
    console.log(this.name);
}
function Child(name, age) {
    this.name = name;
    this.age = age;
}
// 实现继承
Child.prototype = new Parent();
// 给子类添加子类特有的方法,注意顺序要在继承之后
Child.prototype.sayChildName = function() {
    console.log(this.name);
}
const child = new Child('lisi', 12);
// 调用继承的sayName方法
child.sayName(); // lisi
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

上述代码的重点在于:

Child.prototype = new Parent();
1

实现继承的本质是:用父类的实例对象来重写子类的原型对象(子类默认的原型对象是一个Object的空对象)。这样一来,原来存在于父类实例上的属性和方法,也会存在于子类的原型对象上。同时,Child.prototype中也会包含一个指向Parent.prototype的指针。

形成原型链:child ==> Child.prototype ==> Parent.prototype ==> Object.prototype ==> null。

需要注意:任何一个原型对象都有一个constructor属性,指向它的构造函数。如果没有Child.prototype = new Parent();这一行,Child.prototype.constructor是指向Child的;加了这一行以后,子类原型被重写后,Child.prototype将不再有constructor属性,因此会沿着原型链向上查找,Child.prototype.constructor最终指向了Parent。所以,为了确保Child.prototype.constructor是指向Child,需要重新指定Child.prototype.constructor = Child;

以上原型链继承还缺少一环,那就是Object,所有的构造函数都继承自Object。而继承Object是自动完成的,并不需要我们自己手动继承。

直接继承prototype

由于Parent对象中,不变的属性都可以直接写入Parent.prototype。所以,我们也可以让Child直接继承Parent.prototype。

function Parent() {}
function Child(name, age) {
    this.name = name;
    this.age = age;
}
// 将Child的prototype对象,然后指向Parent的prototype对象,这样就完成了继承
Child.prototype = Parent.prototype;
Child.prototype.constructor = Child; // 这里同样会修改Parent的构造函数
let child = new Child('lisi', 12);

console.log(Child.prototype.constructor); // Child
console.log(Parent.prototype.constructor); // Child
1
2
3
4
5
6
7
8
9
10
11
12

与前一种方法相比:

  • 优点:效率比较高,不用执行和建立Parent的实例了,比较省内存。
  • 缺点:Child.prototype和Parent.prototype现在指向了同一个对象,那么任何对Child.prototype的修改,都会反映到Parent.prototype上。

确定原型和实例的关系

可以通过两种方式来确定原型和实例之间的关系:instanceof操作符和isPrototypeof方法。

console.log(child instanceof Child); // true
console.log(child instanceof Parent); // true

console.log(Child.prototype.isPrototypeOf(child)); // true
console.log(Parent.prototype.isPrototypeOf(Child.prototype)); // true
1
2
3
4
5

原型链继承问题

本来为了构造函数属性的封装私有性,方法的复用性,提倡将属性声明在构造函数内,而将方法绑定在原型对象上,但是基于原型链的继承:子类的原型是父类的一个实例,所以父类的属性就变成子类原型的属性了。这就会带来一个问题,我们知道构造函数的原型属性在所有构造的实例中是共享的,所以原型中属性的改变会反应到所有的实例上,这就违背了我们想要属性私有化的初衷;

  • 引用类型的原型属性将被所有实例共享 举个🌰:
function Parent() {
    // colors是父类的实例属性,是子类的原型属性
    this.colors = ['red', 'blue'];
}
function Child() {}

Child.prototype = new Parent();

let child1 = new Child();
child1.colors.push('green');
console.log(child1.colors); // ["red", "blue", "green"]
let child2 = new Child();
console.log(child2.colors); // ["red", "blue", "green"]
1
2
3
4
5
6
7
8
9
10
11
12
13
  • 创建子类实例的时候,无法向父类的构造函数传递参数。

借用构造函数(类式继承)

为了解决以上原型链继承的两个问题,有一个叫借用构造函数的方法。 原理:只需要在子类构造函数内部使用apply或者call来调用父类的函数即可在实现属性继承的同时,又能传递参数,又能让实例不互相影响 。

function Parent(name) {
    this.colors = ['red','blue','green'];
    this.name = name;
}
function Child(name) {
    // 继承父类,同时还可以向父类构造函数传递参数
    // 构造函数中的this指向new的当前实例对象
    Parent.call(this, name);
}
let child1 = new Child('lisi');
let child2 = new Child('wangwu');
console.log(child1.name); // lisi
console.log(child2.name); // wangwu
child1.colors.push('yellow');
console.log(child1.colors); // ["red", "blue", "green", "yellow"]
console.log(child2.colors); // ["red", "blue", "green"]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

借用构造函数优缺点

优点:

  • 避免了引用类型的属性被所有实例共享;
  • 可以在子类构造函数中向父类构造函数传参。

借用构造函数虽然解决了刚才原型链继承的两个问题,但是方法都在构造函数中定义,每次创建一个新的实例对象都会创建一遍方法(我们都知道函数也是引用类型,相同方法多次创建的话会浪费内存空间)。没有原型,复用则无从谈起,所以我们需要原型链+借用构造函数的继承模式,这种模式称为组合继承。

组合继承(推荐使用)

组合式继承是比较常用的一种继承方法,核心思路是:使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承。这样,既通过在原型上定义的方法实现了复用,又保证每个实例都有它自己的属性。

特点:父类私有和公有的属性方法分别是子类实例的私有和公有的属性方法。

function Parent(name) {
    this.colors = ['red','blue','green'];
    this.name = name;
}
Parent.prototype.sayName = function() {
    console.log(this.name);
}
function Child(name) {
    // 对象冒充,同时给父类传递name参数
    Parent.call(this, name); // 实现父类私有属性和方法继承
}
Child.prototype = new Parent(); // 原型链继承,实现父类原型属性和方法继承
Child.prototype.constructor = Child;

const child1 = new Child('lisi');
child1.colors.push('yellow');
console.log(child1.colors); // ["red", "blue", "green", "yellow"]
child1.sayName(); // lisi

const child2 = new Child('wangwu');
console.log(child2.colors); // ["red", "blue", "green"]
child2.sayName(); // wangwu
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

上述例子实现了原型方法sayName的复用,同时每个实例都有自己的name和colors属性。

原型式继承

原型式继承,要求必须有一个对象可以作为另一个对象的基础。如果有这么一个对象的话,可以把它传递给object函数,然后再根据具体要求对得到的对象加以修改即可。在下面的例子中,可以作为另一个对象基础的是person对象,我们将其传入object函数,然后该函数就会返回一个新对象。这个新对象将person对象作为原型,所以它的原型中就包含一个基本类型值属性name和一个引用类型值属性family。也就说person.family不仅属于person所有,而且也会被p1和p2共享。这就相当于又创建了person对象的两个副本(即对person对象进行了浅复制)。

// 这种继承方式借助原型并基于已有的对象创建新对象
// 原型式继承首先在object函数内部创建一个临时性的构造函数 ,然后将传入的对象作为这个构造函数的原型,最后返回这个临时构造函数的一个新实例。
function object(o) {
    function F() {}
    F.prototype = o;
    return new F();
}
let person = {
    name: 'lisi',
    family: ['brother', 'sister', 'me']
};
let p1 = object(person);
p1.name = 'wangwu';
p1.family.push('mother');
console.log(p1.name); // wangwu
console.log(p1.family); // ["brother", "sister", "me", "mother"]

let p2 = object(person);
p2.name = 'zhaoliu';
p2.family.push('father');
console.log(p2.name); // zhaoliu
console.log(p2.family); // ["brother", "sister", "me", "mother", "father"]
console.log(person.name); // lisi
console.log(person.family); // ["brother", "sister", "me", "mother", "father"]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

规范化的原型式继承-Object.create()

ECMAScript5新增了Object.create()方法规范化了原型式继承。这个方法接收两个参数:一个用作新对象原型的对象和一个为新对象定义额外属性的对象(可选参数)。

let person = {
    name: 'lisi',
    family: ['brother', 'sister', 'me']
};
let p1 = Object.create(person);
p1.name = 'wangwu';
p1.family.push('mother');
console.log(p1.name); // wangwu
console.log(p1.family); // ["brother", "sister", "me", "mother"]

let p2 = Object.create(person);
p2.name = 'zhaoliu';
p2.family.push('father');
console.log(p2.name); // zhaoliu
console.log(p2.family); // ["brother", "sister", "me", "mother", "father"]
console.log(person.name); // lisi
console.log(person.family); // ["brother", "sister", "me", "mother", "father"]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

Object.create()方法的第二个参数与Object.defineProperties()方法的第二个参数格式相同:每个属性都是通过自己的描述符定义的。以这种方式指定的任何属性都会覆盖原型对象上的同名属性。

let p3 = Object.create(person, {
    name: { // 属性描述符对象
        value: 'zhangsan'
    }
});
console.log(p3.name); // zhangsan  覆盖了原型上的name属性
console.log(p3.family); // ["brother", "sister", "me", "mother", "father"]
1
2
3
4
5
6
7

原型式继承应用场景

如果只是想让一个对象与另一个对象保持类似的话,原型式继承完全可以胜任。

// 创建一个空对象
Object.create(null);
1
2

原型式继承缺点

  • 与原型链继承一样,引用类型值的属性始终都会共享相应的值。

寄生式继承

寄生式继承是与原型式继承紧密相关的一种思路,这种继承方式是把原型式继承+工厂模式结合起来,创建一个仅用于封装继承过程的函数,该函数内部以某种方式来增强对象,最后再像真地是它做了所有工作一样返回对象。

根据已有对象person,利用原型式继承返回一个新的对象,并根据要求对该对象进行增强。

function object(o) {
    function F(){}
    F.prototype = o;
    return new F();
}
let person = {
    name: 'lisi',
    family: ['brother', 'sister', 'me']
};
function createPerson(o) { // 工厂模式
    let clone = object(o); // 通过调用object函数创建一个新对象
    clone.sayName = function() { // 按要求增强对象
        console.log(this.name);
    }
    return clone;
}
let p1 = createPerson(person);
// p1是产生的新对象,不仅具有person的属性和方法,还有自己的sayName方法
p1.sayName(); // lisi
console.log(p1.name); // lisi
console.log(p1.family); // ["brother", "sister", "me"]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

寄生式继承缺点

  • 缺点:跟借用构造函数模式一样,每次创建对象都会创建一遍方法。

寄生组合式继承

这种继承方式的目的在于:解决组合继承中父类构造函数调用两次造成的子类原型上创建多余的、不必要的属性的问题。

所谓寄生组合式继承,即通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。基本思路是:不必为指定子类型的原型而调用超类型的构造函数,我们所需要的无非就是超类型原型的一个副本而已。我们可以使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型。这样就可以避免两次调用父类的构造函数,不会在子类原型上创建多余的、不必要的属性。

组合式继承的问题

组合式继承是js最常用的继承模式,但组合继承的超类型在使用过程中会被调用两次;一次是创建子类型原型的时候,另一次是在子类型构造函数的内部。 子类原型上最终会包含超类型对象的全部实例属性,而这些属性是多余的。

function SuperType(name) {
    this.name = name;
    this.color = ['red','blue','yellow'];
}
SuperType.prototype.sayName = function() {
    console.log(this.name);
};
function SubType(name, age) {
    SuperType.call(this, name); // 第二次调用SuperType()
    this.age = age;
}
SubType.prototype = new SuperType(); // 第一次调用SuperType()
console.log(SubType.prototype.constructor); // SuperType(name)
SubType.prototype.constructor = SubType; // 这里如果不设置,SubType.prototype.constructor将指向SuperType,因为上面实现继承的同时也重写了SubType.prototype
SubType.prototype.sayAge = function() {
    console.log(this.age);
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

子类原型上创建多余的、不必要的属性的过程:第一次调用SuperType()时,会将SuperType实例上的name和color属性挂载到SubType.prototype上;第二次调用SuperType()时,会将name和color属性挂载到SubType实例对象上,SubType实例上拥有自己的name和color属性,因此SubType.prototype上的name和color属性都是多余的。

寄生组合式继承实现

function object(o) { //o = SuperType.prototype
    // 将传入的对象作为创建的对象的原型
    function F(){}
    F.prototype = o; // F.prototype = SuperType.prototype
    return new F(); //这里返回父类原型的副本
}
function inheritPrototype(subType, superType) {
    var prototype = object(superType.prototype); // 创建对象,prototype实例对象的原型指向SuperType.prototype
    // console.log(prototype); // F {}
    //console.log(prototype.constructor); // SuperType(name)
    prototype.constructor = subType; // 增强对象  为创建的副本添加constructor属性,从而弥补下一步重写子类原型而让子类失去默认的constructor属性
    subType.prototype = prototype; // 指定对象  将超类型原型的副本赋值给子类原型,这样就实现了继承
    //console.log(subType.prototype.constructor); //SubType(name, age)
}

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.prototype = new SuperType();语句的作用
inheritPrototype(SubType, SuperType); // 通过这里实现继承
SubType.prototype.sayAge = function() { // 子类的原型方法
    console.log(this.age);
};

var instance1 = new SubType('lisi', 12);
instance1.colors.push('black');
console.log(instance1.colors);  // ["red", "blue", "green", "black"]
instance1.sayName(); // lisi
instance1.sayAge(); // 12


var instance2 = new SubType('wangwu', 13);
console.log(instance2.colors);  // ["red", "blue", "green"] 引用类型属性共享问题解决
instance2.sayName(); // wangwu
instance2.sayAge(); // 13
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

这种方式的高效率体现它只调用了一次SuperType构造函数,并且因此避免了在SubType.prototype上面创建不必要的、多余的属性。与此同时,原型链还能保持不变;因此,还能够正常使用instanceof和isPrototypeOf。开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。

js继承方式及其优缺点

继承方式 优点 缺点
原型链继承 - 1. 引用类型的原型属性将被所有实例共享;2. 创建子类实例的时候,无法向父类的构造函数传递参数
借用构造函数(类式继承) - 借用构造函数虽然解决了刚才两种问题,但没有原型,则复用无从谈起。
组合式继承 既通过在原型上定义方法实现了函数复用,又保证每个实例都有它自己的属性。 最大的问题:无论什么情况下,都会调用两次超类型构造函数。一次是在创建子类原型的时候,另一次是在子类型构造函数内部。子类型最终会包含超类型对象的全部实例属性,但我们不得不在调用子类型构造函数时重写这些属性。这样就会导致在子类原型上创建不必要的、多余的属性。
原型式继承 - 引用类型值的属性始终都会共享相应的值
寄生式继承 - 跟借用构造函数模式一样,每次创建对象都会创建一遍方法
寄生组合式继承 调用一次超类型构造函数,避免在子类原型上创建不必要的、多余的属性 -

非构造函数的继承

浅拷贝继承

把父对象的属性,全部拷贝给子对象,也能实现继承。

let Parent = {
	name: 'lisi',
	friends: ['wangwu','zhaoliu']
};
function extendCopy(p) {
	let c = {};
	for(let i in p) {
		c[i] = p[i];
	}
	return c;
}
let Child = extendCopy(Parent);
Child.age = 23;
Child.friends.push('zhangsan');
console.log(Child.name); // lisi
console.log(Child.friends); // ["wangwu", "zhaoliu", "zhangsan"]
console.log(Parent.friends); // ["wangwu", "zhaoliu", "zhangsan"]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

缺点:这样的拷贝有一个问题。那就是,如果父对象的属性等于数组或另一个对象,那么实际上,子对象获得的只是一个内存地址,而不是真正拷贝,因此存在父对象被篡改的可能。上例中父对象的friends属性就被修改了。所以,extendCopy只是拷贝基本类型的数据,我们把这种拷贝叫做"浅拷贝"。这是早期jQuery实现继承的方式。

深拷贝继承

所谓深拷贝,就是能够实现真正意义上的数组和对象的拷贝。它的实现并不难,只要递归调用浅拷贝就行了。jQuery库使用的就是这种继承方法。

let Parent = {
	name: 'lisi',
	friends: ['wangwu','zhaoliu']
};
function deepCopy(p, c) {
	var c = c || {};
	for(let i in p) {
		// 如果父元素的属性是对象类型并且是数组,则递归拷贝
		if(typeof p[i] === 'object') {
			c[i] = (p[i].constructor === Array) ? [] : {};
			deepCopy(p[i],c[i]);
		} else {//如果是基本类型,则直接拷贝
			c[i] = p[i];
		}
	}
	return c;
}
let Child = deepCopy(Parent);
Child.friends.push("zhangsan");
console.log(Child.friends); // ["wangwu", "zhaoliu", "zhangsan"]
console.log(Parent.friends); // ["wangwu", "zhaoliu"]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

ES6继承

ES6的继承实现方法,其内部其实也是ES5的组合继承的方式,通过call借用构造函数,在父类构造函数中调用相关属性,再用原型链的连接实现方法的继承。

class Super {}

class Sub extends Super {}

const sub = new Sub();

Sub.prototype.constructor === Sub; // ② true
sub.constructor === Sub; // ④ true
sub.__proto__ === Sub.prototype; // ⑤ true
Sub.__proto__ === Super; // ⑥ true
Sub.prototype.__proto__ === Super.prototype; // ⑦ true
1
2
3
4
5
6
7
8
9
10
11

ES6的子类和父类,子类原型和父类原型,通过__proto__连接。

ES6继承与ES5继承的区别

ES5和ES6中对于继承的实现方法

参考文档

  1. JavaScript高级程序设计
  2. Javascript 面向对象编程(一):封装
  3. Javascript面向对象编程(二):构造函数的继承
  4. Javascript面向对象编程(三):非构造函数的继承
  5. 如何继承Date对象?由一道题彻底弄懂JS继承。
  6. JavaScript深入之继承的多种方式和优缺点