# 什么是原型

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

我们都知道在 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