原型与原型链


1.前置知识

在聊原型和原型链之前,我们需要知道几个概念:构造函数和普通函数、函数对象和实例对象。


(1) 构造函数和普通函数

1
2
3
4
5
6
7
8
9
function Test(hi){
this.hi = hi
console.log(this.hi);
}
function test(){
console.log("这是普通函数");
}
var t = new Test("这是构造函数");
test();

在函数声明的时候,无法判断一个函数是否为构造函数。只有使用new操作符创建对象时,调用的函数才叫做构造函数。

两者区别:

  1. 作用不同

    普通函数的作用自然是执行函数体内的代码以实现某种功能。构造函数的作用是用来创建对象。

  2. 调用方式不同

    普通函数直接调用:函数名()

    构造函数是为了创建对象,所以需要使用new关键字来调用:new 函数名()

  3. 书写习惯不同

    为了区别普通函数和构造函数,构造函数的函数名一般大写开头。

  4. this指向不同

    普通函数中的this指向window对象,构造函数中的this则是指向它创建的对象。

  5. 写法不同

    构造函数中一般不写return

所以上述代码中test为普通函数,Test为构造函数。


(2) 函数对象和实例对象

以下面的代码为例:

1
2
3
4
function Student(name){
this.name = name;
}
var st1 = new Student('小明');

在JavaScript中,函数也是对象,所以从这个角度讲,上述代码中Student构造函数也称为函数对象,而通过new操作符创建的对象,称为实例对象,上述代码中st1就是通过Student构造函数创建的实例对象。

不仅仅是构造函数,任何函数从对象的角度讲或者当做对象去使用时,都可以称为函数对象。

这里所说的函数对象不是JS内置的Function对象。


2.原型

什么是原型?我们还是通过上面的代码来讲解:

1
2
3
4
function Student(name){
this.name = name;
}
var st1 = new Student('小明');

假设现在我们需要给st1添加一个speak方法,可以这么做:

1
2
3
4
5
6
7
8
function Student(name){
this.name = name;
}
var st1 = new Student('小明');
st1.speak = function(){
console.log("my name is", this.name);
}
st1.speak(); // my name is 小明

但假如我要给每一个Student创建出来的实例对象都添加上这个speak方法,可以这么做:

1
2
3
4
5
6
7
8
9
10
function Student(name){
this.name = name;
this.speak = function(){
console.log("my name is", this.name);
}
}
var st1 = new Student('小明');
var st2 = new Student('小红');
st1.speak(); // my name is 小明
st2.speak(); // my name is 小红

这种方式每创建一个实例对象,就会创建一个新的speak方法,也就是说每个实例对象的speak方法都是唯一的。

原型-1

但这种方式有个问题:如果我们创建出来的实例对象越来越多,在内存中占用的空间是不是也越来越多。

我们仔细想想,这个speak方法需要每个实例对象都唯一吗?能不能共用一个?答案是可以共用,因为每个实例对象的speak方法做的事情都是一样的。

那么我们要怎么修改呢?可以这么做:

1
2
3
4
5
6
7
8
9
10
11
12
function Student(name){
this.name = name;
this.speak = _speak;
}
// 将方法提取到全局作用域下
function _speak(){
console.log("my name is", this.name);
}
var st1 = new Student('小明');
var st2 = new Student('小红');
st1.speak(); // my name is 小明
st2.speak(); // my name is 小红

这时候的内存空间:

原型-2

这种方式看上去很不错,但是仍然会带来一些问题:

  1. _speak是在全局作用域下声明的,可能会污染全局作用域(变量冲突)。
  2. 随着全局作用域下的函数声明越来越多,全局作用域会变得越来越臃肿。

因此,JS就提出了原型的概念。每一个函数对象身上都有一个prototype属性,该属性指向一个对象。

1
2
3
4
function Student(name){
this.name = name;
}
console.log(Student.prototype); // Object

这个对象叫做原型对象,每个函数对象都有自己的原型对象。

不管是构造函数还是普通函数,都有prototype属性,只不过在普通函数上这一个属性没有什么作用。所以在讲原型时,我们比较强调构造函数,下文中的函数对象也都指构造函数。

另外,创建实例对象时,实例对象身上会有一个属性__proto__,它指向创建该实例对象的函数对象的原型对象。同一个函数对象创建的每一个实例对象,它们的__proto__属性都指向同一个原型对象。

1
2
3
4
5
function Student(name){
this.name = name;
}
var st1 = new Student("小明");
console.log(Student.prototype === st1.__proto__); // true

原型-3

这就意味着,如果我们在函数对象的原型对象中添加属性和方法,该函数对象创建的实例对象也可以访问到。JS就是这么做的,JS中对于对象属性和方法的访问顺序是从对象本身到对象的原型,如果对象本身中就有要找的属性或方法,直接使用对象本身中的属性或方法,否则从对象的原型中找。比如:

1
2
3
4
5
6
7
8
function Student(name){
this.name = name;
}
Student.prototype.school = "xx大学";
var st1 = new Student("小明");
console.log(st1.school); // xx大学
st1.school = "yy大学";
console.log(st1.school); // yy大学

原型-4

原型-5

那么为了实现我们上面所说的对于speak方法的操作,可以这么做:

1
2
3
4
5
6
7
8
9
10
function Student(name){
this.name = name;
}
Student.prototype.speak = function(){
console.log("my name is", this.name);
}
var st1 = new Student('小明');
var st2 = new Student('小红');
st1.speak(); // my name is 小明
st2.speak(); // my name is 小红

原型-6

再补充一点,原型对象在函数声明时一同创建,然后挂载到函数对象的prototype属性上,另外原型对象上有一个constructor属性,指向创建它的函数对象,这样两者的关系就紧密结合起来了。

完整关系图:

原型-7

总结:

  1. 函数对象上有一个prototype属性,该属性称为显示原型。它指向一个对象,该对象称为原型对象。
  2. 函数对象创建的每个实例对象都有一个 __proto__属性 ,该属性称为隐式原型。它指向创建该实例对象的函数对象的原型对象。
  3. 原型对象上有一个constructor属性,它指向创建该原型对象的函数对象。

所以原型就是一个对象,它的作用是所有实例对象共享属性和方法。

一般是共享方法,因为属性一般都是实例对象独有的,不同实例对象他们的属性不同,比如上面的st1st2,他们的name属性各不相同,不应该把name放入Student原型对象中。

有些人可能会问:为什么一定要通过函数对象的prototype往原型对象上添加方法和属性,能不能通过实例对象的__proto__去添加呢?答案是不行的,实例对象的__proto__属性的意义在于为对象的查找机制提供一个方向或者说一条线路,但它是一个非标准属性,因此实际开发中不可以使用这个属性,这也是为什么称它为隐式原型的原因。


3.原型链

理解了原型之后,原型链理解起来就比较容易。我们还是从上面的例子入手:

1
2
3
4
5
6
7
8
9
function Student(name){
this.name = name;
}
Student.prototype.speak = function(){
console.log("my name is", this.name);
}
var st1 = new Student('小明');
st1.speak(); // my name is 小明
console.log(st1.toString()); // [object Object]

我们会发现上述第九行代码,会输出[object Object],也就是说st1能够执行toString()方法。但是st1这个实例对象本身和它__proto__所指向的原型对象上都没有这个方法。那这个方法是哪来的呢?

我们知道,Object对象是所有对象的“祖先”,既然原型对象也是一个对象,那它也不例外,所以其实原型对象是Object对象的实例对象。既然是实例对象,那它身上就会有一个__proto__属性,指向Object对象的原型对象。

原型链-1

我们简化一下这张图:

原型链-2

接着我们来看下Object对象的原型对象上有没有toString()方法:

原型链-3

果然有,这说明了什么?说明JS对于对象属性和方法的查找规则是这样的:

先从对象本身去找,如果找不到,就去该对象的原型对象上去找,如果还是没有找到,就去原型对象的原型对象上去找……但原型对象不可能永无止境,Object对象的原型对象__proto__属性值为null,也就是说查找到Object对象的原型对象就结束了,如果还是没有找到,则返回undefined

原型链-4

像图上这条被__proto__链接起来的链式关系,就叫原型链。它直接反应了JS对于对象属性和方法的查找顺序。


4.补充

理解完了原型和原型链,我们再想一想,所有的函数都可以通过new Function()的方式创建,那么也就是说所有的函数都是Function对象的实例对象。既然是实例对象,那它身上就会有一个__proto__属性,并且这一属性指向Function对象的原型对象。

Function对象-1

那么Function对象是谁的实例对象呢?

我们刚说所有函数都是Function对象的实例对象,而Function对象也是构造函数,那么不就成了Function对象创建了Function对象?其实不必太过纠结这一点,因为Function对象是JS的内置对象,在脚本还没开始执行,就已经创建好了。所以Function.__proto__ === Function.prototype,记住这一个特殊情况就好了。

Function对象-2

Object对象和Function对象都是函数对象,也就是构造函数。只不过它们两个都是JS的内置对象,所以没有特别标注函数对象。

这里有一张非常流行的关于原型的图,就是我们刚刚讲到的所有内容:

原型和原型链

5.继承

继承是面向对象编程的一个概念。继承可以使子类具有父类的属性和方法,同时可以在子类中重新定义或追加属性和方法。继承是类与类之间的关系。

但是JavaScript并没有类的概念,只有对象。那怎么会有继承呢?因为JavaScript是非常灵活的,我们可以通过构造函数和原型来模拟类的继承。

如果熟悉面向对象编程的语言,我们会发现JS中的构造函数和类有点相似。在ES6中,JS也提出了类的概念,但ES6中的类其实是一个语法糖,它的本质还是构造函数,感兴趣的可以自行了解。


(1) 通过构造函数继承属性

假如我们现在有一个Father构造函数,并且希望有另一个构造函数继承Father构造函数,我们可以这么做:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 父构造函数
function Father(name,age){
// this 指向父构造函数的实例对象
this.name = name;
this.age = age;
}
// 子构造函数
function Son(name,age){
// this 指向子构造函数的实例对象
// 要想继承父构造函数的属性,必须调用父构造函数,并且将父构造函数中的this改为当前构造函数中的this
Father.call(this, name, age);
}
var son = new Son('小明', 18);

但是这样还不够,因为这样Son构造函数只继承了Father构造函数的属性,没有继承Father构造函数的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 父构造函数
function Father(name,age){
this.name = name;
this.age = age;
}
Father.prototype.money = function(){
console.log(1000);
}
// 子构造函数
function Son(name,age){
Father.call(this, name, age);
}
var son = new Son('小明', 18);
son.money(); // 报错,son无法调用money方法,或者说根本找不到money方法

继承-1

接下去,我们就需要让Son构造函数继承Father构造函数的方法。


(2) 通过原型对象继承方法

由上面的图像结合原型链的知识,我们可以发现,如果让Son原型对象链接到Father原型对象,那我们Son构造函数创建的实例对象,是不是就可以使用Father原型对象上的方法了?

继承-2

原型对象的链接是依靠__proto__属性去操作的,所以最简单的方式:

1
Son.prototype.__proto__ = Father.prototype;

但是我们说过__proto__属性不能直接使用。那有没有什么办法同样能实现上述代码的效果?我们可以这么做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 父构造函数
function Father(name,age){
this.name = name;
this.age = age;
}
Father.prototype.money = function(){
console.log(1000);
}
// 子构造函数
function Son(name,age){
Father.call(this, name, age);
}
// 将Son构造函数的prototype指向Father构造函数创建的实例对象
// 由于Father构造函数创建的实例对象中有__proto__属性,并且指向Father构造函数的原型对象
// 这样就达到了我们预先的目的
Son.prototype = new Father();
var son = new Son('小明', 18);
son.money(); // 1000

继承-3

这样Father构造函数创建的实例对象就变成了Son构造函数的原型对象,Son构造函数new出来的实例对象,也指向这一个Father构造函数创建的实例对象。从而达到我们预先的目的。

继承-4

除此之外还差一步,我们原先说过原型对象中有一个constructor属性指向构造函数,我们修改了构造函数的原型对象,那就需要将新的原型对象中的constructor属性指向构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 父构造函数
function Father(name,age){
this.name = name;
this.age = age;
}
Father.prototype.money = function(){
console.log(1000);
}
// 子构造函数
function Son(name,age){
Father.call(this, name, age);
}
Son.prototype = new Father();
// 修改constructor
Son.prototype.constructor = Son;
var son = new Son('小明', 18);
son.money(); // 1000

继承-5

上述这种继承方式叫做组合继承



参考:

https://juejin.cn/post/6996583771952644110

https://www.bilibili.com/video/BV1Kt411w7MP