揭秘命名函数表达式

前言

网上还没用发现有人对命名函数表达式进去重复深入的讨论,正因为如此,网上出现了各种各样的误解,本文将从原理和实践两个方面来探讨JavaScript关于命名函数表达式的优缺点。
简单的说,命名函数表达式只有一个用户,那就是在==Debug==或者==Profiler==分析的时候来描述函数的名称,也可以使用函数名实现递归,但很快你就会发现其实是不切实际的。当然,如果你不关注调试,那就没什么可担心的了,否则,如果你想了解兼容性方面的东西的话,你还是应该继续往下看看。
我们先开始看看,什么叫函数表达式,然后再说一下现代调试器如何处理这些表达式,如果你已经对这方面很熟悉的话,请直接跳过此小节。
本文中后半部分说了好多JScript,基本上是过时的东西,我觉得直接略过就行

函数表达式和函数声明

ECMAScript中,创建函数的最常用的两个方法是函数表达式函数声明,两者期间的区别是有点晕,因为ECMAScript规范只明确了一点:函数声明必须带有标示符(==Identifier==)(就是大家常说的函数名称),而函数表达式则可以省略这个标示符:

  1. 函数声明:
    1
    function 函数名称 (参数:可选){ 函数体 }
  2. 函数表达式:
    1
    function 函数名称(可选)(参数:可选){ 函数体 }

所以,可以看出,如果不声明函数名称,它肯定是表达式,可如果声明了函数名称的话,如何判断是函数声明还是函数表达式呢?==ECMAScript==是通过上下文来区分的,如果function foo(){}是作为赋值表达式的一部分的话,那它就是一个函数表达式,如果function foo(){}被包含在一个函数体内,或者位于程序的最顶部的话,那它就是一个函数声明。

1
2
3
4
5
6
7
8
9
function foo(){} // 声明,因为它是程序的一部分

var bar = function foo(){}; // 表达式,因为它是赋值表达式的一部分

new function bar(){}; // 表达式,因为它是new表达式

(function(){
function bar(){} // 声明,因为它是函数体的一部分
})();

还有一种函数表达式不太常见,就是被括号括住的(function foo(){}),他是表达式的原因是因为括号 ()是一个分组操作符,它的内部只能包含表达式,我们来看几个例子:

1
2
3
4
5
6
7
8
9
function foo(){} // 函数声明

(function foo(){}); // 函数表达式:包含在分组操作符内

try {
(var x = 5); // 分组操作符,只能包含表达式而不能包含语句:这里的var就是语句
} catch(err) {
// SyntaxError
}

你可以会想到,在使用eval对JSON进行执行的时候,JSON字符串通常被包含在一个圆括号里:eval('(' + json + ')'),这样做的原因就是因为分组操作符,也就是这对括号,会让解析器强制将JSON的花括号解析成表达式而不是代码块。

1
2
3
4
5
6
7
try {
{ "x": 5 }; // "{" 和 "}" 做解析成代码块
} catch(err) {
// SyntaxError
}

({ "x": 5 }); // 分组操作符强制将"{" 和 "}"作为对象字面量来解析

表达式和声明存在着十分微妙的差别,首先,函数声明会在任何表达式被解析和求值之前先被解析和求值,即使你的声明在代码的最后一行,它也会在同作用域内第一个表达式之前被解析/求值,参考如下例子,函数fn是在alert之后声明的,但是在alert执行的时候,fn已经有定义了:

1
2
3
4
5
alert(fn());

function fn() {
return 'Hello world!';
}

另外,还有一点需要提醒一下,函数声明在条件语句内虽然可以用,但是没有被标准化,也就是说不同的环境可能有不同的执行结果,所以这样情况下,最好使用函数表达式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 千万别这样做!
// 因为有的浏览器会返回first的这个function,而有的浏览器返回的却是第二个

if (true) {
function foo() {
return 'first';
}
}else {
function foo() {
return 'second';
}
}
foo();

// 相反,这样情况,我们要用函数表达式
var foo;
if (true) {
foo = function() {
return 'first';
};
}else {
foo = function() {
return 'second';
};
}
foo();

函数声明的实际规则如下:

函数声明只能出现在程序或函数体内。从句法上讲,它们 不能出现在Block(块)({ … })中,例如不能出现在 if、while 或 for 语句中。因为 Block(块) 中只能包含Statement语句, 而不能包含函数声明这样的源元素。另一方面,仔细看一看规则也会发现,唯一可能让表达式出现在Block(块)中情形,就是让它作为表达式语句的一部分。但是,规范明确规定了表达式语句不能以关键字function开头。而这实际上就是说,函数表达式同样也不能出现在Statement语句或Block(块)中(因为Block(块)就是由Statement语句构成的)。

函数语句

在ECMAScript的语法扩展中,有一个是函数语句,目前只有基于Gecko的浏览器实现了该扩展,所以对于下面的例子,我们仅是抱着学习的目的来看,一般来说不推荐使用(除非你针对Gecko浏览器进行开发)。

  1. 一般语句能用的地方,函数语句也能用,当然也包括Block块中:

    1
    2
    3
    4
    5
    if (true) {
    function f(){ }
    }else {
    function f(){ }
    }
  2. 函数语句可以像其他语句一样被解析,包含基于条件执行的情形

    1
    2
    3
    4
    5
    6
    7
    8
    if (true) {
    function foo(){ return 1; }
    }else {
    function foo(){ return 2; }
    }
    foo(); // 1
    // 注:其它客户端会将foo解析成函数声明
    // 因此,第二个foo会覆盖第一个,结果返回2,而不是1
  3. 函数语句不是在变量初始化期间声明的,而是在运行时声明的——与函数表达式一样。不过,函数语句的标识符一旦声明能在函数的整个作用域生效了。标识符有效性正是导致函数语句与函数表达式不同的关键所在(下一小节我们将会展示命名函数表达式的具体行为)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 此刻,foo还没用声明
    typeof foo; // "undefined"
    if (true) {
    // 进入这里以后,foo就被声明在整个作用域内了
    function foo(){ return 1; }
    }else {
    // 从来不会走到这里,所以这里的foo也不会被声明
    function foo(){ return 2; }
    }
    typeof foo; // "function"

    不过,我们可以使用下面这样的符合标准的代码来模式上面例子中的函数语句:

    1
    2
    3
    4
    5
    6
    var foo;
    if (true) {
    foo = function foo(){ return 1; };
    }else {
    foo = function foo() { return 2; };
    }
  4. 函数语句和函数声明(或命名函数表达式)的字符串表示类似,也包括标识符:

    1
    2
    3
    4
    if (true) {
    function foo(){ return 1; }
    }
    String(foo); // function foo() { return 1; }
  5. 另外一个,早期基于Gecko的实现(Firefox 3及以前版本)中存在一个bug,即函数语句覆盖函数声明的方式不正确。在这些早期的实现中,函数语句不知何故不能覆盖函数声明:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // 函数声明
    function foo(){ return 1; }
    if (true) {
    // 用函数语句重写
    function foo(){ return 2; }
    }
    foo(); // FF3以下返回1,FF3.5以上返回2
    // 不过,如果前面是函数表达式,则没用问题
    var foo = function(){ return 1; };
    if (true) {
    function foo(){ return 2; }
    }
    foo(); // 所有版本都返回2

    再次强调一点,上面这些例子只是在某些浏览器支持,所以推荐大家不要使用这些,除非你就在特性的浏览器上做开发。

命名函数表达式

函数表达式在实际应用中还是很常见的,在web开发中友个常用的模式是基于对某种特性的测试来伪装函数定义,从而达到性能优化的目的,但由于这种方式都是在同一作用域内,所以基本上一定要用函数表达式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 该代码来自Garrett Smith的APE Javascript library库(http://dhtmlkitchen.com/ape/)
var contains = (function() {
var docEl = document.documentElement;

if (typeof docEl.compareDocumentPosition != 'undefined') {
return function(el, b) {
return (el.compareDocumentPosition(b) & 16) !== 0;
};
}
else if (typeof docEl.contains != 'undefined') {
return function(el, b) {
return el !== b && el.contains(b);
};
}
return function(el, b) {
if (el === b) return false;
while (el != b && (b = b.parentNode) != null);
return el === b;
};
})();

提到命名函数表达式,理所当然,就是它得有名字,前面的例子var bar = function foo(){};就是一个有效的命名函数表达式,但有一点需要记住:这个名字只在新定义的函数作用域内有效,因为规范规定了标示符不能在外围的作用域内有效:

1
2
3
4
5
6
var f = function foo(){
return typeof foo; // foo是在内部作用域内有效
};
// foo在外部用于是不可见的
typeof foo; // "undefined"
f(); // "function"

既然,这么要求,那命名函数表达式到底有啥用啊?为啥要取名?
正如我们开头所说:给它一个名字就是可以让调试过程更方便,因为在调试的时候,如果在调用栈中的每个项都有自己的名字来描述,那么调试过程就太爽了,感受不一样嘛。

调试器中的函数名

如果一个函数有名字,那调试器在调试的时候会将它的名字显示在调用的栈上。有些调试器(Firebug)有时候还会为你们函数取名并显示,让他们和那些应用该函数的便利具有相同的角色,可是通常情况下,这些调试器只安装简单的规则来取名,所以说没有太大价格,我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function foo(){
return bar();
}
function bar(){
return baz();
}
function baz(){
debugger;
}
foo();

// 这里我们使用了3个带名字的函数声明
// 所以当调试器走到debugger语句的时候,Firebug的调用栈上看起来非常清晰明了
// 因为很明白地显示了名称
baz
bar
foo
expr_test.html()

通过查看调用栈的信息,我们可以很明了地知道foo调用了bar, bar又调用了baz(而foo本身有在expr_test.html文档的全局作用域内被调用),不过,还有一个比较爽地方,就是刚才说的Firebug为匿名表达式取名的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function foo(){
return bar();
}
var bar = function(){
return baz();
}
function baz(){
debugger;
}
foo();

// Call stack
baz
bar() //看到了么?
foo
expr_test.html()

然后,当函数表达式稍微复杂一些的时候,调试器就不那么聪明了,我们只能在调用栈中看到问号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function foo(){
return bar();
}
var bar = (function(){
if (window.addEventListener) {
return function(){
return baz();
};
}
else if (window.attachEvent) {
return function() {
return baz();
};
}
})();
function baz(){
debugger;
}
foo();

// Call stack
baz
(?)() // 这里可是问号哦
foo
expr_test.html()

另外,当把函数赋值给多个变量的时候,也会出现令人郁闷的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function foo(){
return baz();
}
var bar = function(){
debugger;
};
var baz = bar;
bar = function() {
alert('spoofed');
};
foo();

// Call stack:
bar()
foo
expr_test.html()

这时候,调用栈显示的是foo调用了bar,但实际上并非如此,之所以有这种问题,是因为baz和另外一个包含alert(‘spoofed’)的函数做了引用交换所导致的。

归根结底,只有给函数表达式取个名字,才是最稳妥的办法,也就是使用命名函数表达式。我们来使用带名字的表达式来重写上面的例子(注意立即调用的表达式块里返回的2个函数的名字都是bar):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function foo(){
return bar();
}
var bar = (function(){
if (window.addEventListener) {
return function bar(){
return baz();
};
}
else if (window.attachEvent) {
return function bar() {
return baz();
};
}
})();
function baz(){
debugger;
}
foo();

// 又再次看到了清晰的调用栈信息了耶!
baz
bar
foo
expr_test.html()

OK,又学了一招吧?不过在高兴之前,我们再看看不同寻常的JScript吧。

#JScript
这一部分讲的全都是JScript而不是Javascript这两个真不是一种东西

netscape开发了在Navigator中使用的LiveScript语言,后改名为JavaScript
Microsoft发行jscript用于internet explorer.
最初的jscript和javascript差异过大,web程序员不得不痛苦的为两种浏览器编写两种脚本。于是诞生了ECMAScript,是一种国际标准化的javascript版本。现在的主流浏览器都支持这种版本。
javascript是一个通用的名称,所有浏览器都认识,而jscript只有IE认识。
其他语言细节上的区别,不是一两下能说完的。编程时最好遵循ECMAscript标准。这样可以保证兼容性。
顺便说一下,javascript原来叫Livescript,后来Sun的java风头正盛的时候netscape就把名字改成javascript。

个人感觉这一段基本上可以忽略了 但为了尊重作者我还是把它整理了一下。

JScript的Bug

比较恶的是,IE的ECMAScript实现JScript严重混淆了命名函数表达式,搞得现很多人都出来反对命名函数表达式,而且即便是最新的一版(IE8中使用的5.8版)仍然存在下列问题。

下面我们就来看看IE在实现中究竟犯了那些错误,俗话说知已知彼,才能百战不殆。我们来看看如下几个例子:

例1:函数表达式的标示符泄露到外部作用域

1
2
var f = function g(){};
typeof g; // "function"

上面我们说过,命名函数表达式的标示符在外部作用域是无效的,但JScript明显是违反了这一规范,上面例子中的标示符g被解析成函数对象,这就乱了套了,很多难以发现的bug都是因为这个原因导致的。
==注:IE9貌似已经修复了这个问题==

例2:将命名函数表达式同时当作函数声明和函数表达式

1
2
typeof g; // "function"
var f = function g(){};

特性环境下,函数声明会优先于任何表达式被解析,上面的例子展示的是JScript实际上是把命名函数表达式当成函数声明了,因为它在实际声明之前就解析了g。

这个例子引出了下一个例子。

例3:命名函数表达式会创建两个截然不同的函数对象!

1
2
3
4
5
var f = function g(){};
f === g; // false

f.expando = 'foo';
g.expando; // undefined

看到这里,大家会觉得问题严重了,因为修改任何一个对象,另外一个没有什么改变,这太恶了。通过这个例子可以发现,创建2个不同的对象,也就是说如果你想修改f的属性中保存某个信息,然后想当然地通过引用相同对象的g的同名属性来使用,那问题就大了,因为根本就不可能。

再来看一个稍微复杂的例子:

例4:仅仅顺序解析函数声明而忽略条件语句块

1
2
3
4
5
6
7
8
9
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函数对象。

你可能会文,将不同的对象和arguments.callee相比较时,有什么样的区别呢?我们来看看:

1
2
3
4
5
6
7
8
var f = function g(){
return [
arguments.callee == f,
arguments.callee == g
];
};
f(); // [true, false]
g(); // [false, true]

可以看到,arguments.callee的引用一直是被调用的函数,实际上这也是好事,稍后会解释。

还有一个有趣的例子,那就是在不包含声明的赋值语句中使用命名函数表达式:

1
2
3
(function(){
f = function f(){};
})();

按照代码的分析,我们原本是想创建一个全局属性f(注意不要和一般的匿名函数混淆了,里面用的是带名字的生命),JScript在这里捣乱了一把,首先他把表达式当成函数声明解析了,所以左边的f被声明为局部变量了(和一般的匿名函数里的声明一样),然后在函数执行的时候,f已经是定义过的了,右边的function f(){}则直接就赋值给局部变量f了,所以f根本就不是全局属性。

了解了JScript这么变态以后,我们就要及时预防这些问题了,首先防范标识符泄漏带外部作用域,其次,应该永远不引用被用作函数名称的标识符;还记得前面例子中那个讨人厌的标识符g吗?——如果我们能够当g不存在,可以避免多少不必要的麻烦哪。因此,关键就在于始终要通过f或者arguments.callee来引用函数。如果你使用了命名函数表达式,那么应该只在调试的时候利用那个名字。最后,还要记住一点,一定要把命名函数表达式声明期间错误创建的函数清理干净

对于,上面最后一点,我们还得再解释一下。

WebKit的displayName

WebKit团队在这个问题采取了有点儿另类的策略。介于匿名和命名函数如此之差的表现力,WebKit引入了一个“特殊的”displayName属性(本质上是一个字符串),如果开发人员为函数的这个属性赋值,则该属性的值将在调试器或性能分析器中被显示在函数“名称”的位置上。Francisco Tolmasky详细地解释了这个策略的原理和实现

ECMAScript-5

在ECMAScript-262第5版引入了严格模式(strict mode)。开启严格模式的实现会禁用语言中的那些不稳定、不可靠和不安全的特性。据说出于安全方面的考虑,arguments.callee属性将在严格模式下被“封杀”。因此,在处于严格模式时,访问arguments.callee会导致TypeError(参见ECMA-262第5版的10.6节)。而我之所以在此提到严格模式,是因为如果在基于第5版标准的实现中无法使用arguments.callee来执行递归操作,那么使用命名函数表达式的可能性就会大大增加。从这个意义上来说,理解命名函数表达式的语义及其bug也就显得更加重要了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 此前,你可能会使用arguments.callee
(function(x) {
if (x <= 1) return 1;
return x * arguments.callee(x - 1);
})(10);

// 但在严格模式下,有可能就要使用命名函数表达式
(function factorial(x) {
if (x <= 1) return 1;
return x * factorial(x - 1);
})(10);

// 要么就退一步,使用没有那么灵活的函数声明
function factorial(x) {
if (x <= 1) return 1;
return x * factorial(x - 1);
}
factorial(10);

致谢

理查德· 康福德(Richard Cornford),是他率先解释了JScript中命名函数表达式所存在的bug。理查德解释了我在这篇文章中提及的大多数bug,所以我强烈建议大家去看看他的解释。我还要感谢Yann-Erwan Perio道格拉斯·克劳克佛德(Douglas Crockford),他们早在2003年就在comp.lang.javascript论坛中提及并讨论NFE问题了

约翰-戴维·道尔顿(John-David Dalton)对“最终解决方案”提出了很好的建议。

托比·兰吉的点子被我用在了“替代方案”中。

盖瑞特·史密斯(Garrett Smith)德米特里·苏斯尼科(Dmitry Soshnikov)对本文的多方面作出了补充和修正。

英文原文:http://kangax.github.com/nfe/

参考译文:连接访问 (SpiderMonkey的怪癖之后的章节参考该文)

关于本文

本文转自TOM大叔深入理解JavaScript系列本文有大量删减,查看原文

【深入理解JavaScript系列】文章,包括了原创,翻译,转载,整理等各类型文章,原文是TOM大叔的一个非常不错的专题,现将其重新整理发布。谢谢大叔。