长久以来,JavaScript总是被广大的专业开发者轻视,不少人以为JavaScript是像VBScript一样的雕虫小技,或者说是给非专业的网页设计者用的简易工具。而早期的因特网上也恰恰流传着大量低质量的JavaScript代码,很多都是可视化网页设计工具生成的,复杂而混乱,更加深了人们对它的不良印象。在当时,JavaScript的一个主要作用是在网页上显示出花哨的效果,譬如弹出令人厌烦的广告窗口。
早期的JavaScript运行效率低下、浏览器兼容性问题严重。就连JavaScript之父Brendan Eich都觉得它很烂,从来没有想过它能够发展成今天的样子。后来随着以Gmail为代表的Web 2.0应用的兴起,人们开始重新认识JavaScript。
JavaScript经历了一个十分纠结的发展过程,因为ECMAScript新标准总是在提出后若干年才会被浏览器开发商陆续实现,所以开发者不得不忍痛割爱放弃许多JavaScript优美的新特性,以保持浏览器之间的兼容性。值得庆幸的是,这些问题在Node.js中已不复存在,我们可以放心地享受JavaScript的全部特性给我们带来的便利了。这些特性大多已经是现代编程语言共有的理念,例如面向对象、函数式编程思想、lambda演算、闭包、动态绑定等。
我假设你了解JavaScript的基本语法,并且对面向对象的语言有一定的理解,如果你还知道函数式编程(functional programming),那么你将可以很容易地理解闭包。本附录通过大量的示例帮你了解JavaScript众多特性,理解JavaScript背后的机制。我们以作用域、闭包和对象为线索,介绍JavaScript编程中常用到的特性和技巧。
A.1 作用域
作用域(scope)是结构化编程语言中的重要概念,它决定了变量的可见范围和生命周期,正确使用作用域可以使代码更清晰、易懂。作用域可以减少命名冲突,而且是垃圾回收的基本单元。和C、C++、Java等常见语言不同,JavaScript的作用域不是以花括号包围的块级作用域(block scope),这个特性经常被大多数人忽视,因而导致莫名其妙的错误。例如下面代码,在大多数类C的语言中会出现变量未定义的错误,而在JavaScript中却完全合法:
这是因为JavaScript的作用域完全是由函数来决定的,if、for语句中的花括号不是独立的作用域。
A.1.1 函数作用域
不同于大多数类C的语言,由一对花括号封闭的代码块就是一个作用域,JavaScript的作用域是通过函数来定义的,在一个函数中定义的变量只对这个函数内部可见,我们称为函数作用域。在函数中引用一个变量时,JavaScript会先搜索当前函数作用域,或者称为“局部作用域”,如果没有找到则搜索其上层作用域,一直到全局作用域。我们看一个简单的例子:
以上示例十分明了,JavaScript的函数定义是可以嵌套的,每一层是一个作用域,变量搜索顺序是从内到外。下面这个例子可能就有些令人困惑:
上面代码可能和你预想的不一样,没有输出global,而是undefined,这是为什么呢?这是JavaScript的一个特性,按照作用域搜索顺序,在console.log函数访问scope变量时,JavaScript会先搜索函数f的作用域,恰巧在f作用域里面搜索到scope变量,所以上层作用域中定义的scope就被屏蔽了,但执行到console.log语句时,scope还没被定义,或者说初始化,所以得到的就是undefined值了。
我们还可以从另一个角度来理解:对于开发者来说,在访问未定义的变量或定义了但没有初始化的变量时,获得的值都是undefined。于是我们可以认为,无论在函数内什么地方定义的变量,在一进入函数时就被定义了,但直到var所在的那一行它才被初始化,所以在这之前引用到的都是undefined值。(事实上,JavaScript的内部实现并不是这样,未定义变量和值为undefined的变量还是有区别的。)
函数作用域的嵌套
接下来看一个稍微复杂的例子:
上面是一个函数作用域嵌套的例子,我们在最内层函数引用了scope变量,通过作用域搜索,找到了其父作用域中定义的scope变量。
有一点需要注意:函数作用域的嵌套关系是定义时决定的,而不是调用时决定的,也就是说,JavaScript的作用域是静态作用域,又叫词法作用域,这是因为作用域的嵌套关系可以在语法分析时确定,而不必等到运行时确定。下面的例子说明了这一切:
这个例子中,通过f2调用的f1在查找scope定义时,找到的是父作用域中定义的scope变量,而不是f2中定义的scope变量。这说明了作用域的嵌套关系不是在调用时确定的,而是在定义时确定的。
A.1.2 全局作用域
在JavaScript中有一种特殊的对象称为全局对象。这个对象在Node.js对应的是global对象,在浏览器中对应的是window对象。由于全局对象的所有属性在任何地方都是可见的,所以这个对象又称为全局作用域。全局作用域中的变量不论在什么函数中都可以被直接引用,而不必通过全局对象。
满足以下条件的变量属于全局作用域:
□在最外层定义的变量;
□全局对象的属性;
□任何地方隐式定义的变量(未定义直接赋值的变量)。
需要格外注意的是第三点,在任何地方隐式定义的变量都会定义在全局作用域中,即不通过var声明直接赋值的变量。这一点经常被人遗忘,而模块化编程的一个重要原则就是避免使用全局变量,所以我们在任何地方都不应该隐式定义变量。
A.2 闭包
闭包(closure)是函数式编程中的概念,出现于20世纪60年代,最早实现闭包的语言是Scheme,它是LISP的一种方言。之后闭包特性被其他语言广泛吸纳。
闭包的严格定义是“由函数(环境)及其封闭的自由变量组成的集合体。”这个定义对于大家来说有些晦涩难懂,所以让我们先通过例子和不那么严格的解释来说明什么是闭包,然后再举例说明一些闭包的经典用途。
A.2.1 什么是闭包
通俗地讲,JavaScript中每个的函数都是一个闭包,但通常意义上嵌套的函数更能够体现出闭包的特性,请看下面这个例子:
这段代码中,generateClosure()函数中有一个局部变量count,初值为0。还有一个叫做get的函数,get将其父作用域,也就是generateClosure()函数中的count变量增加1,并返回count的值。generateClosure()的返回值是get函数。在外部我们通过counter变量调用了generateClosure()函数并获取了它的返回值,也就是get函数,接下来反复调用几次counter(),我们发现每次返回的值都递增了1。
让我们看看上面的例子有什么特点,按照通常命令式编程思维的理解,count是generateClosure函数内部的变量,它的生命周期就是generateClosure被调用的时期,当generateClosure从调用栈中返回时,count变量申请的空间也就被释放。问题是,在generateClosure()调用结束后,counter()却引用了“已经释放了的”count变量,而且非但没有出错,反而每次调用counter()时还修改并返回了count。这是怎么回事呢?
这正是所谓闭包的特性。当一个函数返回它内部定义的一个函数时,就产生了一个闭包,闭包不但包括被返回的函数,还包括这个函数的定义环境。上面例子中,当函数generateClosure()的内部函数get被一个外部变量counter引用时,counter和generateClosure()的局部变量就是一个闭包。如果还不够清晰,下面这个例子可以帮助你理解:
上面这个例子解释了闭包是如何产生的:counter1和counter2分别调用了generateClosure()函数,生成了两个闭包的实例,它们内部引用的count变量分别属于各自的运行环境。我们可以理解为,在generateClosure()返回get函数时,私下将get可能引用到的generateClosure()函数的内部变量(也就是count变量)也返回了,并在内存中生成了一个副本,之后generateClosure()返回的函数的两个实例counter1和counter2就是相互独立的了。
A.2.2 闭包的用途
1.嵌套的回调函数
闭包有两个主要用途,一是实现嵌套的回调函数,二是隐藏对象的细节。让我们先看下面这段代码示例,了解嵌套的回调函数。如下代码是在Node.js中使用MongoDB实现一个简单的增加用户的功能:
如果你对Node.js或MongoDB不熟悉,没关系,不需要去理解细节,只要看清楚大概的逻辑即可。这段代码中用到了闭包的层层嵌套,每一层的嵌套都是一个回调函数。回调函数不会立即执行,而是等待相应请求处理完后由请求的函数回调。我们可以看到,在嵌套的每一层中都有对callback的引用,而且最里层还用到了外层定义的uid变量。由于闭包机制的存在,即使外层函数已经执行完毕,其作用域内申请的变量也不会释放,因为里层的函数还有可能引用到这些变量,这样就完美地实现了嵌套的异步回调。
尽管可以这么做,上面这种回调函数深层嵌套的实现并不优美,本书第6章中介绍了控制流优化的方法。
2.实现私有成员
我们知道,JavaScript的对象没有私有属性,也就是说对象的每一个属性都是曝露给外部的。这样可能会有安全隐患,譬如对象的使用者直接修改了某个属性,导致对象内部数据的一致性受到破坏等。JavaScript通过约定在所有私有属性前加上下划线(例如_myPrivateProp),表示这个属性是私有的,外部对象不应该直接读写它。但这只是个非正式的约定,假设对象的使用者不这么做,有没有更严格的机制呢?答案是有的,通过闭包可以实现。让我们再看看前面那个例子:
我们可以看到,只有调用counter()才能访问到闭包内的count变量,并按照规则对其增加1,除此之外决无可能用其他方式找到count变量。受到这个简单例子的启发,我们可以把一个对象用闭包封装起来,只返回一个“访问器”的对象,即可实现对细节隐藏。关于实现JavaScript对象私有成员的更多信息,请参考http://javascript.crockford.com/private.html。
A.3 对象
提起面向对象的程序设计语言,立刻让人想起的是C++、Java等这类静态强类型语言,以及Python、Ruby等脚本语言,它们共有的特点是基于类的面向对象。而说到JavaScript,很少能让人想到它面向对象的特性,甚至有人说它不是面向对象的语言,因为它没有类。没错,JavaScript真的没有类,但JavaScript是面向对象的语言。JavaScript只有对象,对象就是对象,不是类的实例。
因为绝大多数面向对象语言中的对象都是基于类的,所以经常有人混淆类的实例与对象的概念。对象就是类的实例,这在大多数语言中都没错,但在JavaScript中却不适用。JavaScript中的对象是基于原型的,因此很多人在初学JavaScript对象时感到无比困惑。通过这一节,我们将重新认识JavaScript中对象,充分理解基于原型的面向对象的实质。
A.3.1 创建和访问
JavaScript中的对象实际上就是一个由属性组成的关联数组,属性由名称和值组成,值的类型可以是任何数据类型,或者函数和其他对象。注意JavaScript具有函数式编程的特性,所以函数也是一种变量,大多数时候不用与一般的数据类型区分。
在JavaScript中,你可以用以下方法创建一个简单的对象:
以上代码中,我们通过var foo={};创建了一个对象,并将其引用赋值给foo,通过foo.prop1来获取它的成员并赋值,其中{}是对象字面量的表示方法,也可以用var foo=new Object()来显式地创建一个对象。
1.使用关联数组访问对象成员
我们还可以用关联数组的模式来创建对象,以上代码修改为:
在JavaScript中,使用句点运算符和关联数组引用是等价的,也就是说任何对象(包括this指针)都可以使用这两种模式。使用关联数组的好处是,在我们不知道对象的属性名称的时候,可以用变量来作为关联数组的索引。例如:
2.使用对象初始化器创建对象
上述的方法只是让你对JavaScript对象的定义有个了解,真正在使用的时候,我们会采用下面这种更加紧凑明了的方法:
这种定义的方法称为对象的初始化器。注意,使用初始化器时,对象属性名称是否加引号是可选的,除非属性名称中有空格或者其他可能造成歧义的字符,否则没有必要使用引号。
A.3.2 构造函数
前一小节讲述的对象创建方法都有一个弱点,就是创建对象的代码是一次性的。如果我们想创建多个规划好的对象,有若干个固定的属性、方法,并能够初始化,就像C++语言中的对象一样,该如何做呢?别担心,JavaScript提供了构造函数,让我们来看看应该如何创建复杂的对象。
以上是一个简单的构造函数,接下来用new语句来创建对象:
然后就可以通过someuser来访问这个对象的属性和方法了。
A.3.3 上下文对象
在JavaScript中,上下文对象就是this指针,即被调用函数所处的环境。上下文对象的作用是在一个函数内部引用调用它的对象本身,JavaScript的任何函数都是被某个对象调用的,包括全局对象,所以this指针是一个非常重要的东西。
JavaScript中并没有像C++一样的指针概念,这里所谓的this指针只是沿用习惯的说法而已。
在前面使用构造函数的代码中我们已经看到了this的使用方法,下面代码可以更佳清楚地说明上下文对象的使用方式:
JavaScript的函数式编程特性使得函数可以像一般的变量一样赋值、传递和计算,我们看到在上面代码中,foo对象的bar属性是someuser.display函数,使用foo.bar()调用时,bar和foo对象的函数看起来没有区别,其中的this指针不属于某个函数,而是函数调用时所属的对象。
在JavaScript中,本质上,函数类型的变量是指向这个函数实体的一个引用,在引用之间赋值不会对对象产生复制行为。我们可以通过函数的任何一个引用调用这个函数,不同之处仅仅在于上下文。下面例子可以帮助我们理解:
仔细观察上面的例子,使用不同的引用来调用同一个函数时,this指针永远是这个引用所属的对象。在前面的章节中我们提到了JavaScript的函数作用域是静态的,也就是说一个函数的可见范围是在预编译的语法分析中就可以确定的,而上下文对象则可以看作是静态作用域的补充。
1.call和apply
在JavaScript中,call和apply是两个神奇的方法,但同时也是容易令人迷惑的两个方法,乃至许多对JavaScript有经验的人也不太清楚它们的用法。call和apply的功能是以不同的对象作为上下文来调用某个函数。简而言之,就是允许一个对象去调用另一个对象的成员函数。乍一看似乎很不可思议,而且容易引起混乱,但其实JavaScript并没有严格的所谓“成员函数”的概念,函数与对象的所属关系在调用时才展现出来。灵活使用call和apply可以节省不少时间,在后面我们可以看到,call可以用于实现对象的继承。
call和apply的功能是一致的,两者细微的差别在于call以参数表来接受被调用函数的参数,而apply以数组来接受被调用函数的参数。call和apply的语法分别是:
其中,func是函数的引用,thisArg是func被调用时的上下文对象,arg1、arg2或argsArray是传入func的参数。我们以下面一段代码为例介绍call的工作机制:
用Node.js运行这段代码,我们可以看到控制台输出了foobar。someuser.display是被调用的函数,它通过call将上下文改变为foo对象,因此在函数体内访问this.name时,实际上访问的是foo.name,因而输出了foobar。
2.bind
如何改变被调用函数的上下文呢?前面说过,可以用call或apply方法,但如果重复使用会不方便,因为每次都要把上下文对象作为参数传递,而且还会使代码变得不直观。针对这种情况,我们可以使用bind方法来永久地绑定函数的上下文,使其无论被谁调用,上下文都是固定的。bind语法如下:
其中func是待绑定函数,thisArg是改变的上下文对象,arg1、arg2是绑定的参数表。bind方法返回值是上下文为thisArg的func。通过下面例子可以帮你理解bind的使用方法:
上面代码直接将foo.func赋值为someuser.func,调用foo.func()时,this指针为foo,所以输出结果是foobar。foo.func1使用了bind方法,将someuser作为this指针绑定到someuser.func,调用foo.func1()时,this指针为someuser,所以输出结果是byvoid。全局函数func同样使用了bind方法,将foo作为this指针绑定到someuser.func,调用func()时,this指针为foo,所以输出结果是foobar。而func2直接将绑定过的func赋值过来,与func行为完全相同。
3.使用bind绑定参数表
bind方法还有一个重要的功能:绑定参数表,如下例所示。
可以看到,byvoidLoves将this指针绑定到了person,并将第一个参数绑定到loves,之后在调用byvoidLoves的时候,只需传入第三个参数。这个特性可以用于创建一个函数的“捷径”,之后我们可以通过这个“捷径”调用,以便在代码多处调用时省略重复输入相同的参数。
4.理解bind
尽管bind很优美,还是有一些令人迷惑的地方,例如下面的代码:
全局函数func通过someuser.func.bind将this指针绑定到了foo,调用func()输出了foobar。我们试图将func2赋值为已绑定的func重新通过bind将this指针绑定到someuser的结果,而调用func2时却发现输出值仍为foobar,即this指针还是停留在foo对象上,这是为什么呢?要想解释这个现象,我们必须了解bind方法的原理。
让我们看一个bind方法的简化版本(不支持绑定参数表):
假设上面函数是someuser.func的bind方法的实现,函数体内this指向的是someuser.func,因为函数也是对象,所以this.call(self)的作用就是以self作为this指针调用someuser.func。
从上面展开过程我们可以看出,func2实际上是以someuser作为func的this指针调用了func,而func根本没有使用this指针,所以两次bind是没有效果的。
A.3.4 原型
原型是JavaScript面向对象特性中重要的概念,也是大家太熟悉的概念。因为在绝大多数的面向对象语言中,对象是基于类的(例如Java和C++),对象是类实例化的结果。而在JavaScript语言中,没有类的概念【1】,对象由对象实例化。打个比方来说,基于类的语言中类就像一个模具,对象由这个模具浇注产生,而基于原型的语言中,原型就好像是一件艺术品的原件,我们通过一台100%精确的机器把这个原件复制出很多份。
前面小节的例子中都没有涉及原型,仅仅通过构造函数和new语句生成类,让我们看看如何使用原型和构造函数共同生成对象。
上面这段代码使用了原型而不是构造函数初始化对象。这样做与直接在构造函数内定义属性有什么不同呢?
□构造函数内定义的属性继承方式与原型不同,子对象需要显式调用父对象才能继承构造函数内定义的属性。
□构造函数内定义的任何属性,包括函数在内都会被重复创建,同一个构造函数产生的两个对象不共享实例。
□构造函数内定义的函数有运行时闭包的开销,因为构造函数内的局部变量对其中定义的函数来说也是可见的。
下面这段代码可以验证以上问题:
尽管如此,并不是说在构造函数内创建属性不好,而是两者各有适合的范围。那么我们什么时候使用原型,什么时候使用构造函数内定义来创建属性呢?
□除非必须用构造函数闭包,否则尽量用原型定义成员函数,因为这样可以减少开销。
□尽量在构造函数内定义一般成员,尤其是对象或数组,因为用原型定义的成员是多个实例共享的。
接下来,我们介绍一下JavaScript中的原型链机制。
原型链
JavaScript中有两个特殊的对象:Object与Function,它们都是构造函数,用于生成对象。Object.prototype是所有对象的祖先,Function.prototype是所有函数的原型,包括构造函数。我把JavaScript中的对象分为三类,一类是用户创建的对象,一类是构造函数对象,一类是原型对象。用户创建的对象,即一般意义上用new语句显式构造的对象。构造函数对象指的是普通的构造函数,即通过new调用生成普通对象的函数。原型对象特指构造函数prototype属性指向的对象。这三类对象中每一类都有一个proto属性,它指向该对象的原型,从任何对象沿着它开始遍历都可以追溯到Object.prototype。构造函数对象有prototype属性,指向一个原型对象,通过该构造函数创建对象时,被创建对象的proto属性将会指向构造函数的prototype属性。原型对象有constructor属性,指向它对应的构造函数。让我们通过下面这个例子来理解原型:
我们定义了一个叫做Foo ()的构造函数,生成了对象foo。同时我们还分别给Object和Foo生成原型对象。
图A-1解析了它们之间错综复杂的关系。
图A-1 JavaScript原型之间的关系
在JavaScript中,继承是依靠一套叫做原型链(prototype chain)的机制实现的。属性继承的本质就是一个对象可以访问到它的原型链上任何一个原型对象的属性。例如上例的foo对象,它拥有foo. proto和foo. proto.proto所有属性的浅拷贝(只复制基本数据类型,不复制对象)。所以可以直接访问foo.constructor(来自foo. proto,即Foo.prototype),foo.toString(来自foo. proto.proto,即Object.prototype)。
A.3.5 对象的复制
JavaScript和Java一样都没有像C语言中一样的指针,所有对象类型的变量都是指向对象的引用,两个变量之间赋值传递一个对象并不会对这个对象进行复制,而只是传递引用。有些时候我们需要完整地复制一个对象,这该如何做呢?Java语言中有clone方法可以实现对象复制,但JavaScript中没有这样的函数。因此我们需要手动实现这样一个函数,一个简单的做法是复制对象的所有属性:
上面的代码是一个对象浅拷贝(shallow copy)的实现,即只复制基本类型的属性,而共享对象类型的属性。浅拷贝的问题是两个对象共享对象类型的属性,例如上例中likes属性指向的是同一个数组。
实现一个完全的复制,或深拷贝(deep copy)并不是一件容易的事,因为除了基本数据类型,还有多种不同的对象,对象内部还有复杂的结构,因此需要用递归的方式来实现:
上面这个实现看起来很完美,它不仅递归地复制了对象复杂的结构,还实现了函数的深拷贝。这个方法在大多数情况下都很好用,但有一种情况它却无能为力,例如下面的代码:
这段代码的逻辑非常简单,就是两个相互引用的对象。当我们试图使用深拷贝来复制obj1和obj2中的任何一个时,问题就出现了。因为深拷贝的做法是遇到对象就进行递归复制,那么结果只能无限循环下去。对于这种情况,简单的递归已经无法解决,必须设计一套图论算法,分析对象之间的依赖关系,建立一个拓扑结构图,然后分别依次复制每个顶点,并重新构建它们之间的依赖关系。这已经超出了本书的讨论范围,而且在实际的工程操作中几乎不会遇到这种需求,所以我们就不继续讨论了。
注 释
【1】很多时候对象的构造函数会被称为“类”,但实际上并不是严格意义上的类。