前端基础进阶(十一):详解面向对象、构造函数、原型与原型链
对象的定义
在ECMAScript-262中,对象被定义为“ 无序属性的集合,其属性可以包含基本值,对象或者函数 ”。
也就是说,在JavaScript中,对象无非就是由一些列无序的key-value
对组成。其中value可以是基本值,对象或者函数。
1 | // 这里的person就是一个对象 |
创建对象
可以通过new的方式创建一个对象。
1 | var obj = new Object(); |
也可以通过对象字面量的形式创建一个简单的对象。
1 | var obj = {}; |
想要给简单对象添加方法时,可以这样表示,
1 | // 可以这样 |
访问对象的属性和方法
假如有一个简单的对象如下:
1 | var person = { |
想要访问它的name属性时,可以用如下两种方式访问。
1 | person.name |
如果想要访问的属性名是一个变量时,常常会使用第二种方式。例如我们要同时访问person的name与age,可以这样写:
1 | ['name', 'age'].forEach(function(item) { |
工厂模式
使用上面的方式创建对象虽然简单,但是在很多时候并不能满足需求。以person对象为例,假如在实际开发中,不仅仅需要一个名字叫做TOM的person对象,同时还需要另外一个名为Jake的person对象,虽然它们有很多相似之处,但是不得不重复写两次。
1 | var perTom = { |
很显然这并不是合理的方式,当相似对象太多时,大家都会崩溃掉。
可以使用工厂模式解决这个问题。顾名思义,工厂模式就是我们提供一个模子,然后通过这个模子复制出需要的对象。需要多少个,就复制多少个。
1 | var createPerson = function(name, age) { |
工厂模式帮助我们解决了重复代码上的麻烦,让我们可以写很少的代码,就能够创建很多个person对象。但是这里还有两个麻烦,需要我们注意。
第一个麻烦就是:没有办法识别对象实例的类型。我们可以使用 instanceof 识别对象的类型,如下例子:
1 | var obj = {}; |
因此在工厂模式的基础上,我们需要使用 构造函数 的方式来解决这个麻烦。
构造函数
在JavaScript中,new关键字 可以让一个函数变得与众不同。通过下面的例子,我们来一探 new关键字 的神奇之处。
1 | function demo() { |
为了能够直观地感受它们不同,建议大家动手实践观察一下。很显然,使用new之后,函数内部发生了事情,让this指向改变。new关键字到底做了什么?
1 | // 先一本正经的创建一个构造函数,其实该函数与普通函数并无区别 |
JavaScript内部再通过其他的一些特殊处理,将
var p1 = New(Person, 'tom', 20)
等效于var p1 = new Person('tom', 20)
。就是我们认识的new关键字了。具体怎么处理的,我也不知道,别刨根问底了,一直回答下去我太难了 !
为了能够判断实例与对象的关系,我们使用构造函数来搞定。
1 | var Person = function(name, age) { |
关于构造函数,如果你暂时不能够理解new的具体实现,就先记住下面这几个结论:
- 与普通函数相比,构造函数并没有任何特别的地方,首字母大写只是我们约定的小规定,用于区分普通函数。
- new关键字让构造函数具有了与普通函数不同的许多特点,而new的过程中,执行了如下过程:
- 声明一个中间对象;
- 将该中间对象的原型指向构造函数的原型;
- 将构造函数的this,指向该中间对象;
- 返回该中间对象,即返回实例对象。
原型
构造函数虽然解决了判断实例类型的问题,但终究是一个对象复制的过程。跟工厂模式颇有相似之处。也就是说,当我们声明了100个person对象,就有100个getName方法被重新生成。
每一个getName方法实现的功能其实是一模一样的,但由于分别属于不同的实例,就不得不一直不停的为getName分配空间。这就是工厂模式存在的第二个麻烦。
显然这是不合理的。我们期望的是,既然都是实现同一个功能,那么能不能就让每一个实例对象都访问同一个方法?当然能,这就是原型对象要帮我们解决的问题了。
每创建一个函数,都有一个prototype属性,该属性指向一个对象。这个对象,就是我们这里说的原型对象。
创建对象时,可以根据自己的需求,选择性的将一些属性和方法通过prototype属性,挂载在原型对象上。而每一个new出来的实例,都有一个__proto__属性,该属性指向构造函数的原型对象,通过这个属性,让实例对象也能够访问原型对象上的方法。因此,当所有的实例都能够通过__proto__访问到原型对象时,原型对象的方法与属性就变成了共有方法与属性。
我们通过一个简单的例子与图示,来了解构造函数,实例与原型三者之间的关系。
1 | // 声明构造函数 |
通过上图可以看出,构造函数的prototype与所有实例对象的__proto__都指向原型对象。而原型对象的constructor指向构造函数。
除此之外,还可以从图中看出,实例对象实际上对前面所说的中间对象的复制,而中间对象中的属性与方法都在构造函数中添加。于是根据构造函数与原型的特性,可以在构造函数中,通过this声明的属性与方法称为私有变量与方法,它们被当前被某一个实例对象所独有。而通过原型声明的属性与方法,我们可以称之为共有属性与方法,它们可以被所有的实例对象访问。
当我们访问实例对象中的属性或者方法时,会优先访问实例对象自身的属性和方法。
1 | function Person(name, age) { |
在这个例子中,我们同时在原型与构造函数中都声明了一个getName函数,运行代码的结果表示原型中的访问并没有被访问。
我们还可以通过in来判断,一个对象是否拥有某一个属性/方法,无论是该属性/方法存在于实例对象还是原型对象。
1 | function Person(name, age) { |
更简单的原型写法
根据前面例子的写法,如果要在原型上添加更多的方法,可以这样写:
1 | function Person() {} |
除此之外,还可以使用更为简单的写法。
1 | function Person() {} |
这种字面量的写法看上去简单很多,但是有一个需要特别注意的地方。Person.prototype = {}
实际上是重新创建了一个{}
对象并赋值给Person.prototype
,这里的{}
并不是最初的那个原型对象。因此它里面并不包含constructor
属性。为了保证正确性,我们必须在新创建的{}
对象中显示的设置constructor
的指向。即上面的constructor: Person
。
原型链
原型对象其实也是普通的对象。几乎所有的对象都可能是原型对象,也可能是实例对象,而且还可以同时是原型对象与实例对象。这样的一个对象,正是构成原型链的一个节点。因此理解了原型,原型链并不是一个多么复杂的概念。
我们知道所有的函数都有一个叫做toString的方法。那么这个方法到底是在哪里的呢?
先随意声明一个函数:
1 | function add() {} |
用如下的图来表示这个函数的原型链:
其中add是Function对象的实例。而Function的原型对象同时又是Object的实例。这样就构成了一条原型链。原型链的访问,其实跟作用域链有很大的相似之处,它们都是一次单向的查找过程。因此实例对象能够通过原型链,访问到处于原型链上对象的所有属性与方法。这也是foo最终能够访问到处于Object原型对象上的toString方法的原因。
基于原型链的特性,我们可以很轻松的实现 继承 。
继承
我们常常结合构造函数与原型来创建一个对象。因为构造函数与原型的不同特性,因此当我们想要实现继承时,就必须得根据构造函数与原型的不同而采取不同的策略。
声明一个Person对象,该对象将作为父级,而子级cPerson将要继承Person的所有属性与方法。
1 | function Person(name, age) { |
首先来看构造函数的继承。在上面我们已经理解了构造函数的本质,它其实是在new内部实现的一个复制过程。而我们在继承时想要的,就是想父级构造函数中的操作在子级的构造函数中重现一遍即可。我们可以通过call方法来达到目的。
1 | // 构造函数的继承 |
原型的继承,只需要将子级的原型对象设置为父级的一个实例,加入到原型链中即可。
1 | // 继承原型 |
更好的继承
假设原型链的终点Object.prototype
为原型链的E(end)端,原型链的起点为S(start)端。
通过前面对原型链的学习,我们知道,处于S端的对象,可以通过S -> E的单向查找,访问到原型链上的所有方法与属性。因此这给继承提供了理论基础。我们只需要在S端添加新的对象,那么新对象就能够通过原型链访问到父级的方法与属性。因此想要实现继承,是一件非常简单的事情。
因为封装一个对象由构造函数与原型共同组成,因此继承也会分别有构造函数的继承与原型的继承。
假设我们已经封装好了一个父类对象Person。如下:
1 | var Person = function(name, age) { |
构造函数的继承比较简单,可以借助call/apply来实现。假设我们要通过继承封装一个Student的子类对象。那么构造函数可以如下实现。
1 | var Student = function(name, age, grade) { |
原型的继承则只需要让子类对象的原型,成为父类对象的一个实例,然后通过__proto__就可以访问父类对象的原型。这样就继承了父类原型中的方法与属性了。
我们可以先封装一个方法,该方法根据父类对象的原型创建一个实例,该实例将会作为子类对象的原型。
1 | function create(proto, options) { |
简单封装create
对象之后就可以使用该方法来实现原型的继承了。
1 | Student.prototype = create(Person.prototype, { |
我们来验证一下这里实现的继承是否正确。
1 | var s1 = new Student('ming', 22, 5); |
全部都能正常访问,没问题。在ECMAScript5中直接提供了一个Object.create
方法来完成我们上面自己封装的create
的功能。因此我们可以直接使用Object.create
。
1 | Student.prototype = create(Person.prototype, { |
完整代码如下:
1 | function Person(name, age) { |
属性类型
在上面的继承实现中,使用了一个大家可能不太熟悉的方法defineProperties
。并且在定义getGrade
时使用了一个很奇怪的方式。
1 | getGrade: { |
这其实是对象中的属性类型。在我们平常的使用中,给对象添加一个属性时,直接使用object.param
的方式就可以了,或者直接在对象中挂载。
在ECMAScript5中,对每个属性都添加了几个属性类型,来描述这些属性的特点。它们分别是:
- configurable: 表示该属性是否能被delete删除。当其值为false时,其他的特性也不能被改变。默认值为true
- enumerable: 是否能枚举。也就是是否能被for-in遍历。默认值为true
- writable: 是否能修改值。默认为true
- value: 该属性的具体值是多少。默认为undefined
- get: 当我们通过
person.name
访问name的值时,get将被调用。该方法可以自定义返回的具体值是多少。get默认值为undefined - set: 当我们通过
person.name = 'Jake'
设置name的值时,set方法将被调用。该方法可以自定义设置值的具体方式。set默认值为undefined
需要注意的是,不能同时设置value、writable 与 get、set的值。
我们可以通过Object.defineProperty
方法来修改这些属性类型。下面用一些简单的例子来演示一下这些属性类型的具体表现。
configurable
1 | // 用普通的方式给person对象添加一个name属性,值为TOM |
enumerable
1 | var person = { |
writable
1 | var person = { |
value
1 | var person = {} |
get/set
1 | var person = {} |
请尽量同时设置get、set。如果仅仅只设置了get,那么将无法设置该属性值。如果仅仅只设置了set,也无法读取该属性的值。
Object.defineProperty
只能设置一个属性的属性特性。当我们想要同时设置多个属性的特性时,需要使用之前提到过的Object.defineProperties
。
1 | var person = {} |
读取属性的特性值
我们可以使用Object.getOwnPropertyDescriptor
方法读取某一个属性的特性值。
1 | var person = {} |