JavaScript中的类和对象
当我们编写如下代码的时候,我们会如何来称呼这个Person呢?
在JS中Person应该被称之为是一个构造函数;
从很多别的面向对象语言过来的开发者,也习惯称之为类,因为类可以帮助我们创建出来对象p1、p2;
如果从面向对象的编程范式角度来看,Person确实是可以称之为类的
面向对象的特性 – 继承
面向对象有三大特性:封装、继承、多态
封装:我们将属性和方法封装到一个类中,可以称之为封装的过程;
继承:继承是面向对象中非常重要的,不仅仅可以减少重复代码的数量,也是多态前提(纯面向对象中);
多态:不同的子类对象在执行时表现出不同的形态;
那么这里我们核心讲继承。
那么继承是做什么的呢?
继承可以帮助我们将重复的代码和逻辑抽取到父类中,子类只需要直接继承过来使用即可。
那么JavaScript当中如何实现继承呢?
不着急,我们先来看一下JavaScript原型链的机制;
再利用原型链的机制实现一下继承;
JavaScript原型链
在真正实现继承之前,我们先来理解一个非常重要的概念:原型链。
我们知道,从一个对象上获取属性,如果在当前对象中没有获取到就会去它的原型上面获取:
前面我们的说法是当在原型上也找不到该属性的时候,会得到一个undefined,其实这里这么说是不准确的,准确的说法是当在当前对象的原型上找不到要查找的属性时,因为原型也是一个对象,原型对象proptotype里面也保存着一个隐式原型属性__propto__指向另一个显式原型prototype,这样一层一层的就形成了原型链,沿着原型链向上查找,直到查找到顶层原型为止,如果还是没找到,则得到undefined,如下:
Object的原型
那么什么地方是原型链的尽头呢?比如第三个对象是否也是有原型__proto__属性呢?
我们会发现它打印的是 [Object: null prototype] {}
事实上这个原型就是我们最顶层的原型了
从Object直接创建出来的对象的原型都是 [Object: null prototype] {}。
注:通过对象字面量{}创建的对象其实本质上等同于new Object创建对象,因为使用new关键字会把对象的显式原型赋值给隐式原型,也就是说字面量对象{} 的__proto__指向的是Object的显式原型,也就是顶层原型了。
所以,为什么Object是所有类型的父类?因为Object的原型对象是最顶层的原型对象了
那么我们可能会有问题:[Object: null prototype] {} 原型有什么特殊吗?
1:该对象有原型属性,但是它的原型属性已经指向的是null,也就是已经是顶层原型了;
2:该对象上有很多默认的属性和方法;
如果我们给obj对象的原型赋值一个新的对象,在内存中的表现会是怎样的呢?
这里注意一下因为obj是一个对象而不是一个函数,所以只能通过__proto__取原型,而不能通过.prototype取原型,或者可以通过obj.constructor.prototype属性
他会创建出来一个obj2对象,这个对象因为是通过对象字面量创建出来的,所以它的原型指向顶部原型,而这个obj2对象就赋值给了obj对象的隐式原型了
假如我们要打印obj里的address,先从obj对象中查找,这时候找不到address,则从obj对象的原型中查找,这里就是obj2了,这时候能找到,则打印address。假设这里仍然找不到,则会继续往上一层原型对象查找,这里则是Object原型对象了,这里仍然找不到的话就不会继续往上查找了,因为Object的原型对象已经指向null了。所以当我们不给对象的__proto__属性进行赋值的时候,这个属性默认指向顶层原型,给赋值的时候,会指向一个我们自己创建的对象,这个手动创建的对象里面的__proto__属性则指向顶层原型。
Object是所有类的父类
从我们上面的Object原型我们可以得出一个结论:原型链最顶层的原型对象就是Object的原型对象,如下图:Person函数对象上有一个显式原型指向Person的原型对象,因为Person的显式原型它也是一个对象,所以上面有一个隐式原型指向顶层原型对象
通过原型链实现继承
如果我们现在需要实现继承,那么就可以利用原型链来实现了:
1.首先,在内存中创建一个Person函数对象,Person函数对象中有一个prototype指向Person的显式原型对象(代码2~4行)
2.在Person的原型对象中添加一个eating属性,这个属性是一个方法(代码6~8行)
3.在内存中创建一个Student函数对象,Student函数对象中有一个prototype指向Student的默认原型对象(代码10~12行)
4.创建出来一个p对象,p对象的(隐式)原型对象是Person默认的原型对象(14行代码),在执行new Person()的时候会执行Person函数体里的代码,所以p对象里会有一个name属性,值是‘why'
5.接着,让Student函数的prototype指向刚刚创建除了的p对象(15行代码)
6.接着在Student的原型上添加一个studying方法(17~19行代码)
7.创建出来一个stu对象,然后把stu对象的原型__propto__赋值为Student的显式原型对象(代码21行)
8.接着在沿着原型链查找name属性和eating方法(代码22,23行)先在stu对象中查找,找不到着沿着原型链查找
目前Student的原型是p对象,而p对象的原型是Person默认的原型,里面包含eating等函数;
注意:14,15行不能写到19行后面,否则会有问题,因为14~19行代码执行的时候做的事情是先改变了Student原型的指向,再在改变后的原型上添加studying方法,如果反过来,则是先在Student原来的原型对象上添加方法,然后让Student的prototype属性指向了另一个对象,这样做studying方法就等于没添加!(要先改变指向,再添加特有属性和方法)
总结一下使用原型链实现继承的时候的关键点:在1~12行创建完父类函数对象和子类函数对象之后,创建一个父类的实例对象(14行),然后让子类的原型指向这个创建出来的父类的实例对象(15行)通过这个操作,后续往子类(Student)原型中添加方法的时候,就相当于在父类实例对象中添加方法而父类对象的原型则指向父类本来的原型,在这里就有父类的方法,这样以后,通过new关键字创建出来的子类(Student)实例对象(stu)就可以拥有父类(Person)的方法了。
以上是通过new出来一个父类的对象作为子类的原型,实现了继承
原型链继承的弊端
但是目前有一个很大的弊端:某些属性其实是保存在p对象上的;
第一,我们通过直接打印对象是看不到继承的属性的(因为只打印该对象上可枚举的属性,至于原型上的属性,不管);
第二,这个属性会被多个对象共享,如果这个对象是一个引用类型,那么就会造成问题;
第三,不能给Person传递参数,因为这个对象是一次性创建的(没办法定制化);
问题1:
为什么打印的时候看不到原型里添加的属性呢?
js机制就是这样的,通过console.log打印的时候只会打印当前对象上的属性,不会打印对象原型上的属性,
如下:
问题2:
问题3
借用构造函数继承
为了解决原型链继承中存在的问题,开发人员提供了一种新的技术: constructor stealing(有很多名称: 借用构造函数或者称之为经典继承或者称之为伪造对象):
steal是偷窃、剽窃的意思,但是这里可以翻译成借用;
借用继承的做法非常简单:在子类型构造函数的内部调用父类型构造函数.
因为函数可以在任意的时刻被调用;
因此通过apply()和call()方法也可以在新创建的对象上执行构造函数;
只通过这一步操作,上面所说的3个弊端就都解决了
借用构造函数继承内存表现
1.代码19行创建了一个p对象,类型是Student类型,只不过因为没有传入参数,所以name,friends,age这几个属性的值是undefined
2.当我们使用Person.call(代码32、33行调用15、16行)的时候是在内存中创建了Student类型的对象stu1和stu2。
当我们在打印stu1,stu2的时候,直接是找到了stu1对象和stu2对象,由于在这2个对象中直接存放着各自的属性,所以这些属性是能打印出来的,改变引用类型属性的时候也是相互独立的,创建对象的时候也是可以传入参数的
为什么叫做借用构造函数继承呢?
因为在子类的构造函数中调用了父类的构造函数,而这个调用的目的是给子类对象添加属性
组合借用继承的问题
组合继承是JavaScript最常用的继承模式之一:
如果你理解到这里, 点到为止, 那么组合来实现继承只能说问题不大
但是它依然不是很完美,但是基本已经没有问题了
组合继承存在什么问题呢?
1.组合继承最大的问题就是无论在什么情况下,都会调用两次父类构造函数。
一次在创建子类原型的时候(代码19行)
另一次在子类构造函数内部(也就是每次创建子类实例的时候)(代码32行调用14行)
2.另外,如果你仔细按照我的流程走了上面的每一个步骤,你会发现:所有的子类实例事实上会拥有两份父类的属性
一份在当前的实例自己里面(也就是person本身的),另一份在子类对应的原型对象中(也就是
person.__proto__里面)
当然,这两份属性我们无需担心访问出现问题,因为默认一定是访问实例本身这一部分的;
我们来构思一个比较合理的实现继承的方案:
如图所示:在这个方案中,我们希望创建一个对象,让子类(Student类)的显式原型属性(prototype)指向这个对象,然后这个对象的隐式原型指向父类(Person类)的显式原型。这样子,因为父类中的方法是在父类的显式原型上的,所以父类的方法能够沿着原型链被继承到子类,子类特有的方法则加到各子类自己的原型上就可以了。
原型式继承函数
原型式继承的渊源
这种模式要从道格拉斯·克罗克福德(Douglas Crockford,著名的前端大师,JSON的创立者)在2006年写的
一篇文章说起: Prototypal Inheritance in JavaScript(在JS中使用原型式继承)
在这篇文章中,它介绍了一种继承方法,而且这种继承方法不是通过构造函数来实现的.
为了理解这种方式,我们先再次回顾一下JavaScript想实现继承的目的:重复利用另外一个对象的属性和方法.
最终的目的:student对象的原型指向了person对象; 为了达到这个目的,我们首先来实现一个小需求
如图:假设我们现在有一个obj对象,而我们希望能够创建出来一个对象,并且让创建出来的对象的原型指向这个obj对象。
这里提供3种实现方式:
方式3也可以直接写成下面的形式,这样就是不指定所创建对象所包含的属性,则创建出来的对象是一个空对象了
以上的原型式继承函数,实现的是对象之间的继承,还没达到类之间的继承
寄生式继承函数
寄生式(Parasitic)继承
寄生式(Parasitic)继承是与原型式继承紧密相关的一种思想, 并且同样由道格拉斯·克罗克福德(Douglas
Crockford)提出和推广的;
寄生式继承的思路是结合原型类继承和工厂模式的一种方式;
即创建一个封装继承过程的函数, 该函数在内部以某种方式来增强对象,最后再将这个对象返回;
代码如下:
但是这种方式的弊端也很明显,每创建一个对象,内存中就多一个studying函数对象,而且我们不希望通过像createStudent这种方法来创建对象,我们希望通过new Student()这种方式来创建对象
寄生组合式继承
现在我们来回顾一下之前提出的比较理想的组合继承
组合继承是比较理想的继承方式, 但是存在两个问题:
问题一: 构造函数会被调用两次: 一次在创建子类型原型对象的时候, 一次在创建子类型实例的时候.
问题二: 父类型中的属性会有两份: 一份在原型对象中, 一份在子类型实例中.
事实上, 我们现在可以利用寄生式继承将这两个问题给解决掉.
你需要先明确一点: 当我们在子类型的构造函数中调用父类型.call(this, 参数)这个函数的时候, 就会将父类型中
的属性和方法复制一份到了子类型中. 所以父类型本身里面的内容, 我们不再需要.
这个时候, 我们还需要获取到一份父类型的原型对象中的属性和方法.
能不能直接让子类型的原型对象 = 父类型的原型对象呢?
不要这么做, 因为这么做意味着以后修改了子类型原型对象的某个引用类型的时候, 父类型原生对象的引用类型也会被修改.
我们使用前面的寄生式思想就可以了
JS原型内容的补充
hasOwnProperty
对象是否有某一个属于自己的属性(不是在原型上的属性)
in/for in 操作符
判断某个属性是否在某个对象或者对象的原型上
instanceof
用于检测构造函数的pototype,是否出现在某个实例对象的原型链上 。这里如果觉得不好理解,采用传统的面向对象语言的方式来理解instanceof操作符吧,比如student的对象,instance of Person就是true了
isPrototypeOf
用于检测某个对象,是否出现在某个实例对象的原型链上
上面2个方法的作用是类似的,区别在于instanceof后面跟的是一个类,而isPrototypeOf方法传入的参数是一个对象
原型继承关系
假设我们有一个obj对象,那么我们知道它的__proto__是指向顶层原型的,因为对象字面量是由Objtct()函数创建出来的,那也就意味着obj.__proto__是指向Object.prototype的
对象里面是有一个隐式原型对象__proto__的
我们现在写了一个函数Foo
由于函数除了是一个函数之外,它也是一个对象,所以它除了有__proto__这个隐式原型对象之外,它还有一个prototype显式原型对象。我们知道obj对象是由Object()方法通过new Object()创建出来的,那么Foo作为一个对象,它是由什么创建出来的呢?他是由Funtion()通过new Function()函数创建出来的
在全局作用域里有2个构造函数(类),Object()和Function(),分别用来创建普通对象和函数对象。所以我们在定义函数的时候,比如说function Foo(){}其实跟var Foo=new Funtion()本质上是一样的。既然Foo函数也是一个对象,那么它也会有一个隐式原型对象__proto__,又由于它也是一个函数,所以函数有一个显式原型对象prototype。
那么问题来了,函数的显式原型prototype和隐式原型__proto__相等吗?它们是不相等的。为什么呢?
我们分别来研究一下函数的隐式原型和显式原型的出处:
Foo.prototype来自哪里呢?
Foo.prototype这个显式原型是当我们一旦创建了一个函数那么js引擎内部就会帮我们创建出来一个新的对象Foo.prototype={},而且这个对象上面有一个constructor,这个constructor指向Foo,相当于是这样了:
Foo.__proto__来自哪里呢?
当我们new了一个Function的时候,也就是Foo=new Function()的时候,由于new关键字的作用,我们会把Function的显式原型赋值给Foo的隐式原型,也就是内部操作是Foo.proto=Function.prototype,那么Function.protoype又是什么呢?前面我们提到过,Function()是一个全局对象,Function.protype是在创建全局对象Function()的时候,系统做了这样的一个操作:
Foo.__proto__来自哪里呢?
所以可以得出来,函数的显示原型Foo.prototype和隐式原型Foo.__proto__是不相等的
我们来验证一下
接着我们来分别拿一下constructor属性
画一下内存图:
①在全局作用域中存在着一个Function对象,这个它的prototype指向Function的原型对象
②我们有一个Foo函数,因为它是一个函数所以js引擎会帮Foo创建一个原型对象,并让Foo的显式原型指向这个创建出来的原型对象
③由于Foo也是一个对象,它是由全家对象Function创建出来的,所以Function对象会把自己的显式原型赋值给Foo的隐式原型,也就是说Foo的隐式原型指向了Function的原型对象
④接着原型对象上有一个constructor属性,分别又指向各自的函数对象
⑤全局对象Object由于也是一个函数,所以它的显式原型prototype指向自己的原型对象
⑥由于Function的原型对象和Foo的原型对象也是对象,并且它们都是由Object创建出来的,所以它们的隐式原型指向的是Object的原型对象
⑦由于Object也是一个函数,所以Objcet的__proto__也是指向Function的原型对象的
⑧而Function作为函数,是由自己创建出来,所以Function的__proto__则指向Function的原型对象
我们再来捋一下函数对象和普通对象创建的时候的内存表现:
1.在全局作用域中有一个对象Function,作为一个函数,它的显式原型prototype指向自己的prototype对象,
又由于它同时也是一个对象,所以它有一个隐式原型,作为一个函数对象它是由自己创建出来的,所以它的隐式原型也指向自己的原型对象
2.在全局作用域中还有一个对象Object,作为一个对象它的显式原型prototype指向自己的原型对象,作为一个函数,它是由Function对象创建出来的,所以它的隐式原型指向Function对象的显式原型对象
3.当我们通过new关键字创建一个普通对象的时候,由于这个对象其实是由全局中的Object对象创建出来的,所以这个普通对象的隐式原型__proto__就指向全局中的Object对象的显式原型,也就是顶层原型对象
4.当我们通过new关键字创建一个函数对象的时候由于这个对象是通过new Foo创建出来的,所以这个对象的隐式原型则指向了Foo函数对象的显式原型了。当然Foo函数的显示原型则指向自己的prototype对象