原型与原型链


什么是原型

在搞明白什么是原型之前我们首先要明白我们为什么要原型,毕竟语言的设计者不会无缘无故搞出一个完全没有用的东西,它肯定是为了解决某个问题而诞生的。那么原型它解决了什么问题呢?

我们都知道在es6之前,js是没有关键字class创建类的。那时候我们要使用类就用构造函数这种形式来实现它。在C++这种原生支持类的语言中,同一个类的多个实例里的方法只有一份,也就是说不管我们实例化了多少个对象,在计算机的内存中这个类的方法在内存中只有一份。只有每个实例的属性才会在内存中产生多个副本。例如

class foo{
	private:
	string name;
	int age;
	public:
	string getName(){ return this.name; };
	int getAge(){ return this.age; };
}

int main(){
    foo a = new foo();
    foo b = new foo();
    return 0;
}

在计算机的内存中,a、b这两个实例化对象的内存分布仅有private里的属性才有独立的内存副本,而public里的方法a、b是共享同一片内存的,这样设计就节约了内存空间。

C++的类方法只占一份内存

而在es6以前没有class时候,我们通过构造函数实例化的类就会出现上图的第一种情况。每个实例的方法都有独有的内存空间。为了解决这个问题,所以就出现了原型。构造函数通过原型分配的函数是所有对象所共享的,也就实现了第二种内存分布。

如何使用原型

JavaScript 规定,每一个构造函数都有一个prototype 属性,指向prototype对象。注意这个prototype就是一个对象,这个对象的所有属性和方法,都会被构造函数所拥有。

我们可以把那些不变的方法,直接定义在 prototype 对象上,这样所有对象的实例就可以共享这些方法。

function Person(uname,age){
    this.name = uname;
    this.age = age;
    //定义类共享的方法时我们将方法定义在原型上,而不是构造函数内部
}
Person.prototype.say = function(){ console.log('哈喽'); };  //这样定义方法,多个实例对象就可以共享
var man = new Person('Asuhe',18);
var woman = new Person('Asuka',16);
man.say();  //输出 哈喽
woman.say(); //输出 哈喽

对象原型

在构造函数拥有一个原型对象叫prototype,这个原型对象由构造函数内的prototype属性指明,每个构造函数都有一个这样的原型对象。经过上述学习我们知道共享的方法是定义在构造函数的原型prototype中的,但是我们的实例化对象却能使用定义在构造函数里prototype上的方法。这是如何实现的呢?

实现这个机制的就是我们即将要讲的对象原型。在每个实例化的对象中,都会包含一个属性__proto__。这个__protto__属性就是令我们的实例化对象能够调用构造函数里prototype对象里定义的方法的原因,我们同时也称这个属性为对象原型。__proto__属性指向的是我们的构造函数的prototype原型对象,如上面的man当我们使用实例化的对象调用prototype里的方法时,我们的调用链是:man -> __proto__ -> prototype -> say()

经过这一层调用,我们的构造函数与实例化对象之间就形成了如下三角关系

image-20211020094410424

constructor构造函数

不管我们构造函数里的原型对象Prototype,还是我们实例化的对象里的对象原型__proto__,它们都包含了一个属性constructorconstructor属性的作用就是指明我们引用的构造函数是哪个。例如我们的man、woman它们都是通过构造函数Person创建出来的,所以它们的对象原型里的construtctor属性指向的就应该是Person这个构造函数。按这个指向顺序我们属性指向的关系应该是如下

直觉上对象原型里constructor的指向

但事实情况是不是这样的呢?实际上对象原型里的constructor指向是通过构造函数里的prototype.constructor间接指回构造函数的。

20211020100310.png

一般情况下,对象的方法都在构造函数的原型对象中设置。如果有多个对象的方法,我们可以给原型对象采取对象形式赋值,但是这样就会覆盖构造函数原型对象原来的内容,这样修改后的原型对象 constructor 就不再指向当前构造函数了。此时,我们可以在修改后的原型对象中,添加一个 constructor 指向原来的构造函数。

Person.prototype = {
	say:function(){ console.log('哈喽'); }
}
//如果我们采用上述赋值的形式给原型对象添加新的方法,这意味着原本的那个原型对象被我们覆盖了。此时constructor指向的并不是Person

//所以我们需要手动指定constructor的指向,让它重新指向Person
Person.prototype = {
    constructor:Person,
	say:function(){ console.log('哈喽'); }
}

原型链

当我们继续打印出prototype这个对象的时候,我们可以看到prototype里面和已经实例化的对象一样里面也有一个__proto__对象原型。那么这个__proto__又指向哪里呢。实际上这个__proto__指向的是js内置的空对象Objectprototype。这个Objerct构造函数的对象原型里的__proto__还会继续往下指向最后的null。所以当我们查找方法时就会延着这条路径链式查找下去返回最先查找到的方法,若最后没找到则返回null。

function Person(uname,age){
    this.name = uname;
    this.age = age;
}
//Person.prototype.__proto__指向的是Object的prototype,Object.prototype.__proto__指向null
console.log(Person.prototype.__proto__);
console.log(Object.prototype.__proto__);

输出结果

实例对象查找共享方法的链式结构

function Person(uname,age){
    this.name = uname;
    this.age = age;
    
}
Person.prototype.say = function(){ console.log('哈喽'); };  
var man = new Person('Asuhe',18);
var woman = new Person('Asuka',16);
man.say();  //输出 哈喽  //在Person.prototype中找到了say方法,停止查找
woman.sleep(); //输出 TypeError //Person.prototype -> Object.prototype中均未找到sleep方法,返回null

像上面这种__proto__层层查找构成的链式结构就是我们常说的原型链。

还有一点需要我们注意的是,不管是构造函数里的this 还是构造函数的prototype里的this都是指向我们实例化出来的对象


function Person(uname,age){
    this.name = uname;
    this.age = age;
}
var ptr = null;
Person.prototype.say = function(){
    console.log('哈喽');
    ptr = this;
}
var man = new Person('Asuhe',18);
console.log(man === ptr) //输出 true