类是面向对象程序设计的核心概念之一。一个类代表了具有相似属性的一类事物的抽象。从本篇开始,我们将正式的进入JavaScript的面向对象部分。首先需要注意的是,在JavaScript中并没有“类”这一关键字——在大多数语言中都是使用class作为关键字的。所以,这里的类就成了一个概念,它没有明确的语法标志。
1. 类和构造函数
前面说过,在JavaScript中并没有明确的类的概念。实际上,我们给出的只是类的构造函数。类的构造函数构成了这个类的全部内容。既然叫做构造函数,它也是一个普通的函数,没有什么不同之处。因此,我们能够很轻易的定义出一个构造函数:
- function Person(name, age) {
- this.name = name;
- this.age = age;
- this.show = function() {
- alert("Hello, my name is " + this.name + ", my age is " + this.age);
- };
- }
这里,我们定义了一个类 Person,它有两个属性:name和age;有一个方法:show。看上去和其他语言的类的定义没有什么不同。其实,这里最大的不同就是在于这个关键字function。我们使用了定义函数的方式定义了一个类。
2. new
定义出来类之后,需要创建类的对象。同其他语言一眼,JavaScript也使用new操作符创建对象。具体代码如下:
- var bill = new Person("Bill", 30);
- alert(bill.name);
- alert(bill["age"]);
- bill.show();
这里使用new创建一个Person类的对象。和其他语言类似,new之后是该类的构造函数。当创建对象之后,就可以像前面的章节中说到的一样,使用.或者[]对属性进行访问。
注意一下,这里的构造函数就是一个普通的函数,那么,是不是所有的函数都可以使用new操作符呢?答案是肯定的。那么,这个new操作符到底做了什么呢?
当使用new操作符的时候,首先JavaScript会创建一个空的对象,然后将会对这个对象进行初始化。用什么来初始化呢?当然就是你调用的那个构造函数了。最后,这个创建的对象将返回给调用者,于是,我们就可以使用这个对象了。
3. prototype
prototype是原型的意思。在JavaScript中,每个对象都有一个prototype属性。这个属性指向一个prototype对象。这就是原型属性和原型对象的概念。
每个对象都有一个prototype属性,构造函数是一个普通的函数,而函数也是一个对象,因此,构造函数也有一个prototype属性。而每个prototype对象都有一个constructor属性,这个prototype对象的constructor属性指向这个prototype属性所在的构造函数本身。也就是说,new操作符要保证生成的对象的prototype属性和构造函数的prototype属性是一致的。
有点迷糊了不是?看一下附件中的图,无论怎样,都要保证这个图所示的关系式正确的!
需要大家注意的是,这个prototype对象是JavaScript的面向对象的基础,包括继承等的实现都是使用prototype。
4. 一个实现技巧:检测参数非空和设置参数默认值
由于JavaScript函数对于参数控制比较困难,因此参数检测成为一个不可忽视的问题。这里给出一个编程的小技巧,能够检查传入的实参是否非空,以及给参数设置默认值。
- function print(mustHave, person) {
- var defaultPerson = {
- name: "noname",
- age: 0
- };
- if(!mustHave) { // 非空检测
- alert("mustHave should not be null!");
- return;
- }
- person = person || defaultPerson; // 设置默认值
- alert(mustHave + ": name- " + person.name + "; age- " + person.age);
- }
- print();
- print("sth");
- print("sth", {name: "new", age: 20});
非空检测比较简单。默认值的设置比较有技巧,利用了JavaScript的||操作的短路特性。如果形参person为空,那么||前半部分为false,通过或操作,将把person设置为defaultPerson;如果person非空,则||直接返回true,那么就不进行或操作。
封装是面向对象的重要概念之一。如果一个程序没有封装性,也就谈不上什么面向对象。但是,JavaScript并不像其他的语言,比如Java,有公有变量和私有变量等;在JavaScript中只有一种作用域:公有作用域。在本章中,我们将会见识到JavaScript是如何实现封装的特性的。
1. this和公有变量
首先需要理解this关键字。看下面的一段代码,你应该对此感到熟悉:
- function Person(name, age) {
- this.name = name; // 定义一个公有变量
- this.age = age;
- this.show = function() { // 定义一个公有函数
- alert("name: " + name + "; age: " + age);
- }
- }
- var bill = new Person("Bill", 20);
- alert(bill.name);
- bill.show();
这里的this关键字是必不可少的。前面只是让大家记住,那么为什么要这样呢?想想JavaScript的对象,JavaScript的对象类似于散列,一个<string, object>键-值对的集合。这里的对象的属性实际上都是离散的,并不像其他的语言那样绑定到一个对象上面。this关键字指代的是属性或者函数的调用者,也就是说,谁调用这个属性或者函数指的就是谁。可以看到,这里的this和Java或者C++的this是有所不同的,后者的this是指属性或者函数所在的那个对象本身。而这里this的作用就是将它后面跟着的属性或者对象绑定到调用者上面。回忆一下JavaScript的new的过程,首先将创建一个空的对象,然后使用构造函数初始化这个对象,最后返回这个对象。在这个过程中,JavaScript将把this用这个对象替换,也就是把对象和这些属性或函数相关联,看上去就像是这个调用者拥有这个属性或者函数似的,其实这是this的作用。
这样看来,show里面的name和age并没有关键字,但也是可以正常的执行就会明白怎么回事了——因为前面已经用this把name和age与这个对象bill相关联,并且,show也关联到这个bill变量,因此JavaScript是可以找到这两个变量的。
这样来看,似乎由this修饰的都是公有变量。事实确实如此,如果你要使一个变量成为公有变量,可以使用this。像上面代码中的name和age都是公有变量,在外面使用aPerson.name或者aPerson.age就可以访问到。
2. 私有变量
怎么声明一个私有变量呢?事实上就像前面说的,JavaScript根本没有私有作用域这一说。那么来看下面的代码:
- function Person(name, age) {
- var name = name; // 私有属性
- var age = age;
- var show = function() { // 私有函数
- alert("name: " + name + "; age: " + age);
- }
- }
- var bill = new Person("Bill", 20);
- alert(bill.name); // undefined
- bill.show(); // error, 不存在
这段代码和前面几乎是相同的,只是把属性前面的this换成了var。我们知道,var是用来声明变量的。show函数和bill.name都是未定义!这是怎么回事呢?
回忆一下前面说过的JavaScript的new的过程。由于name和age都是使用var声明的,JavaScript会将它看作是一个普通的变量,这样在构造初始化结束之后,构造函数就返回了,变量因超出作用域而访问不到。也就是说,我们使用JavaScript变量作用域模拟了私有属性。
3. 静态变量
静态变量是绑定到类上面的。对于不同的对象来说,它们共享一个静态变量。
- Person.num = 0; // 静态属性
- function Person() {
- this.show = function() {
- alert("num: " + Person.num);
- };
- Person.num++;
- }
- var bill = new Person();
- bill.show(); // 1
- var tom = new Person();
- tom.show(); // 2
- bill.show(); // 2
在JavaScript中可以很方便的添加静态属性,因为JavaScript的对象就是散列,所以只要简单的在类名后添加一个属性或者函数即可。
4. 访问私有变量和公有变量
当对私有变量进行访问时,只需要使用变量的名字就可以了,但是,如果要访问公有变量,则需要使用this关键字。
- function Person(name, age) {
- this.myName = name;
- var myAge = age;
- this.show = function() {
- alert("show = name: " + this.myName + "; age: " + myAge);
- }
- var showAll = function() {
- alert("showAll = name: " + this.myName + "; age: " + myAge);
- }
- }
- var bill = new Person("Bill", 20);
- bill.show();
在这里,如果去掉myName的this关键字,就会有未定义属性的错误。
简单来说,我们需要使用this来声明公有变量,使用var来声明私有变量。但是,JavaScript却不是那么简单,因为JavaScript是一个脚本语言,我们需要十分关心它的执行效率。下面,我们将会看一下JavaScript面向对象设计的最佳实践。
闭包这个概念看上去很深奥,这个词在离散数学里面的意思确实比较难于理解。在这里,我们先可以把闭包理解成是一种匿名函数或者匿名类。
1. 什么是闭包?
什么是闭包?一种正式的解释是:所谓闭包,指的是一种拥有很多变量并且绑定了这些变量的环境的表达式(通常是一个函数),因而这些变量也是这个表达式的一部分。
相信很多人都不会理解这个定义,因为他的学术味道太浓了——或许你喜欢从字面的语法上进行分析:首先,它是一个表达式,这个表达式绑定了很多变量以及这些变量的环境。不过这并没有什么意义,这依然不会告诉我们什么是闭包。
那么,来看一个例子:
- function add(a) {
- return function(b) {
- return a + b;
- };
- }
- var func = add(10);
- alert(func(20));
我想经过了前面有关函数的描述,这个例子应该很清楚的理解。JavaScript里面的函数就是对象,他可以做对象能做的一切事情——我们首先定义了一个函数add,它接受一个参数,这个函数返回一个匿名函数,这个匿名函数也接受一个参数,并且会返回这个参数同外部函数的那个参数的和。因此在我们使用的时候,我们将add返回的匿名函数赋值给func,然后调用func,就返回了这两个数的和。
当我们创建一个这样的函数,这个函数内部的一个变量能够在函数外面被引用时,我们就称创建了一个闭包。仔细的品味一下:这就是那个闭包的定义。
看看我们的代码:首先,它有一个内部变量,就是那个匿名函数;其次,这个函数将匿名函数返回了出去,以便外面的变量可以引用到内部定义的变量。
2. 闭包的作用
闭包有什么用呢?或许现在还看不出来,那么看看这段代码:
- function inc(a) {
- var i = 0;
- return function() {
- return i;
- };
- }
- var num = inc();
- alert(num());
本来,这个变量 i 在函数外面是访问不到的,因为它是 var 定义的,一旦跳出作用域,这个变量就被垃圾回收了,但是,由于我们使用了闭包,在外面是能够访问到这个变量的,因此它并不被垃圾回收!
如果还是不明白闭包的作用,那么看一段应该很熟悉的代码:
- function Person() {
- var id;
- this.getId = function() {
- return id;
- }
- this.setId = function(newId) {
- id = newId;
- }
- }
- var p = new Person();
- p.setId(1000);
- alert(p.getId()); // 1000
- alert(p.id); // undefined
我们定义一个类Person,它有一个id属性。现在这个属性的行为很像是私有变量——只能通过 setter 和 getter 函数访问到。没错,这就是闭包的一个用途:制造类的私有变量!
闭包还有一个作用:在内存中维护一个变量,不让垃圾回收器回收这个变量。这里的例子就不再举出了。
这里我们只是简单的说了JavaScript的闭包的概念,并没有涉及闭包的内存模型等等之类。这是一个相当重要的概念,Java社区中的部分成员一直对闭包梦寐以求,C#也已经在最新版本中添加了闭包的概念,只不过在那里称为lambda表达式。
优雅的封装还是执行的效率?这是一个悖论。
优雅封装的程序看起来是那么的美妙:每个属性被隐藏在对象之后,你所能看到的就是这个对象让你看到的,至于它到底是怎么操作的,这个不需要你操心。
执行的效率就是另外一回事。就像是C语言和面向对象的C++之间的差别:C++很优雅,但是执行效率,无论是编译后的二进制代码还是运行期的内存的占用,都要比简单的C语言多出一截来。
这个问题在脚本语言中显得更加重要,因为JavaScript根本就是一种解释语言,解释语言的执行效率要比编译语言低很多。
1. 优雅的封装
我们先来看看变量封装。这里的变量不仅仅是属性,也包括函数。
前面已经说过,JavaScript中并没有类这个概念,是我们利用变量作用域和闭包“巧妙的模拟”出来的,这是一种优雅的实现。还是温故一下以前的代码:
- function Person() {
- var id;
- var showId = function() {
- alert("My id is " + id);
- }
- this.getId = function() {
- return id;
- }
- this.setId = function(newId) {
- id = newId;
- }
- }
- var p = new Person();
- p.setId(1000);
- alert(p.id); // undefined
- // p.showId(); error: function not defined
- var p2 = new Person();
- alert(p.getId == p2.getId); // false
我们很优雅的实现了私有变量——尽管是投机取巧的实现的。但是,这段代码又有什么问题呢?为什么两个对象的函数是不同的呢?
想一下,我们使用变量的作用域模拟出私有变量,用闭包模拟出公有变量,那么,也就是说,实际上每个创建的对象都会有一个相同的代码的拷贝!不仅仅是那个id,就连那些showId、getId 等函数也会创建多次。注意,考虑到JavaScript函数就是对象,就不会感到那么奇怪了。但是毫无疑问,这是一种浪费:每个变量所不同的只是自己的数据域,函数代码都是相同的,因为我们进行的是同一种操作。其他语言一般不会遇到这种问题,因为那些语言的函数和对象的概念是不同的,像Java,每个对象的方法其实指向了同一份代码的拷贝,而不是每个对象都会有自己的代码拷贝。
2. 去看效率
那种封装虽然优雅,但是很浪费。好在JavaScript是一种灵活的语言,于是,我们马上想到,把这些函数的指针指向另外的一个函数不就可以了吗?
- function show() {
- alert("I'm a person.");
- }
- function Person() {
- this.show = show;
- }
- var p1 = new Person();
- var p2 = new Person();
- alert(p1.show == p2.show); // true
这个办法不错,解决了我们以前的那个问题:不同的对象共享了一份代码。但是这种实现虽然有了效率,可是却太不优雅了——如果我有很多类,那么岂不是有很多全局函数?
好在JavaScript中还有一个机制:prototype。还记得这个prototype吗?每个对象都维护着一个prototype属性,这些对象的prototype属性是共享的。那么,我们就可以把函数的定义放到prototype里面,于是,不同的对象不就共享了一份代码拷贝吗?事实确实如此:
- function Person() {
- }
- Person.prototype.show = function() {
- alert("I'm a person.");
- }
- var p1 = new Person();
- var p2 = new Person();
- alert(p1.show == p2.show); // true
不过,这种分开定义看上去很别扭,那么好,为什么不把函数定义也写到类定义里面呢?
- function Person() {
- Person.prototype.show = function() {
- alert("I'm a person.");
- }
- }
- var p1 = new Person();
- var p2 = new Person();
- alert(p1.show == p2.show); // true
实际上这种写法和上面一种没有什么不同:唯一的区别就是代码位置不同。这只是一个“看上去很甜”的语法糖,并没有实质性差别。
最初,微软的.Net AJAX框架使用前面的机制模拟了私有变量和函数,这种写法和C#很相像,十分的优雅。但是,处于效率的缘故,微软后来把它改成了这种原型的定义方式。虽然这种方式不那么优雅,但是很有效率。
在JavaScript中,这种封装的优雅和执行的效率之间的矛盾一直存在。现在我们最好的解决方案就是把数据定义在类里面,函数定义在类的prototype属性里面。