8.8. 函数作用域与闭包
如第四章所述,JavaScript函数的函数体在局部作用域中执行,局部作用域不同于全局作用域.本章将解释这些内容和相关的作用域问题,包括闭包.[*]
[*] 本章包含超前的内容,如果你是第一次阅读,可以跳过.
8.8.1. 词法作用域(Lexical Scoping)
JavaScript中的函数是基于词法作用域的,而不是动态作用域.这句话的意思是JavaScript中的函数运行在它们被定义的作用域里,而不是它们被执行的作用域里.定义一个函数时,当前作用域链被保存起来并成为该函数内部状态的一部分.作用域链的顶层(最初一层)是由全局对象构成的,这和词法作用域没什么明显的关联.然而,当你定义一个嵌套函数时,作用域链将包含外层函数(嵌套函数的外层函数.原文:the containing function).这就意味着,被嵌套的函数可以访问外层函数的所有参数和局部变量.
注意:尽管在一个函数定义的时候,作用域链就已经固定了,但是作用域链中定义的属性并不是固定的.作用域链是"活的"("live"),当函数被调用的时候,它有权访问任何当前被关联的数据.
8.8.2. 调用对象(The Call Object)
当JavaScript解释器调用函数的时候,首先,它把作用域设置到作用域链,在函数被定义的时候,该作用域链已经有效.接下来,解释器添加一个叫做调用对象(ECMAScript规范使用术语:activation object,活动对象)的对象到作用域链的头部.引用Arguments对象的arguments属性为函数初始化调用对象.接下来,添加函数的命名参数到调用对象.所有用var语句定义的局部变量也都在这个对象中定义.因为调用对象在作用域链的头部,局部变量,函数参数和参数对象都在函数的作用域内.也就是说它们隐藏了所有同名的在更早的作用域中定义的属性.
注意:与arguments不同,this是关键字,而不是调用对象的一个属性.
8.8.3. 调用对象作为命名空间(The Call Object as a Namespace)
有时,用定义一个简单函数的方法创建一个调用对象是很有用的,这个调用对象可以扮演一个临时命名空间的角色,如此一来,你定义的变量和创建的属性都不会破坏全局命名空间.例如:假设你有一个Javascrip代码文件,你希望把它用到很多不同的Javascript程序中(或者,用于客户端Javascript,在很多不同的web前端网页上).假设这些代码像其它代码一样定义了中间变量来保存计算结果.现在的问题是因为这些代码将用于很多不同的程序,你无法知道此变量是否和其它引入该文件的程序的变量相冲突.
解决的方法是把代码放到函数里,然后调用这个函数.如此一来,变量是被定义在函数的调用对象中:
// 代码从这里开始
// 任何变量声明都会成为调用对象的属性
// 如此不会破坏全局命名空间.
}
init(); // 不要忘了调用这个函数哦!
这段代码只给全局命名空间添加了一个"init"属性,该属性引用init函数.如果定义一个函数还嫌太多,那么你可以用一个表达式定义和调用一个匿名函数.像这样的JavaScript语法如下:
// 代码从这里开始
// 任何变量声明都会成为调用对象的属性
// 如此不会破坏全局命名空间.
} )(); // 结束函数直接量,并调用该函数.
注意:函数直接量外面的括号是JavaScript语法所必需的.
8.8.4. 嵌套函数作为闭包(Nested Functions as Closures)
JavaScript允许函数嵌套,允许把函数作为数据,允许使用词法作用域,把这些结合使用能创造出功能强大的令人惊奇的效果.让我们开始探索,考虑一下函数g被定义在函数f中.当f被调用的时候,作用域链由为函数f调用生成的调用对象跟随在全局对象之后构成.g函数被定义在f函数里,因此,这个作用域链作为g函数定义的一部分被保存起来.当g函数被调用的时候,作用域链包括三个部分:g函数自己的调用对象,f函数的调用对象和全局对象.
嵌套函数在相同的它们被定义的词法作用域里被调用的时候是很容易理解的.例如,下面的代码并没有什么特别:
function f() {
var x = " local " ;
function g() { alert(x); }
g();
}
f(); // 调用这个函数显示 "local"
然而,在JavaScript中,函数可以像其它值一样作为数据,因此可以在函数中返回一个函数,赋值给对象的属性,存储在数组中等等.这也没有什么特别的,除了嵌套的函数被调用的时候.考虑下面的代码,它包含一个返回嵌套函数的函数.每次被调用的时候,它都返回一个函数.被返回的函数的JavaScript代码总是相同的,但是,因为每次调用外层函数时的参数不同,每次被调用的时候,它(被返回的嵌套函数)创建的作用域也有些许不同.(也就是说,对于外层函数的每次调用,都会在作用域链中产生一个不同的调用对象.)如果你把返回函数保存在数组中,然后每一个调用一次,你将发现每一个函数都返回不同的值.因为每一个函数都由相同的JavaScript代码构成,并且每一次都是从相同的作用域中调用,所以,唯一能造成返回值不同的因素就是函数被定义的作用域:
// 函数被定义的作用域在每次调用时都不同
function makefunc(x) {
return function () { return x; }
}
// 调用几次 makefunc() , 把结果保存到数组中:
var a = [makefunc( 0 ), makefunc( 1 ), makefunc( 2 )];
// 现在调用这些函数并显示结果.
// 尽管函数体是相同的,但是作用域是不同的,所以每次调用返回不同的结果:
alert(a[ 0 ]()); // Displays 0
alert(a[ 1 ]()); // Displays 1
alert(a[ 2 ]()); // Displays 2
这段代码的结果是正确的,是根据词法作用域规则的严谨的应用所期待的:函数被执行在它被定义的作用域内.然而,这些结果令人吃惊的原因是,你期待的局部作用域在定义它们的函数退出的时候就不存在了.事实上,这是正常现象.当函数被调用的时候,解释器创建一个调用对象并把它放到作用域链的头部.当函数退出的时候,解释器从作用域链上删除这个调用对象.在没有嵌套函数被定义的时候,调用对象是唯一引用作用域链的对象.当调用对象从作用域链上删除时,就再也没有对它的引用了,它将被GC(garbage collected)回收.
但是,嵌套函数改变了这些.如果嵌套函数被创建,这个函数的定义引用调用对象,因为这个调用对象是函数被定义的作用域链的顶部.如果嵌套函数只是被外层函数使用,对嵌套函数的唯一引用在调用对象里.当外层函数返回时,只有嵌套函数引用调用对象,调用对象引用嵌套函数,除此之外,再也没有其它的什么引用任何一个,因此,这两个对象就只能被GC使用了.
如果你保存了一个嵌套函数的引用到全局作用域,情况就有所不同了.你把嵌套函数作为外层函数的返回值,或者把嵌套函数保存为其它对象的属性.在这种情况下,就有了一个对嵌套函数的外部引用,所以,嵌套函数在它的外部函数的调用对象中保持着它的引用.结果是,为外层函数调用生成的调用对象仍然有效,外层函数的参数和变量的名字和值也保留在这个调用对象里.JavaScript代码无法直接访问调用对象,但是,它定义的作为作用域链的一部分的属性仍用于嵌套函数的任何调用.(注意:如果外层函数保存了两个嵌套函数的全局引用,那么就有两个嵌套函数共享同一个调用对象,通过调用一个函数对调用对象的改变对另一个嵌套函数是可见的)
JavaScript函数是被执行的代码和执行它们的作用域的组合.这个代码和作用域的组合在计算机科学著作中被称作:闭包(closure).所有的JavaScript函数都是闭包.然而,这些闭包只在象上面讨论的那样时才有趣:当一个嵌套的函数被输出到它被定义的作用域之外.只有嵌套函数被如此使用时,才被明确的称为闭包.
闭包是有趣并且功能强大的技术.尽管它们不会被普通的使用在日常JavaScript编程中,它仍然值得我们去理解.如果你理解闭包,你理解作用域链和函数调用对象,那么,你才能真正的称自己为高级JavaScript程序员(JSer :) ).
8.8.4.1. 闭包的例子(Closure examples)
有时,你会想写一个函数,希望它能跨调用保存一个值.这个值不能保存在局部变量里,因为调用对象不会跨调用存在.全局变量是可以的,但是它会破坏全局命名空间.在8.6.3.章节中,我展现了一个名为uniqueInteger()的函数,它用一个属性保存这个恒久的值.你可以用闭包更进一步实现,创建一个恒久的私有的变量.下面是不用闭包写的一个函数:
uniqueID = function () {
if ( ! arguments.callee.id) arguments.callee.id = 0 ;
return arguments.callee.id ++ ;
} ;
这种方法的问题在于任何人都能设置这个uniqueID.id为0,而破坏了该函数不能返回同一个值两次的约定.你可以通过保存这个恒久值到一个只有你自己的函数有权访问的闭包里的方法来防止别人设置:
var id = 0 ; // 这是私有恒久的那个值
// 外层函数返回一个有权访问恒久值的嵌套的函数
// 那就是我们保存在变量uniqueID里的嵌套函数.
return function () { return id ++ ; } ; // 返回,自加.
} )(); // 在定义后调用外层函数.
例子8-6是第二个闭包的例子.它示范的是像第一个一样的私有恒久变量,但是这个能被多个函数共享.
Example 8-6. Private properties with closures
// 方法名为:get<name>和set<name>.
// 如果提供了一个判断函数,setter方法将在保存前判断参数是不是有效的
// 如果检验失败,setter方法抛出一个异常
// 这个函数的与众不同之处在于,用getter和setter方法操作的属性值并不是存储在对象o里面,
// 相反的,值被存储在函数的局部变量里.
// getter和setter方法也被定义为函数的局部方法,因此有权访问这个局部变量.
// 注意:对于两个访问方法,该值是私有的,除了setter方法,无法修改或设置它.
function makeProperty(o, name, predicate) {
var value; // This is the property value
// getter方法只是简单的返回值.
o[ " get " + name] = function () { return value; } ;
// setter保存值,如果校验失败则抛出异常
o[ " set " + name] = function (v) {
if (predicate && ! predicate(v))
throw " set " + name + " : invalid value " + v;
else
value = v;
} ;
}
// 下面的代码演示makeProperty() 方法.
var o = {} ; // 这是一个空对象
// 添加属性访问方法getName() 和 setName()
// 确保只允许字符串值
makeProperty(o, " Name " , function (x) { return typeof x == " string " ; } );
o.setName( " Frank " ); // 设置属性值
print(o.getName()); // 获得属性值
o.setName( 0 ); // 试图设置错误类型的值
我知道的最简单最有用的使用闭包的例子是Steve Yen创建的断点程序,它发布在 http://trimpath.com ,是TrimPath客户端框架的一部分.断点是函数内的一个点,代码执行到该点停止,给程序员检查变量,表达式,调用函数等的值的机会.Steve的断点技术用闭包捕捉函数的当前作用域(包括局部变量和函数参数),用全局的eval()函数组合这些就可以检查作用域了.eval()函数计算JavaScript代码字符串并返回结果.下面是一个以自检闭包方式工作的嵌套函数.
// 捕捉当前作用域,可以用eval()检查
var inspector = function($) { return eval($); }
这个函数用了很少见的标识符$作为参数名,这样可以减少在计划检查的作用域内命名冲突的可能性.
(接下来部分代码与所述内容无关,译略)
8.8.4.2. 闭包和IE中的内存泄露(Closures and memory leaks in Internet Explorer)
MS的IE浏览器在ActiveX对象和客户端DOM元素的GC方面表现较弱.客户端对象按引用计数,当引用数为0的时候释放对象.这种方法在循环引用的时候就失效了,例如,当一个核心JavaScript对象引用一个文档元素,而那个文档元素又有一个属性(比如是一个事件句柄)引用该核心JavaScript对象.
在IE客户端编程使用闭包的时候,这种循环引用经常出现.当你使用闭包的时候,记住,封闭(enclosing)函数的调用对象,包括函数所有的参数和局部变量,都将和闭包一样"长寿".如果任何函数参数或者局部变量引用了一个客户端对象,就会发生内存泄露.
关于这个问题的完整讨论超出本书范围,详情请参见:
http://msdn.microsoft.com/library/en-us/IETechCol/dnwebgen/ie_leak_patterns.asp