JScript的bug
令人讨厌的是,JScript(也就是IE的ECMAScript实现)严重混淆了命名函数表达式。JScript搞得现如今很多人都站出来反对命名函数表达式。而且,直到JScript的最近一版——IE8中使用的5.8版——仍然存在下列的所有怪异问题。
下面我们就来看看IE在它的这个“破”实现中到底都搞出了哪些花样。唉,只有知已知彼,才能百战不殆嘛。请注意,为了清晰起见,我会通过一个个相对独立的小例子来说明这些问题,虽然这些问题很可能是一个主bug引起的一连串的后果。
例1:函数表达式的标识符渗透到外部(enclosing)作用域中
var f = function g(){};
typeof g; // "function"
还有人记得吗,我们说过:命名函数表达式的标识符在其外部作用域中是无效的? 好啦,JScript明目张胆地违反了这一规定——上面例子中的标识符g
被解析为函数对象。这是最让人头疼的一个问题了。这样,任何标识符都可能会在不经意间“污染”某个外部作用域——甚至是全局作用域。而且,这种污染常常就是那些难以捕获的bug的来源。
例2:将命名函数表达式同时当作函数声明和函数表达式
typeof g; // "function"
var f = function g(){};
如前所述,在特定的执行环境中,函数声明会先于任何表达式被解析。上面这个例子展示了JScript实际上是把命名函数表达式当作函数声明了;因为它在“实际的”声明之前就解析了g
。
这个例子进而引出了下一个例子:
例3:命名函数表达式会创建两个截然不同的函数对象!
var f = function g(){};
f === g; // false
f.expando = 'foo';
g.expando; // undefined
问题至此就比较严重了。或者可以说修改其中一个对象对另一个丝毫没有影响——这简直就是胡闹!通过例子可以看出,出现两个不同的对象会存在什么风险。假如你想利用缓存机制,在f
的属性中保存某个信息,然后又想当然地认为可以通过引用相同对象的g
的同名属性取得该信息,那么你的麻烦可就大了。
再来看一个稍微复杂点的情况。
例4:只管顺序地解析函数声明而忽略条件语句块
var f = function g() {
return 1;
};
if (false) {
f = function g(){
return 2;
};
}
g(); // 2
要查找这个例子中的bug就要困难一些了。但导致bug的原因却非常简单。首先,g
被当作函数声明解析,而由于JScript中的函数声明不受条件代码块约束(与条件代码块无关),所以在“该死的”if
分支中,g
被当作另一个函数——function g(){ return 2 }
——又被声明了一次。然后,所有“常规的”表达式被求值,而此时f
被赋予了另一个新创建的对象的引用。由于在对表达式求值的时候,永远不会进入“该死的”if
分支,因此f
就会继续引用第一个函数——function g(){ return 1 }
。分析到这里,问题就很清楚了:假如你不够细心,在f
中调用了g
(在执行递归操作的时候会这样做。——译者注),那么实际上将会调用一个毫不相干的g
函数对象(即返回2的那个函数对象。——译者注)。
聪明的读者可能会联想到:在将不同的函数对象与arguments.callee
进行比较时,这个问题会有所表现吗?callee
到底是引用f
还是引用g
呢?下面我们就来看一看:
var f = function g(){
return [
arguments.callee == f,
arguments.callee == g
];
};
f(); // [true, false]
g(); // [false, true]
看到了吧,arguments.callee
引用的始终是被调用的函数。实际上,这应该是件好事儿,原因你一会儿就知道了。
另一个“意外行为”的好玩的例子,当我们在不包含声明的赋值语句中使用命名函数表达式时可以看到。不过,此时函数的名字必须与引用它的标识符相同才行:
(function(){
f = function f(){};
})();
众所周知(但愿如此。——译者注),不包含声明的赋值语句(注意,我们不建议使用,这里只是出于示范需要才用的)在这里会创建一个全局属性f
。而这也是标准实现的行为。可是,JScript的bug在这里又会出点乱子。由于JScript把命名函数表达式当作函数声明来解析(参见前面的“例2”),因此在变量声明阶段,f
会被声明为局部变量。然后,在函数执行时,赋值语句已经不是未声明的了(因为f已经被声明为局部变量了。——译者注),右手边的function f(){}
就会被直接赋给刚刚创建的局部变量f
。而全局作用域中的f
根本不会存在。
看完这个例子后,相信大家就会明白,如果你对JScript的“怪异”行为缺乏了解,你的代码中出现“严重不符合预期”的行为就不难理解了。
明白了JScript的缺陷以后,要采取哪些预防措施就非常清楚了。首先,要注意防范标识符泄漏(渗透)(不让标识符污染外部作用域)。其次,应该永远不引用被用作函数名称的标识符;还记得前面例子中那个讨人厌的标识符g
吗?——如果我们能够当g
不存在,可以避免多少不必要的麻烦哪。因此,关键就在于始终要通过f
或者arguments.callee
来引用函数。如果你使用了命名函数表达式,那么应该只在调试的时候利用那个名字。最后,还要记住一点,一定要把NFE(Named Funciont Expresssions,命名函数表达式)声明期间错误创建的函数清理干净。
嗯,对于上面最后一点,我觉得还要再啰嗦两句:
JScript的内存管理
熟悉上述JScript缺陷之后,再使用这些有毛病的结构,就会发现内存占用方面的潜在问题。下面看一个简单的例子:
var f = (function(){
if (true) {
return function g(){};
}
return function g(){};
})();
我们知道,这里匿名(函数)调用返回的函数——带有标识符g
的函数——被赋值给了外部的f
。我们也知道,命名函数表达式会导致产生多余的函数对象,而该对象与返回的函数对象不是一回事。由于有一个多余的g
函数被“截留”在了返回函数的闭包中,因此内存问题就出现了。这是因为(if语句)内部(的)函数与讨厌的g
是在同一个作用域中被声明的。在这种情况下 ,除非我们显式地断开对(匿名调用返回的)g
函数的引用,否则那个讨厌的家伙会一直占着内存不放。
var f = (function(){
var f, g;
if (true) {
f = function g(){};
}
else {
f = function g(){};
}
// 废掉g,这样它就不会再引用多余的函数了
g = null;
return f;
})();
请注意,这里也明确声明了变量g
,因此赋值语句g = null
就不会在符合标准的客户端(如非JScript实现)中创建全局变量g
了。通过废掉
对g
的引用,垃圾收集器就可以把g
引用的那个隐式创建的函数对象清除了。
在解决JScript NFE内存泄漏问题的过程中,我运行了一系列简单的测试,以便确定废掉
g
能够释放内存。
测试
这里的测试很简单。就是通过命名函数表达式创建10000个函数,把它们保存在一个数组中。过一会儿,看看这些函数到底占用了多少内存。然后,再废掉这些引用并重复这一过程。下面是我使用的一个测试用例:
function createFn(){
return (function(){
var f;
if (true) {
f = function F(){
return 'standard';
}
}
else if (false) {
f = function F(){
return 'alternative';
}
}
else {
f = function F(){
return 'fallback';
}
}
// var F = null;
return f;
})();
}
var arr = [ ];
for (var i=0; i<10000; i++) {
arr[i] = createFn();
}
通过运行在Windows XP SP2中的Process Explorer可以看到如下结果:
IE6:
without `null`: 7.6K -> 20.3K
with `null`: 7.6K -> 18K
IE7:
without `null`: 14K -> 29.7K
with `null`: 14K -> 27K
这个结果大致验证了我的想法——显式地清除多余的引用确实可以释放内存,但释放的内存空间相对不多。在创建10000个函数对象的情况下,大约有3MB左右。对于大型应用程序,以及需要长时间运行或者在低内存设备(如手持设备)上运行的程序而言,这是绝对需要考虑的。但对小型脚本而言,这点差别可能也算不了什么。
有读者可能认为本文到此差不多就该结尾了——实际上还差得远呢 :)。我还想再多谈一点,这些内容涉及的是Safari 2.x。
原创网址:http://www.cn-cuckoo.com/main/wp-content/uploads/2009/12/named-function-expressions-demystified.html#named-expr