原文链接: http://hi.baidu.com/haojian/blog/item/425fcf1326d16127dc54011c.html 本文结合 ECMA 262 规范详解了闭包的内部工作机制,让 JavaScript 编程人员对闭包的理解从“嵌套的函数”深入到“标识符解析、执行环境和作用域链”等等 JavaScript 对象背后的运行机制当中,真正领会到闭包的实质。 原文链接:JavaScript Closures 可打印版:JavaScript 闭包 目录
简介返回目录
闭包是 ECMAScript (JavaScript)最强大的特性之一,但用好闭包的前提是必须理解闭包。闭包的创建相对容易,人们甚至会在不经意间创建闭包,但这些无意创建的闭包却存在潜在的危害,尤其是在比较常见的浏览器环境下。如果想要扬长避短地使用闭包这一特性,则必须了解它们的工作机制。而闭包工作机制的实现很大程度上有赖于标识符(或者说对象属性)解析过程中作用域的角色。 关于闭包,最简单的描述就是 ECMAScript 允许使用内部函数--即函数定义和函数表达式位于另一个函数的函数体内。而且,这些内部函数可以访问它们所在的外部函数中声明的所有局部变量、参数和声明的其他内部函数。当其中一个这样的内部函数在包含它们的外部函数之外被调用时,就会形成闭包。也就是说,内部函数会在外部函数返回后被执行。而当这个内部函数执行时,它仍然必需访问其外部函数的局部变量、参数以及其他内部函数。这些局部变量、参数和函数声明(最初时)的值是外部函数返回时的值,但也会受到内部函数的影响。 遗憾的是,要适当地理解闭包就必须理解闭包背后运行的机制,以及许多相关的技术细节。虽然本文的前半部分并没有涉及 ECMA 262 规范指定的某些算法,但仍然有许多无法回避或简化的内容。对于个别熟悉对象属性名解析的人来说,可以跳过相关的内容,但是除非你对闭包也非常熟悉,否则最好是不要跳下面几节。 对象属性名解析返回目录 ECMAScript 认可两类对象:原生(Native)对象和宿主(Host)对象,其中宿主对象包含一个被称为内置对象的原生对象的子类(ECMA 262 3rd Ed Section 4.3)。原生对象属于语言,而宿主对象由环境提供,比如说可能是文档对象、DOM 等类似的对象。 原生对象具有松散和动态的命名属性(对于某些实现的内置对象子类别而言,动态性是受限的--但这不是太大的问题)。对象的命名属性用于保存值,该值可以是指向另一个对象(Objects)的引用(在这个意义上说,函数也是对象),也可以是一些基本的数据类型,比如:String、Number、Boolean、Null 或 Undefined。其中比较特殊的是 Undefined 类型,因为可以给对象的属性指定一个 Undefined 类型的值,而不会删除对象的相应属性。而且,该属性只是保存着 undefined 值。 下面简要介绍一下如何设置和读取对象的属性值,并最大程度地体现相应的内部细节。 值的赋予返回目录 对象的命名属性可以通过为该命名属性赋值来创建,或重新赋值。即,对于: var objectRef = new Object(); //创建一个普通的 JavaScript 对象。 可以通过下面语句来创建名为 “testNumber” 的属性: objectRef.testNumber = 5; 在赋值之前,对象中没有“testNumber” 属性,但在赋值后,则创建一个属性。之后的任何赋值语句都不需要再创建这个属性,而只会重新设置它的值: objectRef.testNumber = 8; 稍后我们会介绍,Javascript 对象都有原型(prototypes)属性,而这些原型本身也是对象,因而也可以带有命名的属性。但是,原型对象命名属性的作用并不体现在赋值阶段。同样,在将值赋给其命名属性时,如果对象没有该属性则会创建该命名属性,否则会重设该属性的值。 值的读取返回目录 当读取对象的属性值时,原型对象的作用便体现出来。如果对象的原型中包含属性访问器(property accessor)所使用的属性名,那么该属性的值就会返回: /* 为命名属性赋值。如果在赋值前对象没有相应的属性,那么赋值后就会得到一个:*/ /* 从属性中读取值 */ /* 现在, - val - 中保存着刚赋给对象命名属性的值 8*/ 而且,由于所有对象都有原型,而原型本身也是对象,所以原型也可能有原型,这样就构成了所谓的原型链。原型链终止于链中原型为 null 的对象。 var objectRef = new Object(); //创建一个普通的 JavaScript 对象。 创建了一个原型为 /* 创建 - MyObject1 - 类型对象的函数*/ /* 接下来的操作用 MyObject1 类的实例替换了所有与 MyObject2 类的实例相关联的原型。而且,为 MyObject1 构造函数传递了参数 - 8 - ,因而其 - testNumber - 属性被赋予该值:*/ /* 最后,将一个字符串作为构造函数的第一个参数,创建一个 - MyObject2 - 的实例,并将指向该对象的引用赋给变量 - objectRef - :*/ 被变量 当某个属性访问器尝试读取由 var val = objectRef.testString; 因为 var val = objectRef.testNumber; 则不能从 var val = objectRef.toString; 变量 最后: var val = objectRef.madeUpProperty; 返回 不论是在对象或对象的原型中,读取命名属性值的时候只返回首先找到的属性值。而当为对象的命名属性赋值时,如果对象自身不存在该属性则创建相应的属性。 这意味着,如果执行像 注意:ECMAScript 为 Object 类型定义了一个内部 标识符解析、执行环境和作用域链执行环境返回目录 执行环境是 ECMAScript 规范(ECMA 262 第 3 版)用于定义 ECMAScript 实现必要行为的一个抽象的概念。对如何实现执行环境,规范没有作规定。但由于执行环境中包含引用规范所定义结构的相关属性,因此执行环境中应该保有(甚至实现)带有属性的对象--即使属性不是公共属性。 所有 JavaScript 代码都是在一个执行环境中被执行的。全局代码(作为内置的JS 文件执行的代码,或者 当调用一个 JavaScript 函数时,该函数就会进入相应的执行环境。如果又调用了另外一个函数(或者递归地调用同一个函数),则又会创建一个新的执行环境,并且在函数调用期间执行过程都处于该环境中。当调用的函数返回后,执行过程会返回原始执行环境。因而,运行中的 JavaScript 代码就构成了一个执行环境栈。 在创建执行环境的过程中,会按照定义的先后顺序完成一系列操作。首先,在一个函数的执行环境中,会创建一个“活动”对象。活动对象是规范中规定的另外一种机制。之所以称之为对象,是因为它拥有可访问的命名属性,但是它又不像正常对象那样具有原型(至少没有预定义的原型),而且不能通过 JavaScript 代码直接引用活动对象。 为函数调用创建执行环境的下一步是创建一个 接着,为执行环境分配作用域。作用域由对象列表(链)组成。每个函数对象都有一个内部的 之后会发生由 ECMA 262 中所谓“可变”对象完成的“变量实例化”的过程。只不过此时使用活动对象作为可变对象(这里很重要,请注意:它们是同一个对象)。此时会将函数的形式参数创建为可变对象的命名属性,如果调用函数时传递的参数与形式参数一致,则将相应参数的值赋给这些命名属性(否则,会给命名属性赋 根据声明的局部变量创建的可变对象的属性在变量实例化过程中会被赋予 事实上,拥有 最后,要为使用 创建全局执行环境的过程会稍有不同,因为它没有参数,所以不需要通过定义的活动对象来引用这些参数。但全局执行环境也需要一个作用域,而它的作用域链实际上只由一个对象--全局对象--组成。全局执行环境也会有变量实例化的过程,它的内部函数就是涉及大部分 JavaScript 代码的、常规的顶级函数声明。而且,在变量实例化过程中全局对象就是可变对象,这就是为什么全局性声明的函数是全局对象属性的原因。全局性声明的变量同样如此。 全局执行环境也会使用 作用域链与 [[scope]]返回目录 调用函数时创建的执行环境会包含一个作用域链,这个作用域链是通过将该执行环境的活动(可变)对象添加到保存于所调用函数对象的 在 ECMAScript 中,函数也是对象。函数对象在变量实例化过程中会根据函数声明来创建,或者是在计算函数表达式或调用 通过调用 通过函数声明或函数表达式创建的函数对象,其内部的 在最简单的情况下,比如声明如下全局函数:- function exampleFunction(formalParameter){ - 当为创建全局执行环境而进行变量实例化时,会根据上面的函数声明创建相应的函数对象。因为全局执行环境的作用域链中只包含全局对象,所以它就给自己创建的、并以名为“exampleFunction”的属性引用的这个函数对象的内部 当在全局环境中计算函数表达式时,也会发生类似的指定作用域链的过程:- var exampleFuncRef = function(){ 在这种情况下,不同的是在全局执行环境的变量实例化过程中,会先为全局对象创建一个命名属性。而在计算赋值语句之前,暂时不会创建函数对象,也不会将该函数对象的引用指定给全局对象的命名属性。但是,最终还是会在全局执行环境中创建这个函数对象(当计算函数表达式时。译者注),而为这个创建的函数对象的
exampleFuncWith();在调用 当与 例 3:包装相关的功能返回目录 闭包可以用于创建额外的作用域,通过该作用域可以将相关的和具有依赖性的代码组织起来,以便将意外交互的风险降到最低。假设有一个用于构建字符串的函数,为了避免重复性的连接操作(和创建众多的中间字符串),我们的愿望是使用一个数组按顺序来存储字符串的各个部分,然后再使用 一种解决方案是将这个数组声明为全局变量,这样就可以重用这个数组,而不必每次都建立新数组。但这个方案的结果是,除了引用函数的全局变量会使用这个缓冲数组外,还会多出一个全局属性引用数组自身。如此不仅使代码变得不容易管理,而且,如果要在其他地方使用这个数组时,开发者必须要再次定义函数和数组。这样一来,也使得代码不容易与其他代码整合,因为此时不仅要保证所使用的函数名在全局命名空间中是唯一的,而且还要保证函数所依赖的数组在全局命名空间中也必须是唯一的。 而通过闭包可以使作为缓冲器的数组与依赖它的函数关联起来(优雅地打包),同时也能够维持在全局命名空间外指定的缓冲数组的属性名,免除了名称冲突和意外交互的危险。 其中的关键技巧在于通过执行一个单行(in-line)函数表达式创建一个额外的执行环境,而将该函数表达式返回的内部函数作为在外部代码中使用的函数。此时,缓冲数组被定义为函数表达式的一个局部变量。这个函数表达式只需执行一次,而数组也只需创建一次,就可以供依赖它的函数重复使用。 下面的代码定义了一个函数,这个函数用于返回一个 HTML 字符串,其中大部分内容都是常量,但这些常量字符序列中需要穿插一些可变的信息,而可变的信息由调用函数时传递的参数提供。 通过执行单行函数表达式返回一个内部函数,并将返回的函数赋给一个全局变量,因此这个函数也可以称为全局函数。而缓冲数组被定义为外部函数表达式的一个局部变量。它不会暴露在全局命名空间中,而且无论什么时候调用依赖它的函数都不需要重新创建这个数组。
如果一个函数依赖于另一(或多)个其他函数,而其他函数又没有必要被其他代码直接调用,那么可以运用相同的技术来包装这些函数,而通过一个公开暴露的函数来调用它们。这样,就将一个复杂的多函数处理过程封装成了一个具有移植性的代码单元。 其他例子有关闭包的一个可能是最广为人知的应用是 Douglas Crockford’s technique for the emulation of private instance variables in ECMAScript objects。这种应用方式可以扩展到各种嵌套包含的可访问性(或可见性)的作用域结构,包括 the emulation of private static members for ECMAScript objects。 闭包可能的用途是无限的,可能理解其工作原理才是把握如何使用它的最好指南。 意外的闭包返回目录 在创建可访问的内部函数的函数体之外解析该内部函数就会构成闭包。这表明闭包很容易创建,但这样一来可能会导致一种结果,即没有认识到闭包是一种语言特性的 JavaScript 作者,会按照内部函数能完成多种任务的想法来使用内部函数。但他们对使用内部函数的结果并不明了,而且根本意识不到创建了闭包,或者那样做意味着什么。 正如下一节谈到 IE 中内存泄漏问题时所提及的,意外创建的闭包可能导致严重的负面效应,而且也会影响到代码的性能。问题不在于闭包本身,如果能够真正做到谨慎地使用它们,反而会有助于创建高效的代码。换句话说,使用内部函数会影响到效率。 使用内部函数最常见的一种情况就是将其作为 DOM 元素的事件处理器。例如,下面的代码用于向一个链接元素添加 onclick 事件处理器:
无论什么时候调用 上面例子中的代码没有关注内部函数在创建它的函数外部可以访问(或者说构成了闭包)这一事实。实际上,同样的效果可以通过另一种方式来完成。即单独地定义一个用于事件处理器的函数,然后将该函数的引用指定给元素的事件处理属性。这样,只需创建一个函数对象,而所有使用相同事件处理器的元素都可以共享对这个函数的引用:
在上面例子的第一个版本中,内部函数并没有作为闭包发挥应有的作用。在那种情况下,反而是不使用闭包更有效率,因为不用重复创建许多本质上相同的函数对象。 类似地考量同样适用于对象的构造函数。与下面代码中的构造函数框架类似的代码并不罕见:
每当通过 Douglas Crockford 提出的模仿 JavaScript 对象私有成员的技术,就利用了将对内部函数的引用指定给在构造函数中构造对象的公共属性而形成的闭包。如果对象的方法没有利用在构造函数中形成的闭包,那么在实例化每个对象时创建的多个函数对象,会使实例化过程变慢,而且将有更多的资源被占用,以满足创建更多函数对象的需要。 这那种情况下,只创建一次函数对象,并把它们指定给构造函数
Internet Explorer 的内存泄漏问题返回目录 Internet Explorer Web 浏览器(在 IE 4 到 IE 6 中核实)的垃圾收集系统中存在一个问题,即如果 ECMAScript 和某些宿主对象构成了 “循环引用”,那么这些对象将不会被当作垃圾收集。此时所谓的宿主对象指的是任何 DOM 节点(包括 document 对象及其后代元素)和 ActiveX 对象。如果在一个循环引用中包含了一或多个这样的对象,那么这些对象直到浏览器关闭都不会被释放,而它们所占用的内存同样在浏览器关闭之前都不会交回系统重用。 当两个或多个对象以首尾相连的方式相互引用时,就构成了循环引用。比如对象 1 的一个属性引用了对象 2 ,对象 2 的一个属性引用了对象 3,而对象 3 的一个属性又引用了对象 1。对于纯粹的 ECMAScript 对象而言,只要没有其他对象引用对象 1、2、3,也就是说它们只是相互之间的引用,那么仍然会被垃圾收集系统识别并处理。但是,在 Internet Explorer 中,如果循环引用中的任何对象是 DOM 节点或者 ActiveX 对象,垃圾收集系统则不会发现它们之间的循环关系与系统中的其他对象是隔离的并释放它们。最终它们将被保留在内存中,直到浏览器关闭。 闭包非常容易构成循环引用。如果一个构成闭包的函数对象被指定给,比如一个 DOM 节点的事件处理器,而对该节点的引用又被指定给函数对象作用域中的一个活动(或可变)对象,那么就存在一个循环引用。DOM_Node.onevent ->function_object.[[scope]] ->scope_chain ->Activation_object.nodeRef ->DOM_Node。形成这样一个循环引用是轻而易举的,而且稍微浏览一下包含类似循环引用代码的网站(通常会出现在网站的每个页面中),就会消耗大量(甚至全部)系统内存。 多加注意可以避免形成循环引用,而在无法避免时,也可以使用补偿的方法,比如使用 IE 的 onunload 事件来来清空(null)事件处理函数的引用。时刻意识到这个问题并理解闭包的工作机制是在 IE 中避免此类问题的关键。 |