JavaScript中的对象、constructor属性、prototype属性对比分析

目录

前言

  首先想说的是,这篇文章的题目准确怎么定我就考虑了不少时间。想过“JavaScript中的对象及其constructor属性和prototype属性”,想过“JavaScript中的对象、构造函数对象(constructor)和原型对象(prototype)”等等,但总觉得不够清楚。说实话现有的这个题目也不一定是很好的,只是相比较之下,选择一个“三者相对独立”的说法,这样就不会有先入为主的感觉。因为三者的关系并不是简单的“constructor和prototype是对象的两个属性”,并且“到底把constructor看作属性还是构造函数对象”,“到底把prototype看作属性还是原型对象”,这些问题并没有一个固定答案,它们有着互相渗透的关系。更多地我们应该关注这些名词到底想要描述怎样的问题。本文我就结合《JavaScript权威指南》和《JavaScript高级程序设计》两书进行总结和分析,可能有一些还不正确的理解,需要进一步探究。
  另外,要说明的是,不可能只是干巴巴地描述这3个概念,因为它们本质上涉及的是JavaScript面向对象编程中的“类与对象”、“类的继承”等问题,因此本文是从这些方面进行组织的,constructor属性、prototype属性的分析包含其中。


JavaScript对象简述 

定义

  在之前关于null和undefined的讨论中提到过,JavaScript中的数据类型分为两类:原始类型和对象类型。本篇主要要总结的就是对象类型。对象是JavaScript中十分重要且常用的概念。
  对象是一种复合值:是多个属性的集合,每个属性都以键/值对的形式体现。其中对于属性值为函数的属性,一般将其称作方法。函数、数组等数据组织形式都是对象的一种特殊表现形式。

属性类型

  既然对象是属性的集合,那么有必要对属性进行分类。具体如下:
1.数据属性

示例

1
2
3
4
var student = {
name: "gaochang",
university: "BUPT"
}

  以上对象中的name和university两个属性就是数据属性,对它们可以进行数据的读取和写入。对于这类属性而言,它们又具备4个“特性”:

  • configurable:可配置性。
      是指:能否通过delete删除属性,能否对该属性的这些特性进行修改,能否把数据属性修改成访问器属性(访问器属性稍候会提到)。默认true
  • enumerable:可枚举性。
      是指:该数据属性能否通过for-in循环遍历到。默认为true
  • writable:可写性。
      是指:是否能够修改该属性的属性值。默认为true
  • value:值。
      是指: 该属性的具体属性值,比如name属性的value:”gaochang”。

2.访问器属性

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
var book = {
name: "Professional JavaScript for Web Developers",
_isbn: "978-7-115-27579-0"
}

Object.defineProperty(book, "isbn", {
get: function() {
return this._isbn;
}
set: function(str) {
this._isbn = str;
}
})

  以上对象中的_isbn属性就是访问器属性(前缀下划线表示是内部属性)。特别地,访问器属性不能直接定义,必须借助Object.defineProperty()方法。对于这类属性而言,它们又具备4个“特性”:

  • configurable:可配置性。
      是指:能否通过delete删除属性,能否对该属性的这些特性进行修改,能否把数据属性修改成访问器属性(访问器属性稍候会提到)。默认true
  • enumerable:可枚举性。
      是指:该数据属性能否通过for-in循环遍历到。默认为true
  • get:取值。
      是指:读取属性时调用。
  • set:赋值。
      是指:赋值时调用。

对象共有的“特性”

  除了属性之外,每个对象还具有3个“特性”:

  • 原型(prototype):可以理解成一个指针,指向另一个对象-原型对象,后面将做详细讨论。
  • 类(class):是一个标识对象类型的字符串。
  • 扩展标记(extensible flag):ES5新增,表明是否可以向该对象添加新属性。

JavaScript对象创建方式

  了解了对象的基本概念后,就可以进一步分析对象的创建方式。在《JavaScript高级程序设计》中具体分为7种:

  • 工厂模式
  • 构造函数模式
  • 原型模式
  • 组合模式(构造函数+原型)
  • 动态原型模式
  • 寄生构造函数模式
  • 稳妥构造模式

  而在《JavaScript权威指南》中,对象创建方式分为3种:

  • 对象字面量创建(var obj = {}
  • 关键字new构造函数创建
  • Object.create()基于原型创建

  分为这3类,虽说内容和上面的基本一致,但考虑问题的角度不同,个人感觉没有层层递进(《JavaScript权威指南》的分类标准是从本质上区分了创建对象的3种方式,但不利于初次理解)。因此建议理解的过程按照《JavaScript高级程序设计》进行,之后可以再看《JavaScript权威指南》补缺。本文也是按照《JavaScript高级程序设计》进行总结。
  对于7种分类的方式而言,其基础还是“构造函数模式”和“原型模式”两种,其他的都是因为解决之前的某种缺陷而逐步演进得来的,因此下面重点分析这两个。   


创建对象-构造函数模式(constructor属性相关)

工厂模式与构造函数模式的对比

  在看构造函数模式之前,先看一个工厂模式的例子,从而和构造函数模式进行对比。

使用工厂模式创建对象

1
2
3
4
5
6
7
8
9
10
11
12
function createNews(title, subtitle){
var o = new Object();
o.title = title;
o.subtitle = subtitle;
o.release = function() {
alert(o.title + "--" + o.subtitle);
}
return o;
}

var firstNews = createNews("important news", "strategy");
var secondNews = createNews("interesting news", "strategy");

  接下来看看用构造函数模式如何重构上述代码:

使用构造函数模式创建对象

1
2
3
4
5
6
7
8
9
10
function News(title, subtitle){
this.title = title;
this.subtitle = subtitle;
this.release = function() {
alert(this.title + "--" + this.subtitle);
}
}

var firstNews = new News("important news", "strategy");
var secondNews = new News("interesting news", "strategy");

  对比可得构造函数模式的特点:

  1. 函数名一般首字母大写,代表这是一类特殊的函数-构造函数;
  2. 构造函数内并不直接新建对象,所以自然也不会返回初始化好的对象;
  3. 使用构造函数时明确要用new关键字,具体的调用经历了如下四个步骤:
  • new创建一个新对象
  • 将构造函数的作用域赋给这个新对象
  • 执行构造函数中的代码,添加属性
  • 得到初始化好的新对象

  

对象的constructor属性

  以上构造函数模式代码中的firstNews和secondNews两个对象分别保存着News的一个实例(是不同的)。这里就要提到constructor了。firstNews和secondNews两个对象各自有一个constructor属性-构造函数属性。如下所示:

  换句话说,由构造函数创建的对象,它有一个constructor属性,属性值为构造函数本身(指向构造函数),如下所示:

  而对于使用工厂模式创建的对象,也具有constructor属性,只不过直接指向“最高层”的对象Object,如下所示:

  对于字面量直接创建的对象,也具有constructor属性,也是直接指向“最高层”的对象Object,如下所示:

  对于Object.create()方法创建的对象,也具有constructor属性,也是直接指向“最高层”的对象Object,如下所示:

  以上内容可总结如下:

  1. 任何对象都具有constructor属性。

    注意,这里顺便说一下,即便是原始类型的数据,也可以调用constructor属性,原因应该是,JavaScript自动将其转换为对应的包装类型(对象),再调用相应包装类型的constructor属性(对象),如下图所示:(当然,null和undefined没有属性和方法,也就不用讨论了)

  2. 使用构造函数模式创建的对象,其constructor指向创建该对象的构造函数

  3. 其他各类对象,其constructor指向Object()函数

构造函数模式存在的缺陷

  相比于工厂模式,构造函数模式有其优点—它可以准确识别创建的对象的类型。但是,它本身也存在问题:构造函数中的方法,在每个新创建的对象上都要重新实例化一次,也就是说每个对象的该方法都不是同一个实例,然而它们的作用却是相同的,这就增大了开销。
  为了解决这个问题,可以采取的方法是:把构造函数中的方法单独剥离出来,提出全局作用域上的函数,而在构造函数内部仅保留对这个全局函数的引用。这样,通过构造函数创建的所有实例都是共享了同一个函数。但是,这又产生了新的问题:如果构造函数中需要很多方法,全部都提到全局作用域中,就破坏了封装性,结构很不好。对于此问题,则需要采用下面提到的原型模式了。


创建对象-原型模式(prototype属性相关)

原型模式的案例

使用原型模式模式创建对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function News(){
}

News.prototype.title = "important news";
News.prototype.subtitle = "strategy";
News.prototype.release = function() {
alert(this.title + "--" + this.subtitle);
}

var firstNews = new News();
firstNews.title; //"important news"
var secondNews = new News();
secondNews.subtitle; //"strategy"

firstNews.title = "interesting news";
firstNews.title; //"interesting news"

  每个函数其实都有一个prototype(原型)属性,这个属性是一个指针,指向一个对象-原型对象,而这个对象的作用是包含好可以由特定类型的实例所共享的属性和方法。因此,如上所示,构造函数是空的也可以(但必须有这个构造函数),需要共享的内容写在prototype对象上就好了。

理解原型对象(prototype属性)

  到底什么是原型对象呢?
  首先,我们看看上述例子中通过原型模式创建的对象实例firstNews的原型对象是什么:

  这里发现显示的结果是undefined?原型对象没有定义吗?不是这样的。是因为在JavaScript中,prototype是一个内部属性,没有提供标准的直接访问方式,但是在各个主流浏览器的实现中,为我们提供了__proto__属性,可以帮我们“窥探”一个对象的原型对象。如下所示:

从上图我们能清楚地看到firstNews的原型对象的结构,并且和constructor联系起来了

  1. 原型对象有一个constructor属性,它反过来指向了创建该实例的构造函数
  2. 原型对象本身有我们自定义的两个属性title、subtitle和一个方法release()
  3. 从另一个角度(“继承”的角度),原型对象自己可以看做是一个实例对象,它自己也有prototype属性(指向它的原型对象),并且通过__proto__表现出来。

    其次,上述是创建了函数对象,那么对于其他几种方式创建的对象呢?我们来看看:

通过字面量直接创建对象

可以看出,对象的prototype属性时存在的,并且由于没有构造函数,它的constructor属性直接指向了Object,也就是说字面量对象是由Object()直接创建的。

通过Object.create()创建对象

可以看出,实例对象o的原型对象是{x:1, y:2},并且不带任何诸如constructor之类的属性。原因是,根据APIObject.create((proto [, propertiesObject ])),传入的参数就是要创建的对象的原型proto。传入的是{x:1, y:2},因此__proto__的结果自然也就是{x:1, y:2}。

通过以上分析,可作总结:
1.任何时候,只要创建一个对象,就会有prototype属性,并且大多数情况通过__proto__进行展现
2.在此基础上,通过构造函数创建对象稍有特殊(因为constructor不会直接指向Object)。只要创建了一个新的函数对象,就会根据一组特定规则为函数创建一个prototype属性,这个属性指向函数的原型对象。并且,默认的这个原型对象又会自动获取一个constructor属性,反过来指向prototype属性所在的函数(也就是构造函数)。通过以下实验可以很好理解这一点:

  值得说明的是,根据之前提到的,prototype是内部属性不能直接访问(否则会报错),用__proto__可以间接访问,所以上图是通过firstNews.__proto__.constructor来测试的。但实际上我们要讨论的是firstNews.prototype.constructor,即prototype和constructor的关系,要特别注意这一点。

3.但是,要特别注意,以上所说的prototype是内部属性无法访问,是针对创建的实例对象。而对于函数对象,是可以直接访问prototype属性的,如下所示:

prototype__proto__还有更多区别,详见这篇文章JavaScript中__proto__prototype的对比分析

4.可以通过下图更好地理解prototype和constructor的关系。

  

原型链操作

  每次执行对象属性的读取操作时,其实就是执行了一次搜索。搜索会从实例对象本身开始,如果找到了该属性,则返回该属性的值;如果没有找到,则会根据实例对象的prototype指针找到该实例对象的原型对象,在原型对象上执行搜索。如果找到了,则返回该属性的值;如果没找到,则继续向上,根据原型对象的prototype指针向上查找… …,这样就形成了“链式结构”,也就是原型链。
  关于原型链操作必须明确的是:只有在查询属性值的操作中才会感觉到原型链的存在,在修改属性值的过程中是没有原型链的概念的,也就是说修改属性值只会在实例对象中对原有属性进行覆盖,但并不会影响原型链上上层对象中的属性。通过如下过程可以理解:

某个属性属于谁?(实例 or 原型?)

  根据原型链操作的分析,对象属性在实例中和原型中的表现可能是不一样的,但自然就产生了这样的需求:某个属性是属于实例还是原型?
  JavaScript主要提供了以下几种方式来解决这个问题:

  1. 单独使用in操作符
      格式:"title" in firstNews;

    in操作符能筛选出一个属性,无论它是在实例中还是原型中。

  2. 使用for-in循环
      格式:for(var prop in firstNews){}

    for-in循环会返回所有可枚举的属性,无论它是在实例中还是原型中。

  3. 使用Object.keys()
      格式:Object.keys(obj)

    Object.keys()会返回所有可枚举的属性,仅来自实例对象。

  4. 使用Object.getOwnPropertyNames()
      格式:Object.getOwnPropertyNames(obj)

    Object.keys()会返回所有属性(无论是否可枚举),仅来自实例对象。

原型对象的重写

  写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
function News(){
}

News.prototype = {
title: "important news",
subtitle: "strategy",
release: function() {
alert(this.title + "--" + this.subtitle);
}
}

var firstNews = new News();

  这样的写法不是另一种模式,只是为了说明一个问题。根据之前的分析,我们知道firstNews.constructor会指向构造函数本身,也就是function News(){}。那么在现在这样的写法中,firstNews.constructor还是指向function News(){}吗?答案是否定的,如下:

  可以发现现在指向了function Object(),为什么呢?
  原因是,在这种写法中,直接将一个”{}”对象赋给了News.prototype,本质上是完全重写了News.prototype,而不是像之前只是给News.prototype添加属性,比如News.prototype.title。因此,News.prototype的constructor属性也就发生了变化,变成了新的”{}”对象的constructor属性,而新对象”{}”是字面量直接对象,根据之前的分析知道它的constructor属性指向function Object(),所以现在的firstNews.constructor指向了function Object()
  如果想恢复到原来的状态,可以显式声明prototype对象的constructor属性:

原型对象的动态性

  根据原型链操作的分析,每一次属性获取都是一次查询,因此可以动态向原型中添加属性,并且可以立即反映出来。

  但是,如果是重写整个原型对象,情况就不同了:

原生对象的原型

  原生对象是指String()、Number()、Boolean()等包装类型产生的对象,它们的原型对象也是可以自定义的。但是,并不应该直接去修改这些内置类型的原型,因为可能会产生命名空间冲突,移植性差。


总结

  通过上述过程,详细分析了JavaScript中对象创建的相关方法和极其重要的两个属性constructor和prototype。关于JavaScript中的“类的继承”将会在后续单独讨论(以此篇为基础)。