JS高程之大白话解释原型对象及相关特性

600 阅读8分钟

原型对象

1 原型对象的关系网

1.1 函数的prototype属性及原型对象

我们只要创建任意一个函数,就会自动生成prototype属性,这个属性的值是一个指针,指向的那个对象就叫做原型对象,这个原型对象默认由new Object()的方式创建。由于这是JS默认生成的,我们平时也很少用,难免不太理解。 那么这个prototype属性和指向的原型对象有什么用呢?

  • 主要作用是当我们用new操作符使用这个函数,即用构造函数创建对象实例的时候,原型对象中的所有方法和属性是通过构造函数创建的对象实例所共享的,而不是复制的副本。这样可以节省内存空间的使用。

案例说明

function Student(name){
  this.name=name;
  this.sayjob=function(){
    alert(this.job);
  }
}
Student.prototype.job="student";
Student.prototype.grade=6;
Student.prototype.sayName=function(){
  alert(this.name);
}
var student1=new Student("Jane");
var student2=new Student("Mike");
console.log(student1.grade);  //6          
console.log(student1.sayName==student2.sayName);  //true
console.log(student1.sayjob==student2.sayjob);   //false

在这个实例中,Student()函数接收name参数,创建一个name属性和sayJob()方法。通过Student函数的prototype属性为其原型对象添加了job和grade属性以及sayName方法。

  • 由于我想做一个六年级学生的信息管理,因此把job和grade属性以及sayName方法作为六年级学生的共同特点,通过原型对象让所有的实例共享即可,不要为每个学生都创建一块内存维护他的job和grade属性,节约内存。

console.log(student1.grade); //6 说明不需要在构造函数中添加grade属性,通过prototype属性的原型对象来添加,就可以为所有new Student()创建的实例对象所拥有。

console.log(student1.sayName==student2.sayName); //true 说明原型对象中的属性和方法是所有实例共享的,指向同一块内存空间。

console.log(student1.sayjob==student2.sayjob); //false 说明构造函数中添加的属性在不同的实例对象中是不同的副本,不是同一个,占用着不同的内存空间。

那么通过Student.prototype可以找到Student()函数的原型对象,通过这个原型对象应该如何找到Student()函数呢?JS为此提供了一个属性,即:原型对象默认会有一个constructor属性,这个属性会指向prototype属性所在的函数的指针,也就是指向Student()函数,那么这样函数和原型对象之间就可以互相联系上了。如下图所示:

1.2对象实例与原型对象的关系

上面说明了函数和原型对象的两者的联系,那么当用这个函数创建实例对象时候,这个实例与原型对象又有什么联系呢?真是复杂的三角关系呀。上面的案例代码中我们可以看到通过调用student1.grade我们可以通过实例student1访问原型对象的属性grade,那么实例student1必然有与原型对象联系起来的桥梁。

两者关系 事实上,在调用构造函数创建实例的时候,实例的内部会有一个指针指向构造函数的原型对象。这个指针在叫做[[Prototype]],在Firefox,Safari和Chrome中,可以通过__proto__访问这个指针,在其他浏览器中没有提供访问这个指针的入口,是对外不可见的。也就是说实例通过[[Prototype]]指针与原型对象联系起来,与函数Student()并没有类似的指针索引关系。

两者关系判断与[[Prototype]]访问 由于有的浏览器不支持[[Prototype]]的直接属性访问,JS提供了函数isPrototypeOf()来确定实例和原型对象之间是否存在对应关系,ES5提供了Object.getPrototypeOf()来访问[[Prototype]]的值,如下所示:

alert(Student.prototype.isPrototypeOf(student1));  //true
alert(Object.getPrototypeOf(student1)==Student.prototype);    //true

小结 以上我们循序渐进地分析了函数,原型对象以及实例三者之间的联系,如下图所示:

2 原型对象使用的相关特点

2.1 搜索实例属性方法的路径——原型链

当代码读取某个实例对象的属性时候,会执行一次搜索,先从实例对象的本身属性里搜索,如果没有,再从实例的[[Prototype]]属性指针所指向的原型对象中查找是否与同名的属性,如果有则返回。如果没有找到,由于原型对象本身也是一个实例,默认是由new Object()创建的(本文第一段有说明),因此原型对象中也有一个[[Prototype]]属性,会继续搜索这个原型对象的[[Prototype]]所指向的原型对象(俄罗斯套娃哈哈),因此搜索会不断往上,直到一个对象的原型对象为 null。根据定义,null 没有原型,并作为这个搜索的最后一个环节而停止搜索,其实这就是原型链。 大致搜索路径如下图的红色箭头所示:

2.2 实例属性和原型属性

通过实例由原型链可以访问到原型中的属性,但却不能对它进行更改。如果在实例中添加一个原型对象的同名属性,则通过实例进行该属性访问时候会屏蔽原型对象中的属性,因为这是根据原型链的顺序依次搜索的。如下所示

student1.grade=5;
console.log(student1.grade);  //5
console.log(student2.grade);  //6

我们通过实例student1.grade是无法修改原型对象中的同名grade属性,只能给实例本身添加grade属性,因此student2访问的grade属性值仍然是原型对象中的6,student1实例访问的是实例本身通过student1.grade=5;语句给自己添加的属性值为5。

此时我们如果想要恢复student1实例对原型属性的访问,需要通过delete student1.grade;删除实例本身的grade属性。

如何判断一个属性是实例属性还是原型属性呢? 可以通过hasOwnProperty(),当是实例属性时候才会返回true,如下说明是原型属性:

console.log(student2.hasOwnProperty("grade"));   //false      

2.3 属性的搜索与遍历

属性存在性判断 单独使用in操作符,如果实例属性或原型属性中有则返回true,如下:

console.log("grade" in student2);   //true     

如果配合hasOwnProperty()可以确定一个属性是否存在且是在实例属性中还是原型属性中。

属性的遍历 用for-in语句:返回的是能通过对象实例访问的,可枚举的属性(即[[Enumerable]]标记为true),但是IE早期版本存在一个bug是如果一个属性同时存在于实例和原型对象中,如果原型对象中是不可枚举的,实例中可枚举也无法通过for-in返回。

实例属性的遍历Object.keys()方法,返回可枚举的属性的字符串数组形式 用Object.getOwnPropertyNames()方法返回字符串数组,无论它是否可以枚举,如下所示:其中constructor是不可枚举的。

console.log(Object.keys(Student.prototype));  /// [, "job", "grade", "sayName"]
console.log(Object.getOwnPropertyNames(Student.prototype));  // ["constructor", "job", "grade", "sayName"]

2.4 对象字面量重写原型对象

由于原型对象也是一个实例对象,因此可以用对象字面量的方式来创建它,如下所示:

function Student(name){
  this.name=name;
  this.sayjob=function(){
    alert(this.job);
  }
}
Student.prototype={
    constructor: Student,
    job:"student",
    grade:6,
    sayName:function(){
        alert(this.name);
    }
};

var student1=new Student("Jane");
var student2=new Student("Mike");
console.log(student1.grade);  //6

需要注意的是原型对象中的constructor属性,由于前面的方式会默认生成这个属性并指向Student,如果用对象字面量的方式则是用新的对象完全覆盖默认的原型对象,因此需要自己手动添加constructor属性。但是我们手动添加的属性默认都是可枚举的,而原配的constructor属性是不可枚举的,因此需要用Object.defineProperty()函数手动设置enumerable属性为false。具体的Object.defineProperty()函数使用设置可以参考enumerable属性设置

2.5 原型的动态性

我们对原型对象所做的修改都能立刻在实例上反映出来,不论实例是在修改原型对象之前还是修改原型对象之后创建的。因为我们对于属性的查找的基于原型链的搜索,是通过指针链接起来的,如下尽管先创建了student2实例后修改原型对象,student2还是可以调用后添加的sayHi方法。

var student2=new Student("Mike");
Student.prototype.sayHi=function(){
  alert("Hi");
};
student2.sayHi();  //"Hi"

需要注意的是如果创建实例后用对象字面量的方式重写整个原型对象,则会切断前面已经创建的实例对象和修改后的原型对象的联系。如下面这个新的案例:

function Person(){

}
var friend=new Person();
Person.prototype={
  constructor:Person,
  name:"Nicholas",
  age:29,
  job:"Software Engineer",
  sayName:function(){
    alert(this.name);
  }
};
friend.sayName();   //error

因为在friend实例创建后,我们是通过Person.prototype的方式重写原型对象,此时Person的prototype属性必然会指向新的原型对象。然后这个过程中我们并没有通过任何的接口操作friend的[[Prototype]]属性(用Object.getPrototypeOf()或者__proto__属性操作),因此friend的[[Prototype]]属性仍然指向最开始的那个原型对象,并没有得到更新。如果我们通过Person.prototype.age=29;的方式修改原型对象,我们只是在原来的对象上添加新的属性,并没有进行对象覆盖的操作,所以不会出现这种联系切断的情况。