要成为高级 JavaScript 程序员,就必须理解闭包。

本文结合 ECMA 262 规范详解了闭包的内部工作机制,让 JavaScript 编程人员对闭包的理解从“嵌套的函数”深入到“标识符解析、执行环境和作用域链”等等 JavaScript 对象背后的运行机制当中,真正领会到闭包的实质。

原文链接:JavaScript Closures

翻译: 为之漫笔 http://www.cn-cuckoo.com/2007/08/01/understand-javascript-closures-72.html

可打印版:JavaScript 闭包

目录

简介

返回目录

Closure
所谓“闭包”,指的是一个拥有许多变量和绑定了这些变量的环境的表达式(通常是一个函数),因而这些变量也是该表达式的一部分。

闭包是 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;
/* - 或- */
objectRef["testNumber"] = 5;

在赋值之前,对象中没有“testNumber” 属性,但在赋值后,则创建一个属性。之后的任何赋值语句都不需要再创建这个属性,而只会重新设置它的值:

objectRef.testNumber = 8;
/* - or:- */
objectRef["testNumber"] = 8;

稍后我们会介绍,Javascript 对象都有原型(prototypes)属性,而这些原型本身也是对象,因而也可以带有命名的属性。但是,原型对象命名属性的作用并不体现在赋值阶段。同 样,在将值赋给其命名属性时,如果对象没有该属性则会创建该命名属性,否则会重设该属性的值。

值的读取

返回目录

当读取对象的属性值时,原型对象的作用便体现出来。如果对象的原型中包含属性访问器(property accessor)所使用的属性名,那么该属性的值就会返回:

/* 为命名属性赋值。如果在赋值前对象没有相应的属性,那么赋值后就会得到一个:*/
objectRef.testNumber = 8;
/* 从属性中读取值 */
var val = objectRef.testNumber;
/* 现在, - val - 中保存着刚赋给对象命名属性的值 8*/

而且,由于所有对象都有原型,而原型本身也是对象,所以原型也可能有原型,这样就构成了所谓的原型链。原型链终止于链中原型为 null 的对象。Object 构造函数的默认原型就有一个 null 原型,因此:

var objectRef = new Object(); //创建一个普通的 JavaScript 对象。

创建了一个原型为 Object.prototype 的对象,而该原型自身则拥有一个值为 null 的原型。也就是说, objectRef 的原型链中只包含一个对象-- Object.prototype。但对于下面的代码而言:

/* 创建 - MyObject1 - 类型对象的函数*/
function MyObject1(formalParameter){
/* 给创建的对象添加一个名为 - testNumber - 的属性
并将传递给构造函数的第一个参数指定为该属性的值:*/
this.testNumber = formalParameter;
}
/* 创建 - MyObject2 - 类型对象的函数*/
function MyObject2(formalParameter){
/* 给创建的对象添加一个名为 - testString - 的属性
并将传递给构造函数的第一个参数指定为该属性的值:*/
this.testString = formalParameter;
}

/* 接下来的操作用 MyObject1 类的实例替换了所有与 MyObject2 类的实例相关联的原型。而且,为 MyObject1 构造函数传递了参数 - 8 - ,因而其 - testNumber - 属性被赋予该值:*/
MyObject2.prototype = new MyObject1( 8 );

/* 最后,将一个字符串作为构造函数的第一个参数,创建一个 - MyObject2 - 的实例,并将指向该对象的引用赋给变量 - objectRef - :*/
var objectRef = new MyObject2( “String_Value” );

被变量 objectRef 所引用的 MyObject2 的实例拥有一个原型链。该链中的第一个对象是在创建后被指定给 MyObject2 构造函数的 prototype 属性的 MyObject1 的一个实例。MyObject1 的实例也有一个原型,即与 Object.prototype 所引用的对象对应的默认的 Object 对象的原型。最后, Object.prototype 有一个值为 null 的原型,因此这条原型链到此结束。

当某个属性访问器尝试读取由 objectRef 所引用的对象的属性值时,整个原型链都会被搜索。在下面这种简单的情况下:

var val = objectRef.testString;

因为 objectRef 所引用的 MyObject2 的实例有一个名为“testString”的属性,因此被设置为“String_Value”的该属性的值被赋给了变量 val。但是:

var val = objectRef.testNumber;

则不能从 MyObject2 实例自身中读取到相应的命名属性值,因为该实例没有这个属性。然而,变量 val 的值仍然被设置为 8,而不是未定义--这是因为在该实例中查找相应的命名属性失败后,解释程序会继续检查其原型对象。而该实例的原型对象是 MyObject1 的实例,这个实例有一个名为“testNumber”的属性并且值为 8,所以这个属性访问器最后会取得值 8。而且,虽然 MyObject1MyObject2 都没有定义 toString 方法,但是当属性访问器通过 objectRef 读取 toString 属性的值时:

var val = objectRef.toString;

变量 val 也会被赋予一个函数的引用。这个函数就是在 Object.prototypetoString 属性中所保存的函数。之所以会返回这个函数,是因为发生了搜索 objectRef 原型链的过程。当在作为对象的 objectRef 中发现没有“toString”属性存在时,会搜索其原型对象,而当原型对象中不存在该属性时,则会继续搜索原型的原型。而原型链中最终的原型是 Object.prototype,这个对象确实有一个 toString 方法,因此该方法的引用被返回。

最后:

var val = objectRef.madeUpProperty;

返回 undefined,因为在搜索原型链的过程中,直至 Object.prototype 的原型--null,都没有找到任何对象有名为“madeUpPeoperty”的属性,因此最终返回 undefined

不论是在对象或对象的原型中,读取命名属性值的时候只返回首先找到的属性值。而当为对象的命名属性赋值时,如果对象自身不存在该属性则创建相应的属性。

这意味着,如果执行像 objectRef.testNumber = 3 这样一条赋值语句,那么这个 MyObject2 的实例自身也会创建一个名为“testNumber”的属性,而之后任何读取该命名属性的尝试都将获得相同的新值。这时候,属性访问器不会再进一步搜索原型链,但 MyObject1 实例值为 8 的“testNumber”属性并没有被修改。给 objectRef 对象的赋值只是遮挡了其原型链中相应的属性。

注意:ECMAScript 为 Object 类型定义了一个内部 [[prototype]] 属性。这个属性不能通过脚本直接访问,但在属性访问器解析过程中,则需要用到这个内部 [[prototype]] 属性所引用的对象链--即原型链。可以通过一个公共的 prototype 属性,来对与内部的 [[prototype]] 属性对应的原型对象进行赋值或定义。这两者之间的关系在 ECMA 262(3rd edition)中有详细描述,但超出了本文要讨论的范畴。

标识符解析、执行环境和作用域链

执行环境

返回目录

执行环境是 ECMAScript 规范(ECMA 262 第 3 版)用于定义 ECMAScript 实现必要行为的一个抽象的概念。对如何实现执行环境,规范没有作规定。但由于执行环境中包含引用规范所定义结构的相关属性,因此执行环境中应该保有(甚至 实现)带有属性的对象--即使属性不是公共属性。

所有 JavaScript 代码都是在一个执行环境中被执行的。全局代码(作为内置的JS 文件执行的代码,或者 HTML 页面加载的代码)是在我称之为“全局执行环境”的执行环境中执行的,而对函数的每次调用(
有可能是作为构造函数)同样有关联的执行环境。通过 eval 函数执行的代码也有截然不同的执行环境,但因为 JavaScript 程序员在正常情况下一般不会使用 eval,所以这里不作讨论。有关执行环境的详细说明请参阅 ECMA 262(3rd edition)第 10.2 节。

当调用一个 JavaScript 函数时,该函数就会进入相应的执行环境。如果又调用了另外一个函数(或者递归地调用同一个函数),则又会创建一个新的执行环境,并且在函数调用期间执行过 程都处于该环境中。当调用的函数返回后,执行过程会返回原始执行环境。因而,运行中的 JavaScript 代码就构成了一个执行环境栈。

在创建执行环境的过程中,会按照定义的先后顺序完成一系列操作。首先,在一个函数的执行环境中,会创建一个“活动”对象。活动对象是规范中规定的另 外一种机制。之所以称之为对象,是因为它拥有可访问的命名属性,但是它又不像正常对象那样具有原型(至少没有预定义的原型),而且不能通过 JavaScript 代码直接引用活动对象。

为函数调用创建执行环境的下一步是创建一个 arguments 对象,这是一个类似数组的对象,它以整数索引的数组成员一一对应地保存着调用函数时所传递的参数。这个对象也有 lengthcallee 属性(这两个属性与我们讨论的内容无关,详见规范)。然后,会为活动对象创建一个名为“arguments”的属性,该属性引用前面创建的 arguments对象。

接着,为执行环境分配作用域。作用域由对象列表(链)组成。每个函数对象都有一个内部的 [[scope]] 属性(该属性我们稍后会详细介绍),这个属性也由对象列表(链)组成。指定给一个函数调用执行环境的作用域,由该函数对象的 [[scope]] 属性所引用的对象列表(链)组成,同时,活动对象被添加到该对象列表的顶部(链的前端)。

之后会发生由 ECMA 262 中所谓“可变”对象完成的“变量实例化”的过程。只不过此时使用活动对象作为可变对象(这里很重要,请注意:它们是同一个对象)。此时会将函数的形式参数 创建为可变对象的命名属性,如果调用函数时传递的参数与形式参数一致,则将相应参数的值赋给这些命名属性(否则,会给命名属性赋 undefined 值)。对于定义的内部函数,会以其声明时所用名称为可变对象创建同名属性,而相应的内部函数则被创建为函数对象并指定给该属性。变量实例化的最后一步是将在函数内部声明的所有局部变量创建为可变对象的命名属性。

根据声明的局部变量创建的可变对象的属性在变量实例化过程中会被赋予 undefined 值。在执行函数体内的代码、并计算相应的赋值表达式之前不会对局部变量执行真正的实例化。

事实上,拥有 arguments 属性的活动对象和拥有与函数局部变量对应的命名属性的可变对象是同一个对象。因此,可以将标识符 arguments 作为函数的局部变量来看待。

最后,要为使用 this 关键字而赋值。如果所赋的值引用一个对象,那么前缀以 this 关键字的属性访问器就是引用该对象的属性。如果所赋(内部)值是 null,那么 this 关键字则引用全局对象。

创建全局执行环境的过程会稍有不同,因为它没有参数,所以不需要通过定义的活动对象来引用这些参数。但全局执行环境也需要一个作用域,而它的作用域 链实际上只由一个对象--全局对象--组成。全局执行环境也会有变量实例化的过程,它的内部函数就是涉及大部分 JavaScript 代码的、常规的顶级函数声明。而且,在变量实例化过程中全局对象就是可变对象,这就是为什么全局性声明的函数是全局对象属性的原因。全局性声明的变量同样 如此。

全局执行环境也会使用 this 对象来引用全局对象。

作用域链与 [[scope]]

返回目录

调用函数时创建的执行环境会包含一个作用域链,这个作用域链是通过将该执行环境的活动(可变)对象添加到保存于所调用函数对象的 [[scope]] 属性中的作用域链前端而构成的。所以,理解函数对象内部的 [[scope]] 属性的定义过程至关重要。

在 ECMAScript 中,函数也是对象。函数对象在变量实例化过程中会根据函数声明来创建,或者是在计算函数表达式或调用 Function 构造函数时创建。

通过调用 Function 构造函数创建的函数对象,其内部的 [[scope]] 属性引用的作用域链中始终只包含全局对象。

通过函数声明或函数表达式创建的函数对象,其内部的 [[scope]] 属性引用的则是创建它们的执行环境的作用域链。

在最简单的情况下,比如声明如下全局函数:-

function exampleFunction(formalParameter){
// 函数体内的代码
}

– 当为创建全局执行环境而进行变量实例化时,会根据上面的函数声明创建相应的函数对象。因为全局执行环境的作用域链中只包含全局对象,所以它就给自己创建的、并以名为“exampleFunction”的属性引用的这个函数对象的内部 [[scope]] 属性,赋予了只包含全局对象的作用域链。

当在全局环境中计算函数表达式时,也会发生类似的指定作用域链的过程:-

var exampleFuncRef = function(){
// 函数体代码
}

在这种情况下,不同的是在全局执行环境的变量实例化过程中,会先为全局对象创建一个命名属性。而 在计算赋值语句之前,暂时不会创建函数对象,也不会将该函数对象的引用指定给全局对象的命名属性。但是,最终还是会在全局执行环境中创建这个函数对象(当 计算函数表达式时。译者注),而为这个创建的函数对象的 [[scope]] 属性指定的作用域链中仍然只包含全局对象。内部的函数声明或表达式会导致在包含它们的外部函数的执行环境中创建相应的函数对象,因此这些函数对象的作用域链会稍微复杂一些。在下面的代码中,先定义了一个带有内部函数声明的外部函数,然后调用外部函数:

/* 创建全局变量 - y - 它引用一个对象:- */
var y = {x:5}; // 带有一个属性 - x - 的对象直接量
function exampleFuncWith(){
  var z;
  /* 将全局对象 - y - 引用的对象添加到作用域链的前端:- */
  with(y){
  /* 对函数表达式求值,以创建函数对象并将该函数对象的引用指定给局部变量 - z - :- */
  z = function(){
  ... // 内部函数表达式中的代码;
  }
}
...
}
/* 执行 - exampleFuncWith - 函数:- */

exampleFuncWith();在调用 exampleFuncWith 函数创建的执行环境中包含一个由其活动对象后跟全局对象构成的作用域链。而在执行 with 语句时,又会把全局变量 y 引用的对象添加到这个作用域链的前端。在对其中的函数表达式求值的过程中,所创建函数对象的 [[scope]] 属性与创建它的执行环境的作用域保持一致--即,该属性会引用一个由对象 y 后跟调用外部函数时所创建执行环境的活动对象,后跟全局对象的作用域链。

当与 with 语句相关的语句块执行结束时,执行环境的作用域得以恢复(y 会被移除),但是已经创建的函数对象(z。译者注)的 [[scope]] 属性所引用的作用域链中位于最前面的仍然是对象 y

标识解析(Identifier Resolution)

标识的解析正好跟作用域链相反,ECMA规范把this当作一个关键字而不是标识是不太合理的,因为每次都是在执行上下文中都是依赖this来解析标识而不是通过作用域链。标识的解析从作用域链的第一个元素开始,先检查第一个作用域中是否存在这个标识,因为作用域链上的都是一个个对象,所以这种检查也会覆盖对象的原型链。如果第一个作用域对象上无法找到标识同名的属性名就检查第二个,依次遍历下去直到直到该属性或者作用域链条终结。对已经找到解析的标识进行操作会跟操作对象的属性遵循一样的流程(读/写)。

函数被调用时关联上的执行上下文将会把Activation Object放在作用域链的第一个位置,标识解析也会首先检查Activation Object对象的属性,看是否能在函数参数、内部函数名、local变量中找到对应的名字。

闭包

垃圾回收机制 (Garbage Collection)

返回目录
ECMAScript使用自动垃圾回收机制,规范中并没有定义垃圾回收的细节,而是把具体实现留给了各浏览器厂商,比如有一些实现赋予垃圾回收器非常低的优先级(可能导致内存泄漏)。一般而言默认的原则是一个无法被引用了的对象,即所有对它的引用都变得不可访问的时候,它就变成了一个可回收的对象并且在将来的某个时间会被销毁,它占用的一切资源也可以释放。

垃圾回收典型的触发时机是当一个执行上下文环境退出时,作用域链结构、Activation Object和所有在执行上下文环境中创建的对象,包括函数对象,都将变得不可访问。所以,对垃圾收集器来说它们都可回收。

形成闭包

返回目录
闭包是这样形成的:在Outer函数调用时产生执行上下文环境,Outer函数中存在一个Inner函数,内部函数对象的引用作为返回值退出了Outer函数,被赋给了另外一个对象的某个属性或者某全局变量。例:

function exampleClosureForm(arg1, arg2){
	var localVar = 8;
	function exampleReturned(innerArg){
		return (arg1+arg2)/(innerArg+localVar);
	}
	return exampleReturned;
}
var globalVar = exampleClosuresForm(2,4);

现在对于exampleClosureForm的这次调用创建的执行上下文来说, 内部创建的函数对象无法被垃圾收集器回收,因为它仍然处于可访问状态,可以通过globalVar(5)来触发它。然而现在有更复杂的事情发生了,就是globalVar引用的函数对象自身带有一个作用域链属性,包含着一个Activation Object元素,这个Activation Object是上一次exampleClosureForm被调用时候在它的函数执行上下文中创建的。现在这个Activation Object也无法被垃圾回收器回收因为每次调用globalVar引用的函数,都需要把这个函数对象的作用域链添加到调用函数上下文的作用域链上(Activation Object在函数对象的作用域链上)。

由于globalVar引用了Inner Function对象,导致当初那个Outer Function调用产生的Activation Object关联着上了Inner函数对象的作用域链而陷入一个无法回收的封闭空间里,这就是闭包。并且,这个Activation Object和函数上下文的作用域链上的变量都还保留着当初调用Outer函数时的状态。当globalVar被调用而产生的作用域链解析过程中,Inner函数可以在遍历中找到那些变量就好像它们就是自身的属性一样。这些变量依然可以读/写,哪怕是本次调用产生的函数上下文退出之后。

假设这样调用:globalVar(2)

首先一个新的函数上下文环境会产生,并且随之而来的是一个属于这个函数上下文的Activation Object(我们将称之为ActInner1),它将会被挂载到上下文作用域链的首个位置,所以本次调用形成的作用域链为ActInner -> Outer(之前调用exampleClosuresForm产生) -> global object。而标识的解析会和作用域链相反,所以是全局对象域的属性变量首先识别,之后再是Outer域最后才是ActInner域。

这就是Js如何解析和识别变量,并通过Inner函数来形成闭包的。闭包会保留对一些域变量的引用使得它们无法被回收,仍然处于可读/写状态。直到所有的Inner函数调用都完毕,才能使得Inner函数对象本身可以被回收,那些闭包保留下来的变量占据的内存也才能得意释放。

内部函数也可以含有更内层函数,并且再内层的函数也可以从函数执行上下文中返回来形成一个对自身的闭包,以至于每次嵌套,当前作用域链都会意外获得相对当前的内部函数执行声明代码上下文中的Activation Object。由于这种不确定因素,大家都不太愿意去在编码中使用嵌套函数。

通过闭包可以做什么?

开个玩笑的回答就是闭包可以实现任何需求。有人曾径告诉我闭包使得Javascript可以模仿任何东西,唯一的局限成了去构思我们要如何实现这种模仿。这样说有一点费解,不如来看一些更实际实践化的东西。

例1: 为函数引用设置延时 (setTimeout)

返回目录
一个典型的闭包应用就是通过传递参数改变函数执行的优先级。例如当一个函数做为第一个参数传递给setTimeout函数时。setTimeout创建了一个函数执行的schedule,或者说一个可执行的Js代码串。如果一小块代码希望通过setTimeout执行就需要封装在一个匿名函数里并传递给setTimeout的第一个参数。其它时候都是直接传递一个函数对象的引用,这种函数引用传递就无法携带参数了。尽管如此,如果返回一个嵌套的Inner函数给setTimeout, 则Inner函数声明的Outer函数携带的参数依然可以在setTimeout执行上下文中使用。见一个例子:

function callLater(paramA,paramB,paramC){
	return (function(){
		paramA [paramB] = paramC;
	});
}
var funcRef = callLater(elStyle,”display”,”none”);
hideMenu = setTimeout(funcRef, 500);

例2: 根据对象实例方法关联函数

返回目录
除了setTimeout还有多种情况通过引用使得一个函数在未来的某个时间点执行,此时向执行函数传递参数变得不是那么容易。一个例子是当一个Js对象被设计做为一个DOM元素的封装时,它拥有doOnClick、doMouuseOver、和doMouseOut等方法,当这些方法被调用时由event对象统一触发对应的DOM元素,但是可以存在任意数量的对象实例于不同的DOM元素关联,每一个对象并不知道自身如何被初始化、如何在全局环境中被引用,因为它们不知道全局作用域中哪一个引用指向自身。问题来了,一个event对象处理函数关联上了一个Javascript对象,它如何知道这个对象的哪一个方法将要被调用呢?

下面的例子使用了一个小型通用的闭包模式,关联一个对象和对象事件处理函数、调度event handler来执行对象特定的方法,传递事件对象和元素对象的引用,并返回方法调用的结果(返回值)。

function associateObjectWithEvent(obj,methodName){
	return (function(e){
		e = e||window.event;
		return obj[methodName](e,this);
	});
}
function DhtmlObject(elementId){
	var el = getElementWithId(elementId);
	if(el){
		el.onclick = associateObjectWithEvent(this,”doOnClick”);
		el.onmouseover = associateObjectWithEvent(this,”doMouseOver”);
		el.onmouseout = associateObjectWIthEvent(this,”doMouseOut”);
		//……
	}
}
DhtmlObject.prototype .doOnClick = function(event, element){…};
DhtmlObject.prototype.doMouseOver = function(event, element){…};
DhtmlObject.prototype.doMouseOut = function(event,element){…};

现在所有DhtmlObject实例都可以关联任意它们感兴趣的DOM元素,并不需要知道对元素的操作是如何被代码引入的,也不会影响全局命名空间和与其它对象的冲突。

例 3:包装相关的功能

返回目录

闭包可以用于创建额外的作用域,通过该作用域可以将相关的和具有依赖性的代码组织起来,以便将意外交互的风险降到最低。假设有一个用于构建字符串的 函数,为了避免重复性的连接操作(和创建众多的中间字符串),我们的愿望是使用一个数组按顺序来存储字符串的各个部分,然后再使用 Array.prototype.join 方法(以空字符串作为其参数)输出结果。这个数组将作为输出的缓冲器,但是将数组作为函数的局部变量又会导致在每次调用函数时都重新创建一个新数组,这在每次调用函数时只重新指定数组中的可变内容的情况下并不是必要的。

一种解决方案是将这个数组声明为全局变量,这样就可以重用这个数组,而不必每次都建立新数组。但这个方案的结果是,除了引用函数的全局变量会使用这 个缓冲数组外,还会多出一个全局属性引用数组自身。如此不仅使代码变得不容易管理,而且,如果要在其他地方使用这个数组时,开发者必须要再次定义函数和数 组。这样一来,也使得代码不容易与其他代码整合,因为此时不仅要保证所使用的函数名在全局命名空间中是唯一的,而且还要保证函数所依赖的数组在全局命名空 间中也必须是唯一的。

而通过闭包可以使作为缓冲器的数组与依赖它的函数关联起来(优雅地打包),同时也能够维持在全局命名空间外指定的缓冲数组的属性名,免除了名称冲突和意外交互的危险。

其中的关键技巧在于通过执行一个单行(in-line)函数表达式创建一个额外的执行环境,而将该函数表达式返回的内部函数作为在外部代码中使用的 函数。此时,缓冲数组被定义为函数表达式的一个局部变量。这个函数表达式只需执行一次,而数组也只需创建一次,就可以供依赖它的函数重复使用。

下面的代码定义了一个函数,这个函数用于返回一个 HTML 字符串,其中大部分内容都是常量,但这些常量字符序列中需要穿插一些可变的信息,而可变的信息由调用函数时传递的参数提供。

通过执行单行函数表达式返回一个内部函数,并将返回的函数赋给一个全局变量,因此这个函数也可以称为全局函数。而缓冲数组被定义为外部函数表达式的一个局部变量。它不会暴露在全局命名空间中,而且无论什么时候调用依赖它的函数都不需要重新创建这个数组。

/* 声明一个全局变量 - getImgInPositionedDivHtml -
   并将一次调用一个外部函数表达式返回的内部函数赋给它。
   这个内部函数会返回一个用于表示绝对定位的 DIV 元素
   包围着一个 IMG 元素 的 HTML 字符串,这样一来,
   所有可变的属性值都由调用该函数时的参数提供:
*/
var getImgInPositionedDivHtml = (function(){
    /* 外部函数表达式的局部变量 - buffAr - 保存着缓冲数组。
     这个数组只会被创建一次,生成的数组实例对内部函数而言永远是可用的
     因此,可供每次调用这个内部函数时使用。

    其中的空字符串用作数据占位符,相应的数据
    将由内部函数插入到这个数组中:
    */
    var buffAr = [
        '<div id="',
        '',   //index 1, DIV ID 属性
        '" style="position:absolute;top:',
        '',   //index 3, DIV 顶部位置
        'px;left:',
        '',   //index 5, DIV 左端位置
        'px;width:',
        '',   //index 7, DIV 宽度
        'px;height:',
        '',   //index 9, DIV 高度
        'px;overflow:hidden;"><img src="',
        '',   //index 11, IMG URL
        '" width="',
        '',   //index 13, IMG 宽度
        '" height="',
        '',   //index 15, IMG 高度
        '" alt="',
        '',   //index 17, IMG alt 文本内容
        '"></div>'
    ];
    /* 返回作为对函数表达式求值后结果的内部函数对象。
     这个内部函数就是每次调用执行的函数
	- getImgInPositionedDivHtml( ... ) -
    */
    return (function(url, id, width, height, top, left, altText){
        /* 将不同的参数插入到缓冲数组相应的位置:*/
        buffAr[1] = id;
        buffAr[3] = top;
        buffAr[5] = left;
        buffAr[13] = (buffAr[7] = width);
        buffAr[15] = (buffAr[9] = height);
        buffAr[11] = url;
        buffAr[17] = altText;
        /* 返回通过使用空字符串(相当于将数组元素连接起来)
	连接数组每个元素后形成的字符串:
        */
        return buffAr.join('');
    }); //:内部函数表达式结束。
})();
/*^^- :单行外部函数表达式。*/

如果一个函数依赖于另一(或多)个其他函数,而其他函数又没有必要被其他代码直接调用,那么可以运用相同的技术来包装这些函数,而通过一个公开暴露的函数来调用它们。这样,就将一个复杂的多函数处理过程封装成了一个具有移植性的代码单元。

其他例子

有关闭包的一个可能是最广为人知的应用是 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 事件处理器:

/* 定义一个全局变量,通过下面的函数将它的值
   作为查询字符串的一部分添加到链接的 - href - 中:
*/
var quantaty = 5;
/* 当给这个函数传递一个链接(作为函数中的参数 - linkRef -)时,
   会将一个 onclick 事件处理器指定给该链接,该事件处理器
   将全局变量 - quantaty - 的值作为字符串添加到链接的 - href -
   属性中,然后返回 true 使该链接在单击后定位到由  - href -
   属性包含的查询字符串指定的资源:
*/
function addGlobalQueryOnClick(linkRef){
    /* 如果可以将参数 - linkRef - 通过类型转换为 ture
      (说明它引用了一个对象):
    */
    if(linkRef){
        /* 对一个函数表达式求值,并将对该函数对象的引用
           指定给这个链接元素的 onclick 事件处理器:
        */
        linkRef.onclick = function(){
            /* 这个内部函数表达式将查询字符串
               添加到附加事件处理器的元素的 - href - 属性中:
            */
            this.href += ('?quantaty='+escape(quantaty));
            return true;
        };
    }
}

无论什么时候调用 addGlobalQueryOnClick 函数,都会创建一个新的内部函数(通过赋值构成了闭包)。从效率的角度上看,如果只是调用一两次 addGlobalQueryOnClick 函数并没有什么大的妨碍,但如果频繁使用该函数,就会导致创建许多截然不同的函数对象(每对内部函数表达式求一次值,就会产生一个新的函数对象)。

上面例子中的代码没有关注内部函数在创建它的函数外部可以访问(或者说构成了闭包)这一事实。实际上,同样的效果可以通过另一种方式来完成。即单独 地定义一个用于事件处理器的函数,然后将该函数的引用指定给元素的事件处理属性。这样,只需创建一个函数对象,而所有使用相同事件处理器的元素都可以共享 对这个函数的引用:

/* 定义一个全局变量,通过下面的函数将它的值
   作为查询字符串的一部分添加到链接的 - href - 中:
*/
var quantaty = 5;
/* 当把一个链接(作为函数中的参数 - linkRef -)传递给这个函数时,
   会给这个链接添加一个 onclick 事件处理器,该事件处理器会
   将全局变量  - quantaty - 的值作为查询字符串的一部分添加到
   链接的 - href -  中,然后返回 true,以便单击链接时定位到由
   作为 - href - 属性值的查询字符串所指定的资源:
*/
function addGlobalQueryOnClick(linkRef){
    /* 如果 - linkRef - 参数能够通过类型转换为 true
    (说明它引用了一个对象):
    */
    if(linkRef){
        /* 将一个对全局函数的引用指定给这个链接
           的事件处理属性,使函数成为链接元素的事件处理器:
        */
        linkRef.onclick = forAddQueryOnClick;
    }
}
/* 声明一个全局函数,作为链接元素的事件处理器,
   这个函数将一个全局变量的值作为要添加事件处理器的
   链接元素的  - href - 值的一部分:
*/
function forAddQueryOnClick(){
    this.href += ('?quantaty='+escape(quantaty));
    return true;
}

在上面例子的第一个版本中,内部函数并没有作为闭包发挥应有的作用。在那种情况下,反而是不使用闭包更有效率,因为不用重复创建许多本质上相同的函数对象。

类似地考量同样适用于对象的构造函数。与下面代码中的构造函数框架类似的代码并不罕见:

function ExampleConst(param){
    /* 通过对函数表达式求值创建对象的方法,
      并将求值所得的函数对象的引用赋给要创建对象的属性:
    */
    this.method1 = function(){
        ... // 方法体。
    };
    this.method2 = function(){
        ... // 方法体。
    };
    this.method3 = function(){
        ... // 方法体。
    };
    /* 把构造函数的参数赋给对象的一个属性:*/
    this.publicProp = param;
}

每当通过 new ExampleConst(n) 使用这个构造函数创建一个对象时,都会创建一组新的、作为对象方法的函数对象。因此,创建的对象实例越多,相应的函数对象也就越多。

Douglas Crockford 提出的模仿 JavaScript 对象私有成员的技术,就利用了将对内部函数的引用指定给在构造函数中构造对象的公共属性而形成的闭包。如果对象的方法没有利用在构造函数中形成的闭包,那 么在实例化每个对象时创建的多个函数对象,会使实例化过程变慢,而且将有更多的资源被占用,以满足创建更多函数对象的需要。

这那种情况下,只创建一次函数对象,并把它们指定给构造函数 prototype 的相应属性显然更有效率。这样一来,它们就能被构造函数创建的所有对象共享了:

function ExampleConst(param){
    /* 将构造函数的参数赋给对象的一个属性:*/
    this.publicProp = param;
}
/* 通过对函数表达式求值,并将结果函数对象的引用
      指定给构造函数原型的相应属性来创建对象的方法:
*/
ExampleConst.prototype.method1 = function(){
    ... // 方法体。
};
ExampleConst.prototype.method2 = function(){
    ... // 方法体。
};
ExampleConst.prototype.method3 = function(){
    ... // 方法体。
};

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 中避免此类问题的关键。

comp.lang.javascript FAQ notes T.O.C.

  • 撰稿 Richard Cornford,2004 年 3 月
  • 修改建议来自:
    • Martin Honnen.
    • Yann-Erwan Perio (Yep).
    • Lasse Reichstein Nielsen. (definition of closure)
    • Mike Scirocco.
    • Dr John Stockton.

本文转自:为之漫笔 <理解JavaScript闭包>http://www.cn-cuckoo.com/2007/08/01/understand-javascript-closures-72.html