原型、闭包、作用域等知识可以说是js中面试必考的东西,通过你理解的深度也就能衡量出你基本功是否扎实。今天来复习一下javascript的原型和继承,虽说是老生常谈的话题,但对于这些知识,自己亲手写一遍能更加透彻的理解,能用自己的话说明白了,也就真正理解了。
原型是什么?
在javascript中,通过关键字new调用构造器函数或者使用字面量声明,我们可以得到一个对象实例。每个对象实例内部都持有一个指针,指向一个普通的对象,这个普通的对象就是原型,这是天生的。为什么说它是普通的对象呢?因为它确实没什么特别的地方,同样也是某个构造器函数的一个实例,这个构造器可以是Object,可以是Array,也可以是其他你自己定义的构造器函数。在js中,对象实例的原型是不可访问的,不过在chrome和Firefox浏览器中,我们可以用一个名为__proto__的属性来访问到,来看一下所谓的原型长什么样:
我用string的包装类来创建了一个对象s,可以看到s的原型是一个对象,该对象上包含了一系列方法,比如我们熟悉的charAt。这里也就很明显了,我们平时调用s.charAt(0),其实调用的是s的原型上的方法,也就是说,原型上的属性可以被对象访问到,就像是在访问自身的属性一样。可以认为原型就像孕妇肚子里的孩子一样,孩子的胳膊也可以算是孕妇的胳膊,都在自己身上嘛。不过区别是这里的原型只是一个引用,并不是真正的包含这个对象。注意不要被__proto__后面的那个String迷惑到,s的原型是一个Object的实例,而不是String的实例。下面的代码可以证明:
s.__proto__ instanceOf String; //false s.__proto__ instanceOf Object; //true s.hasOwnProperty('charAt'); //false s.__proto__.hasOwnProperty('charAt'); //true
要明白这个原型指针到底指向什么,就需要明白对象是如何创建出来的,所以接下来有必要了解一下构造器函数。
javascript中没有类,但可以把函数当类使,被用来当做类构造器的函数就叫构造器函数,一般把首字母大写来与普通函数进行区别,其实就是猪鼻子插根葱而已——装象。js中一切都是对象,所以函数也是对象,所以函数也有一个原型指针。与实例对象不同的是,函数这种特殊的对象,它的原型可以通过prototype属性显式的访问到,来看看String类的原型是啥样的:
好像跟我们上面看到的s的原型是一模一样的。。。是这样吗?验证一下:
这是什么原因呢?我们就要细究一下var s = new String('s');在执行的时候到底发生了什么,其实就是用new关键字调用函数String的时候发生了什么:
- 创建一个空对象obj,即Object的一个实例
- 把这个空对象obj绑定到函数的上下文环境中,相当于把this指向了obj
- 执行函数,这个过程就把函数中的属性、方法拷贝到了obj中
- 将obj的原型指向函数的prototype属性
- 返回这个obj对象,s作为它的引用。
到这里就可以得出结论了:对象实例与它的构造器函数拥有同一个原型,这个原型指向的是构造器的父类的一个实例。
我第一次提到了“父类”,在面向对象的语言中,如果B继承自A,我们说A是B的父类。javascript是通过原型实现继承的,所以我也可以说,我的原型指向谁,谁就是我的父类。通过上面的代码我们可以得出:
String.prototype === s.__proto__ //true String.prototype instanceOf Object //true
可以用面向对象语言的话说,Object就是String的父类。之所以这么说是因为这样容易记住,再来重复一遍结论:对象实例与它的构造器函数拥有同一个原型,这个原型指向的是构造器的父类的一个实例。这个结论是非常有用的,由于对象实例的原型是不可访问的(__proto__只是浏览器提供的能力),我们可以通过constructor属性得到它的构造器,然后用构造器的prototype属性来访问到原型,像这样:
s.constructor.prototype
理解的过程像是在做一道道证明题一样。尽管有大师推荐在js中用构造器函数这个称呼来代替类,但为了便于理解和记忆,我还是这么叫吧~
原型的一些特性
明白是原型是什么东西,来看看原型都有哪些特性。其实也不能说是原型的特性,而是javascript语言的特性。
首先要看的就是所谓的原型链。每个对象都有原型,而对象的原型也是一个普通对象,那么就可以形成一个链,例如String对象的原型是Object类的一个实例,而Object对象的原型是一个空对象,空对象的原型是null。除去null不看的话,原型链的顶端是一个空对象{}
当我们访问对象的一个属性时,会先从对象自身找,如过自身没有,就会顺着原型链一直往上找,直到找到为止。如果最后也没找到,则返回undefined。这样对象的内容就会很“丰富”,我的是我的,原型的也是我的。通过修改原型的指向,对象可以获得相应原型上的属性,js就是通过这种方式实现了继承。
有一点需要注意的是,属性的读操作会顺着原型链来查找,而写操作却不是。如果一个对象没有属性a,为该对象的a属性赋值会直接写在该对象上,而不是先在原型上找到该属性然后修改值。举个例子:
var s = new String('string'); s.charAt(0); //返回s s.hasOwnProperty('charAt'); //返回false 说明charAt不是自身的方法,而是原型上的 s.charAt = function(){return 1;} //为s的charAt赋值 s.hasOwnProperty('charAt'); //返回true 说明自身有了charAt方法 s.charAt(0); //返回1 这时候调用charAt找到了自身的方法 s.constructor.prototype.charAt.call(s,0); //返回s 调用原型上的charAt方法结果与原来一样
上面的例子说明,为对象的属性赋值是不会影响到原型的。这也是合理的,因为创建出来的对象s,它的原型是一个指针,指向了构造器的原型。如果原型被修改,那么该类的其他实例也会跟着改变,这显然是不愿意看到的。
我们愿意看到的是,修改了一个构造器的原型,由它构造出的实例也跟着动态变化,这是符合逻辑的。比如我们创建一个Person类,然后修改其原型上的属性,观察它的实例的变化:
function Person(name){ this.name = name; } Person.prototype.age = 10; var p1 = new Person('p1'); console.log(p1.age); //10 Person.prototype.age = 11; console.log(p1.age); //11
这是因为age存在于原型上,p1只是拥有一个指针指向原型,原型发生改变后,用p1.age访问该属性必然也跟着变化。
用原型实现继承
用原型实现继承的思路非常简单,令构造函数的原型指向其父类的一个实例,这样父类中的属性和方法也就相当于被引用到了,调用起来和调用自己的一样。比如定义一个Programmer类继承自Person:
function Person(name){ this.name = name; } Person.prototype.age = 10; function Programmer(name){ this.name = name; } Programmer.prototype = new Person(); Programmer.prototype.constructor = Programmer; var p1 = new Programmer('p1'); console.log(p1.age); //10
可以看到Programmer的实例p1继承了Person的属性age。另外需要注意的就是constructor的修正。因为我们new一个Person对象出来,它的constructor指向自身的构造函数Person,所以在Programmer的原型中,这个constructor始终是Person,这与逻辑是不符的,所以必须显式的“纠正”一下这个副作用,让Programmer原型上的constructor指向自己。
以上代码实现了一个基本的继承。但其中还是有不少可以扩展的地方,如果面试的时候只答出上面的这些,只能算是及格吧。关于如何优化继承的代码,有位大牛的文章分析的十分详细,出于篇幅原因我在本篇就不再陈述。直接贴上链接地址:http://www.cnblogs.com/sanshi/archive/2009/07/08/1519036.html,共六篇系列博客,非常详细。
----------------补充于2014.01.07---------------------
在上面的继承实现方式中,有一个消耗内存的地方,就是为子类指定原型时需要new一个父类的对象,有人做了比较好的处理,今天看到了代码,据说是coffeescript中的,抄在这里:
var _hasProp = {}.hasOwnProperty; var extends = function(child,parent){ for(var key in parent){ if(_hasProp.call(parent,key)){ child[key] = parent[key]; } } function ctor(){ this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child._super_ = parnet.prototype; return child; }
是一个完整的实现继承的方法。在内部创建了一个最小化的对象,减少内存消耗。
继承的另一种实现方式
除了用原型,还有一种方式也可以实现继承,叫做类复制。怎么个复制法呢,看下面的代码:
function People(name){ this.name = name; this.age = 11; this.getName = function(){ return this.name; } } function Worker(name){ People.call(this,name); } var w1 = new Worker('w1'); console.log(w1.getName()); //w1 console.log(w1.age); //11
在People构造器中所有的属性和方法都用this关键字定义在了自身,而不是放在它的原型上。在子类Worker中,用call把People当作函数执行了一下,并传入this作为上下文对象。这样就相当于把People中的所有语句拿过来执行一次,所有属性的定义也都被复制过来了。同样可以实现继承。完全与原型无关。
那么这种方式与原型继承有何区别呢?最大的区别就在于原型是一个引用,所有实例都引用一个共享的对象,每次创建出一个实例时,并不会复制原型的内容,只是用一个指针指过去。而类复制的方法不存在共有的东西,每创建一个对象都把构造器中的代码执行一次,当构造器中的方法较多时,会消耗很多的内存。而原型继承就不会了,只需一个指针指过去就完了。
由这种工作方式产生的另一个区别就是动态修改,我们知道在原型继承中,只要修改了构造器原型中的值,实例对象也跟着变化。但是类复制就不能了,每个对象都有自己的一份数据,已创建出来的对象不会再受构造器的影响了。
另外还有一点,就是属性的访问速度。类复制的方式,对象的属性都在自身,所以在查找的时候可以立即找到,而原型继承在查找的时候还得顺着原型链向上查找,其访问速度肯定不如类复制的快。
总结
以上是我理解到的原型与继承的知识点,可能理解还是没有那么透彻,只是从比较浅的层次梳理了一下。与原型相关的知识还有很多有深度的,还有待于继续研究。这篇博客写完我也感觉到,写一篇基础知识分析的文章真是挺困难的,需要你对每一个细节都掌握清楚,生怕稍不注意就给别人误导。可能自己的水平也有待提高吧,本篇就先分析到这个程度,不知这个程度能否达到初级前端工程师的门槛。后续收集到了面试题,我会结合分析。