萝三画室

深入理解JS-part6-原型和继承

本文是深入理解JS系列的part6,我们要开始研究的是JS的一个区别于其他很多语言的特点:原型、原型链和继承。虽然ES6提供了更简洁优雅的方法来实现继承,但是这不过是语法糖,实现继承的原理是没有变化的。并且我认为原型和继承属于JS中比较难懂的点,也是被广泛使用的点。实际上我们无时不刻的用到了JS的原型和继承,只是没有发觉。深入理解原型和继承的原理,我们才会更好的理解和运用它们。So, let’s start!

参考书籍:JavaScript高级程序设计-第三版, 《你不知道的JavaScript》

原型

当我们定义一个函数之后发生了什么

1
2
3
function Person(age){
this.age = age;
}

当我们像如上代码一样定义了一个函数Person之后:

  1. 默认为Person创建一个属性prototype,并默认指向一个新的对象。
  2. 在这个默认的新对象中,有一个默认属性constructor,它指回函数自身,即Person。

这里需要注意,Person.prototype指向的对象被称为Person的原型对象。当前Person的原型对象是默认创建的这个对象,然而我们可以改变Person.prototype的指向,这时Person的原型对象就是另外的对象了(这里先只知道这个点,后面我们会深入讲到)。

用代码表现就是:

1
2
typeof (Person.prototype) === "object";//true
Person.prototype.constructor === Person;//true

用图表示就是:
part6_1.png

当我们用new操作符创建变量后发生了什么

我们知道,new操作符创建变量就是一个函数调用的过程。

1
2
3
4
5
6
7
8
9
function Person(age){
this.age = age;
}
var entity1 = new Person(18);
var entity2 = new Person(24);
entity1.age;//18
entity2.age;//24

当我们像如上代码一样,通过new Person(18)创建了变量entity1之后:

  1. 创建一个新的对象
  2. 这个新的对象被执行原型连接,即为它默认创建一个隐藏属性[[prototype]],这个属性指向Person的原型对象,可以访问Person的原型对象的属性和方法。
  3. 这个新的对象会绑定到Person的this,即调用了Person函数
  4. 如果函数没有返回其他对象,那么new表达式中的函数调用自动返回这个新对象给entity1。

关于第2点需要注意的是,对象的[[prototype]]属性无法通过.操作符直接访问,大多数浏览器实现了通过

1
2
3
```
entity1.prototype;//undefined
entity1.__proto__;//{constructor:f Person(age)}

理解了上面的4步我们就可以知道,在new一个实例对象entity1之后,entity1通过函数调用直接复制 了一份Person中定义的属性和方法保存到自身,也可以通过原型连接间接引用 了Person的原型对象中定义的属性和方法。直接复制的属性就是实例自有的属性,就是实例属性

用图表示就是:
part6_2.png

为原型对象增加方法

原型对象也是对象,所以我们可以为它增加属性和方法。
当我们为Person增加sayAge方法时,如果直接在Person中增加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Person(age){
this.age = age;
this.sayAge = function(){
console.log(this.age)
};
}
var entity1 = new Person(18);
var entity2 = new Person(24);
entity1.age;//18
entity2.age;//24
entity1.sayAge();//18
entity2.sayAge();//24

这会导致使用new创建的每个Person的实例对象都存有一份sayAge方法的副本,像下面这样:
part6_3.png
这会产生不必要的内存浪费。事实上sayAge方法并不是特例化的,它完全可以被所有实例共用。因此,我们可以将sayAge这个方法直接定义在Person的原型对象中,通过引用 的方式去访问,而不必通过复制 的方式重复创建副本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Person(age){
this.age = age;
}
Person.prototype.sayAge = function (){
console.log(this.age)
};
var entity1 = new Person(18);
var entity2 = new Person(24);
entity1.age;//18
entity2.age;//24
entity1.sayAge();//18
entity2.sayAge();//24

用图表示就是:
part6_4.png
因此,我们应该在函数中定义特例化的属性和方法,在函数的原型对象中定义非特例化的属性和方法。

重写原型对象

本文开始的时候我们说过,Person的原型对象是Person.prototype指向的对象。Person在声明时,Person.prototype默认指向一个对象,且默认包含一个constructor属性指回Person。前一小节中,我们为Person的原型对象增加的sayAge方法就是增加到这个默认原型对象中的。那么我们还有一种选择,就是改变Person.prototype的指向,使它指向另一个对象,也就是重写原型对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Person(age){
this.age = age;
}
Person.prototype = {
sayAge: function (){
console.log(this.age);
}
};
var entity1 = new Person(18);
var entity2 = new Person(24);
entity1.age;//18
entity2.age;//24
entity1.sayAge();//18
entity2.sayAge();//24

看起来与前一节的结果一样!——然而并不是。

1
Person.prototype.constructor === Person;//false

我们发现Person.prototype.constructor并非指向Person。这是因为,只有Person创建时的默认原型对象才会将constructor自动指回Person,当我们改变Person.prototype的指向,使它指向另一个对象时,这个新的对象并不会自动指向Person,它默认指向的是Object(后面会详细讲)。
part6_5.png
因此,当我们重写Person的原型对象时,需要手动为原型对象改变constructor的指向。

1
2
3
4
5
6
Person.prototype = {
constructor: Person,
sayAge: function (){
console.log(this.age);
}
};

part6_6.png
然而这种方式会将constructor的属性描述符enumerable变为true(默认应为false),导致constructor属性可被枚举。

1
2
Object.getOwnPropertyDescriptor(Person.prototype,"constructor");
//{writable: true, enumerable: true, configurable: true, value: ƒ}

我们可以使用Object.defineProperty的方法改变constructor的指向并恢复不可枚举性。重写原型对象且无副作用的完整代码如下:

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
function Person(age){
this.age = age;
}
Person.prototype = {
sayAge: function (){
console.log(this.age);
}
};
Object.defineProperty(Person.prototype, "constructor", {
writable: true,
enumerable: false,
configurable: true,
value: Person
} );
var entity1 = new Person(18);
var entity2 = new Person(24);
entity1.age;//18
entity2.age;//24
entity1.sayAge();//18
entity2.sayAge();//24
Person.prototype.constructor === Person;//true

关于实例与constructor属性的一点澄清

看下面的例子:

1
2
var entity1 = new Person(18);
entity1.constructor === Person;//true

本栗的现象会给人造成一种假象:实例entity1由Person初始化而来,因此它的constructor属性指向Person。

实际上是:实例本身并不具有constructor属性,它是通过[[prototype]]连接到了Person的原型对象,访问的是原型对象的constructor属性。

通过前面的图我们也可以看出来,Person的实例连接的是Person的原型对象,跟Person本身无关。

原型链

上节原型中,我们展示了函数Person和它的原型对象的关系,这节我们顺着Person的原型对象向上讲。我们注意到,Person的原型对象是Object的一个实例!那我们猜想一下,作为Object的一个实例,它是否像entity1一样,也有一个隐藏的[[prototype]]属性指向Object的原型对象呢?下面我们试试看。

1
2
Person.prototype.__proto__;//一个包含toString、valueOf等方法的对象
Person.prototype.__proto__.constructor === Object;//true

事实证明我们猜对了:Person的原型对象的[[prototype]]属性指向指向一个对象,这个对象的constructor属性指向Object。
那么完整的表达Person与它的原型对象就是:
part6_7.png
乍一看这图很乱!来我们照着这张图,从Person的一个实例entity1开始顺着向上理一理。

  1. entity1由Person执行了new操作初始化,得到了实例属性age
  2. entity1通过[[prototype]]连接到Person的原型对象,使entity1可以访问Person的原型对象中的方法sayAge
  3. Person的原型对象通过[[prototype]]连接到Object的原型对象,使Person的原型对象可以访问Object的原型对象的方法toString、hasOwnProperty等
  4. entity通过Person的原型对象可以访问Object的原型对象的方法toString、hasOwnProperty等

也就是说,Person的一个实例entity1顺着[[prototype]]属性向上,可以访问到Person的原型对象、再进一步可以访问到Object的原型对象中定义的方法。这就是我们没有定义toString、hasOwnProperty等方法,却能使用它们的原因。

entity1->Pernson的原型对象->Object的原型对象就是一条原型链。

Object是原型链的顶端,所有对象最终都会被连接到Object的原型对象上。

所以,我们对Array等类型的变量执行toString方法时,实际上都是顺着原型链访问到定义在Object的原型对象上的toString方法。这就是为什么我会在本文的最开始说原型和原型链被广泛使用但是经常不被察觉了。

继承

通过前面原型链的例子,实际上我们已经了解了JS的继承模式中,最基本的一种:原型式继承

原型链式继承

在前栗中,我们将Person的原型对象指定为一个新对象,这个新对象是Object的一个实例。因此,Person的实例entity1可以访问Person的原型对象中的方法,还可以通过Person的原型对象(Object的实例)访问到Object的原型对象中的方法。这就是原型式继承。

也就是说,原型链式继承的思路是将函数A的原型对象指定为函数B的实例,这样A的实例就与B的原型对象建立起了关联关系。

可能看概念不形象,我们再来举个栗子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function B(){
this.B = "I'm B";
}
B.prototype.sayB = function(){
console.log(this.B);
}
function A(){
this.A = "I'm A";
}
A.prototype = new B();
A.prototype.sayA = function(){
console.log(this.A);
}
var entity = new A();
entity.sayA();//I'm A
entity.sayB();//I'm B

我们来分析一下整个流程:

  1. 声明了函数B,并在其原型对象上定义了一个方法sayB
  2. 声明了函数A
  3. 重写A的原型对象为B的一个实例,并在A的原型对象上定义了一个方法sayA
  4. 创建一个变量,起值为A的一个实例

那么对应以上每步,我们又可以来画一张图:
part6_8.png
最终,A的实例entity具有一个实例属性A(黄色),可以访问三个原型链中的属性B、sayA、sayB(绿色)。这就实现了A继承B!

然而,这里还存在几个问题:

  1. 此时A的原型对象丢失了属性constructor,A.constructor访问的是B的原型对象中的constructor属性,它指向B。如果需要,可以重新定义A的原型对象的constructor属性。
  2. 由于A的原型对象是通过执行函数B得到的B的实例,因此它会复制函数B中的B属性作为自身的实例属性。我们知道,实例属性通常都是引用类型,一般都代表实例的独立属性,但由于B属性位于A的原型对象中,因此所有A的实例是共享B属性的,A的实例的B属性不再独立。如果有一个A的实例修改了B属性,那么所有A的实例的B属性都会变化。怎样能将B属性变为A的实例对象的实例属性呢?

对于第一个问题,我们在前一节说过,可以自己手动定义A的原型对象的constructor属性,使其指回A;对于第二个问题,我们可以用接下来要讲的继承模式类似解决。

组合式继承

组合式继承是JS中最常用的继承模式。我们知道,new B();的过程其实就是执行函数B并构建原型链的过程。那么我们可以直接在A中执行B函数!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function B(){
this.B = "I'm B";
}
B.prototype.sayB = function(){
console.log(this.B);
}
function A(){
B.call(this);//借用构造函数
this.A = "I'm A";
}
A.prototype = new B();//原型链式继承
A.prototype.sayA = function(){
console.log(this.A);
}
var entity = new A();
entity.sayA();//I'm A
entity.sayB();//I'm B

我们在原型链式继承的基础上,在函数A内部手动调用了函数B,这样就将B的实例属性复制到A中。这种原型链式+借用构造函数式的继承模式就是组合式继承。对于A的实例entity而言,它具有两个实例属性A和B,用图表示:
part6_9.png
然而,虽然解决了原型链式继承的问题2,这种方法依然存在问题:

  1. 同原型链式问题1
  2. 其实在整个原型链中有两个属性B,一个作为A的实例属性,一个位于A的原型对象中,只不过在访问entity.B时,是从entity的实例属性开始搜索的,找到之后就不会去查找原型链,属于实例属性B屏蔽了原型链属性B。
  3. 整个过程中,实际上执行了两次函数B:一次是函数A内部手动执行,一次是重写A的原型对象new B()。这造成了冗余和浪费。

第一个问题我们已经知道解决办法了,后两个问题,就促成了接下来的继承模式。

原型式继承

我们回想一下,以上栗为例,A的实例对象entity通过原型链连接到A的原型对象。也就是说,原型链连接的是对象和对象,与函数无关。而组合式继承模式中,又存在多次调用构造函数以及属性冗余问题。那么我们是否能够绕开函数,而仅仅对两个对象之间建立关联关系呢?看下面的函数:

1
2
3
4
5
function object(o){
function F(){}
F.prototype = o;
return new F();
}

函数object拥有一个参数O,它是一个对象。在object函数内部,我们首先定义了一个空函数F,并将其原型指向参数o,最后返回了F的一个实例对象。用图表示:

part6_10.png

我们发现F的实例对象与对象o之间建立了连接关系。也就是说,执行函数object之后返回的对象的原型对象是对象o。
我们看一个更直观的栗子

1
2
3
4
5
6
7
8
9
10
11
12
var p = { friends:["Ammy", "Belly", "Cindy"] };
var entity1 = object(p);
entity1.friends;//["Ammy", "Belly", "Cindy"]
entity1.friends.push("Bob");
var entity2 = object(p);
entity2.friends;//["Ammy", "Belly", "Cindy", "Bob"]
p.friends;//["Ammy", "Belly", "Cindy", "Bob"]

这里,entity1和entity2没有通过调用构造函数来初始化,而是直接将原型对象指向对象p。原型式继承(var entity1 = object(p))可以翻译为:entity1的原型对象是p。这种实现继承的方法被标准化为ES5中的Object.creat()0方法。
part6_11.png
注意,对象p作为entity1和entity2的共享的原型对象,它的friend属性会出现牵一发而动全身的现象。无论更改entity1、entity2还是p中哪个对象的friends属性,最终都会导致其它对象的friends属性变化。

寄生式继承

寄生式继承是在原型式继承的基础上,增加对象的实例属性。

1
2
3
4
5
6
7
function creat(o){
var clone = object(o);
clone.sayHi = function (){
consoloe.log('hi');
};
return clone;
}

这里的方法sayHi将会成为返回的每个对象的实例属性

寄生组合式继承

寄生组合式继承,就是通过构造函数继承到实例属性,通过原型链的混合形式继承方法。

1
2
3
4
5
6
7
8
9
10
11
//寄生式
function inheritPrototype(A,B){
var prototype = object(B.prototype);
prototype.constructor = A;
A.prototype = prototype;
}
组合式
function A(){
B.call(this)
{......}
}

其中,根据寄生式的定义翻译一下function inheritPrototype执行后的效果:函数A的原型对象,是一个指向函数B的原型对象的对象。
part6_12.png
下面我们看一个栗子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function B(name){
this.name = name;
}
B.prototype.sayName = function (){
console.log(this.name);
};
function A(name,age){
B.call(this,name);
this.age = age;
}
inheritPrototype(A,B);
A.prototype.sayAge = function(){
console.log(this.age);
};
var entity = new A();

  1. 首先定义了函数B,其中包含一个实例属性Name,并在B的原型对象上增加了一个方法sayName
  2. 定义了函数A,并在函数A中调用函数B,使A复制了B的实例属性Name作为自己的实例属性,然后又新定义了一个实例属性age
  3. 将一个指向函数B的原型对象的对象,作为函数A的原型对象,并在A的原型对象上增加一个方法sayAge
  4. 使用构造函数,创建函数A的实例entity

part6_13.png

图中黄色填充的是entity的实例属性,绿色填充的是原型链上的方法,这次看起来,公共方法和特有属性终于处于它们应该在的位置上了。于是到了这里,我们终于找到了最顺眼的一个方法。呼~(~ o ~)~zZ

作者的碎碎念

写到这里,终于写完了JS的原型链和继承的原理。这大概是最耗费精力的一篇博,为了彻底理解它并阐述清楚,我大概花了不到2周的时间,参考了很多资料,画了很多图,力求把它讲的简单直观。即便如此,本文读起来可能还是很费劲,稍不留神就乱了套。那么作者我的建议是,不要急于看完,理解了当前的内容再往下看,最好是能对示例代码和说明,自己画画原型链的关系图。理清楚图,可以说差不多完全理解了原型链和继承了。

如果觉得本文讲的不清楚,可以去看《javascript高级程序设计 第三版》第六章。私认为对于原型链和继承,高程这本书讲的最为透彻,我也是反复的研究了很多遍。

最后,带着贼好的心情打出:

——本节完——