继承是OO语言中的一个重要的概念,许多OO语言都支持两种继承方式–接口继承和实现继承,接口继承只继承方法签名,而实现继承则继承实际的方法。而在JavaScript中由于没有签名,所以JavaScript无法实现接口继承,所以JavaScript只能支持实现继承,而其实现继承主要是通过原型链来实现的。 –《JavaScript高级程序设计》

1.原型链继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function Parent(name) {
this.name = name
this.relation = ['grandpa', 'grandma']
}
Parent.prototype.say = function () {/*...*/}

function Child() {}
// 继承
p = new Parent('father')
Child.prototype = p

c1 = new Child()
c2 = new Child()
// 可以调用原型链上的方法
c1.say()
// 也可以获取父类实例的属性
console.log(c1.name, c2.relation)
// 直接修改父类实例属性
p.name = 'mother'
// 或者通过子类实例修改父类上的引用类型
c1.relation.push('grandson')
// 子类实例都会被影响
console.log(c1.name, c2.relation)

原型链继承的不足:

  • 修改父类实例上的属性时,所有在此原型链上的对象的属性都会受影响。
  • 当父类实例上有属性为引用类型时,所有在此原型链上的对象修改该属性时其他对象都会受影响。
  • 调用子类构造函数时,不能向父类的构造函数传递参数。
    虽然这里只是构造函数,不是真正的类 class,不过姑且使用这个叫法
    基于以上说明的原型继承的不足,所以在实际使用时,很少会单独使用原型链。

2.借用构造函数

在子类构造函数中使用 apply 或 call 调用父类构造函数。
本来,父类构造函数中的 this 将会指向父类的实例,但是在子类构造函数中 call(this) 把上下文修改为了子类实例,相当于把父类实例的属性给子类实例复制了一份。

1
2
3
4
5
6
7
8
9
function Parent(name) {
this.name = name
}
function Child(name) {
Parent.call(this, name)
}
c = new Child('child')
// c 本身就有 name 属性
console.log(c)

使用原型链继承时,如果访问一个子类实例的属性,但是子类实例并没有这个属性,那么会在子类实例的原型链上寻找,如果发现父类实例有这个属性,那么访问到的值是父类实例的,即原型链上的。同理,如果修改,也是修改的原型链上的。
而借用构造函数的方式,使得子类实例本身就有了这个属性,不需要再去原型链上找了。

  • 可以在 call() 中向父类构造函数传递参数
  • 仍然可以访问父类实例上的属性,但是这些属性已经复制给了 c 自己,不是 c.__proto__上的,所以修改时不会影响其他子类实例
  • 因为没有使用原型链,所以子类实例不能访问父类原型对象上的属性和方法
    在实际使用时,也很少会用到。

3.组合继承

就是原型链继承+借用构造函数。
既然原型链继承让子类实例可以访问父类的原型对象;而借用构造函数让子类实例可以访问父类实例,并且修改父类实例属性时不影响其他子类实例,那么把两者结合一下岂不是美滋滋?
组合继承的原理就是这样:

  • 使用借用构造函数的方法,复制一份父类实例 p 的属性到子类实例 c 上
  • 使用原型链的方法,把子类实例添加到原型链上,使得子类实例也能够访问父类原型对象上的属性和方法,当然,这些属性方法仍然是位于 c.__proto__.__proto__ 上的
    具体代码的实现:
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
46
47
48
49
50
51
52
53
54
function Father(name) {
// 父类实例属性
this.first_name = name
this.last_name = 'vue'
this.age = 40
this.address = {
country: 'china',
province: 'shanghai'
}
}
// 父类原型方法
Father.prototype.say = function () {
console.log(`I am ${this.last_name} ${this.first_name}`)
}
f = new Father('js')

// 子类
// 1. 借用构造函数
function Child1(name) {
Father.call(this, name)
// 注意,要先 call 父构造函数,再定义子类实例自己的属性
// 否则子类实例属性会被父类实例同名属性覆盖
this.age = 10
}
// 2. 原型链
// 修改原型对象
Child1.prototype = f
// 修改原型对象的构造函数
Child1.prototype.constructor = Child1

// 同样方法再建一个子类
function Child2(name) {
Father.call(this, name)
this.age = 9
}
Child2.prototype = f
Child2.prototype.constructor = Child2

c1 = new Child1('router')
c2 = new Child2('x')

print()
// 修改一下,不会对其他实例有影响
c1.address.country = 'usa'
f.last_name = 'react'
print()

function print() {
console.log(c1)
console.log(c2)
console.log(f)
// 子类实例也能访问父类原型对象上的方法
c1.say()
}

到这儿就有一个问题了–一个子类实例将会持有两份父类实例的数据。
因为使用了原型链。一份是 Father.call(this) 复制到子类实例 c 上的数据,一份是父类实例原本的数据,位于 c.__proto__ 上。
虽然冗余,不过使用效果上没有太大影响。也有处理方案,就是后面的寄生组合式继承。
这是实践中常用的继承方式。

4.原型式继承

我们先来看一个例子:

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
// 为一个对象生成子类实例的函数。其实 Object.create() 就是这样实现的
function object(obj){
// 传入的参数 obj 就相当于是父类实例
// F 就相当于子类构造函数,不过是空的,啥也没
function F(){}
// 把子类构造函数的原型对象设置为父类实例
F.prototype = obj
// 调用子类构造函数,创建一个实例并返回
return new F()
}
// 相当于父类实例
var person = {
name: "Nicholas",
friends: ["Shelby", "Court", "Van"]
}
// 子类实例
var anotherPerson = object(person)
// 为子类实例添加实例属性
anotherPerson.name = "Greg"
// 再创建一个子类实例
var yetAnotherPerson = object(person)
yetAnotherPerson.name = "Linda"
// 修改子类实例的一个引用类型属性
anotherPerson.friends.push("Rob")
yetAnotherPerson.friends.push("Barbie")
// 父类实例上的属性也变了
console.log(person.friends) // "Shelby,Court,Van,Rob,Barbie"

上面的 object() 函数其实就是 Object.create()。
MDN 提供的 Object.create() 的 polyfill 的核心代码就是上面 object() 的代码。
目前看来,感觉跟原型链继承好像是没多大差别的。尤其是 object() 函数内部的代码,完全就是原型链继承的套路。
以上面的代码为例分析一下的话:

  • 原型链继承,是先在子类构造函数中定义好了实例属性等等,然后 new 一个父类实例,把子类构造函数的原型指向该实例
  • 而原型式继承,已经有了一个父类实例,最后也同样是把子类构造函数的原型指向该实例,只不过在中间定义子类构造函数的时候,定义了一个空的函数。
    实际上,这个“只不过定义了一个空函数”正是跟原型链继承最大的区别。后面的寄生组合式继承就会体现出它的作用了。

5.寄生式继承

寄生式继承可以看成是原型式继承的增强版。
在通过原型式继承生成了子类实例后,在返回之前处理了一下子类实例,添加了一些属性或方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function createAnother(original){
// 使用前面的 object 函数,生成了一个子类实例
var clone = object(original)
// 先在子类实例上添加一点属性或方法
clone.sayHi = function(){
console.log("hi")
}
// 再返回
return clone
}
var person = {
name: "Nicholas",
friends: ["Shelby", "Court", "Van"]
}
var anotherPerson = createAnother(person)
anotherPerson.sayHi()

6.寄生组合式继承

寄生组合式继承就是寄生式继承+借用构造函数继承。
通过以上的几种继承方法,我们可以总结出究竟要继承哪些东西,可以得出两条结论:

  • 父类实例上的属性和方法
  • 父类原型对象上的属性和方法
    借用构造函数实现了第一点,那么这里寄生式继承只要实现第二点就好了。
    不对,不应该是“只要实现第二点就好了”,前面的原型链继承也可以实现第二点。寄生式继承需要比原型链继承更优秀,不然就没什么意义了。
    组合继承的结尾也提到了,它的一个缺点是会有两份父类实例的数据。那么是不是可以把这一点优化掉?
    这两份数据中,通过 Father.call(this) 复制到子类实例 c 上的这一份是真正需要的,而 c.__proto__ 上的这一份是多余的,是把子类实例放到原型链上时产生的副作用。
    也就是说,需要让子类实例位于原型链上,但是不能让父类实例的属性位于原型链上。
    可以想到两个方法:
  • 一般来说,为了把子类实例挂到原型链上,是需要一个父类实例的,如果能创建一个没有实例属性的父类实例就好了
  • 或者让子类实例绕过父类实例,直接继承父类的原型对象
    寄生组合式继承使用了第一种方法。
    对于一个构造函数 Test() 及其原型对象 Test.prorotype,使用 new Test() 和 Object.create(Test.prototype) 都可以生成继承了该原型对象 Test.prorotype 的实例。
    但是不同的是,Object.create() 生成的实例可以没有实例属性:
1
2
3
4
5
6
7
8
9
10
function Test(name) {
this.name = name
this.age = 20
}

t1 = new Test()
t2 = Object.create(Test.prototype)

console.log(t1) // Test {name: undefined, age: 20}
console.log(t2) // Test {}

构造函数只是建立原型链的途径,就算不通过构造函数也可以生成原型链。
MDN 关于 Object.create() 的介绍正是“使用现有的对象来提供新创建的对象的 __proto__”。
那么,相当于是把原型链继承中使用 new 创建父类实例改为使用 Object.create()。

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
46
47
48
function Parent(name) {
this.name = name
this.age = 40
this.relation = ['grandma', 'grandpa']
}
Parent.prototype.say = function () {
console.log(this.name)
}
function Child(name) {
Parent.call(this, name)
}

// 开始实现继承
// Object.create 创建没有实例属性的父类实例
p = Object.create(Parent.prototype)
// 修改子类构造函数原型对象
Child.prototype = p
// 这里的 p 只是个普通对象,没有 constructor 属性,手动添加一下
p.constructor = Child

// 测试一下
p1 = new Parent('father')
c1 = new Child('child 1')
c2 = new Child('child 2')
// 可以发现没有两份重复数据了
print()
// 修改父类实例,对子类实例没有影响
p1.age = 50
p1.relation.push('child 3')
// 修改父类原型对象,子类实例能够访问到新方法 speak
Parent.prototype.speak = function () {
console.log('speak')
}
// 修改子类原型对象,其他子类实例也能够访问到新方法 marry
Child.prototype.marry = function () {
console.log('married')
}
// 修改一个子类实例,对其他子类实例没有影响
c1.name = 'child 2 plus'
c1.relation.push('grandson')
print()

function print() {
console.log(p1)
console.log(Parent)
console.log(c1)
console.log(c2)
}

这是最成熟的方法,也是现在库实现的方法。

7.ES6中extends继承

关于这部分我在另一篇博客有详细的讲解,理解ES6中的继承和派生

8.混入式继承

混入式继承说白了就是把一个对象的属性复制到另一个对象上去。
比如使用 Object.assign(target, source)。这个方法将所有可枚举的属性的值从一个或多个源对象复制到目标对象,并返回目标对象。这种实现方式是浅拷贝。
而使用mixin混入的实现方式如下:

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
function Mother() {
this.a = 'mom'
}
Mother.prototype.comfort = function () {
console.log("that's ok")
}
function Father() {
this.b = 'dad'
}
Father.prototype.hit = function () {
console.log("you bastard!")
}
function Me() {
// 借用构造函数,获得了 a 和 b 两个实例属性
Mother.call(this)
Father.call(this)
}

// 创建一个没有实例属性的 Mother 的实例
m = Object.create(Mother.prototype)
// 修改 Me 的原型对象,现在 Me 位于 Mother 实例的原型链上了
Me.prototype = m
// 修改构造函数
Me.prototype.constructor = Me
// 再把 Father 原型对象上的属性方法复制到 Me 的原型对象 m 上
// 现在,虽然 Me 的实例并不在 Father 实例的原型链上
// 但是也可以访问 Father.prototype 上的属性方法
Object.assign(Me.prototype, Father.prototype)

me = new Me()
console.log(me)

实际上,考虑到父类的实例和父类的原型对象都是对象,所以在为子类实例添加父类实例的属性的时候,也可以直接使用混入。上面的代码可以修改为:

1
2
3
4
5
6
7
8
9
/**
* Father Mother Me 的构造函数
*/
// 跳过 Object.create,直接放在 Object.assign 里
m = Object.assign({}, Mother.prototype, Father.prototype)
Me.prototype = m

me = new Me()
console.log(me)

以上粗略的提了下JS的几种继承方式,想要详细学习的小伙伴请自行查找资料。
本文完