【译】节选--揭秘命名函数表达式(Named function expressions )

本文探讨了JavaScript中的命名函数表达式,澄清了一些常见的误解。介绍了函数表达式与声明的区别,强调了命名函数表达式在调试工具中的重要性。文章还揭示了JScript中的几个bug,包括函数表达式标识符泄漏、解析顺序问题和函数对象不一致性等,提醒开发者需要注意这些跨浏览器的问题。
摘要由CSDN通过智能技术生成

简介

令人惊讶的是,在网上,关于命名函数表达式的讨论似乎并不多。这可能因为有很多误解在流传。在本文中,我会试着从理论和实践两个方面总结这些精彩的Javascript构念,包括其中好的、坏的以及“丑陋”的部分。

简单说,命名函数表达式只对一种东西有用——调试工具(debugger)和分析器(profiler)中的描述性函数名。在递归时也可能用到函数名,但你很快会发现这种用法在当今往往不怎么实用。如果你不关心调试代码的体验,那就不用操心;不然的话,就往下读,你会看到一些你必须处理的跨浏览器问题,以及关于如何处理它们的建议。

I’ll start with a general explanation of what function expressions are how modern debuggers handle them. 请随意跳到最终方案,该方案解释了如何安全使用这些构念。

函数表达式和函数声明

ECMAScript中有两种最常见的方式可以创建function对象:函数声明和函数表达式。二者的区别确实很让人迷惘,至少对我来说是这样的。ECMA规范唯一明确的就是,函数声明必须带有一个标识符(Identifier),或者说函数名,而函数表达式则可以省略它:

FunctionDeclaration : function Identifier ( FormalParameterListopt ){ FunctionBody }
FunctionExpression : function Identifieropt ( FormalParameterListopt ){ FunctionBody }

我们可以看到,当标识符被省略,那段代码就是表达式了。但如果标识符存在呢?怎么能分清它是一个表达式还是一个声明?它们看起来一模一样。ECMAScript似乎是通过上下文来区分它们。如果function foo(){}是一个赋值表达式的一部分,那它就是一个函数表达式。相反地,如果function foo(){}被包含在一个函数体内,或在一个程序(顶层)本身中,那它就是一个函数表达式。

function foo(){} //函数声明
(function foo(){}); //函数表达式:因为被分组操作符括号包围

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

你可能会想到在用eval计算JSON时,字符串通常被括号包围——eval('(' + json + ')')。这当然也是出于相同原因——分组操作符,圆括号强制把JSON括号解析为表达式,而不是一个代码块。(原文:grouping operator, which parenthesis are, forces JSON brackets to be parsed as expression rather than as a block):

try {{ "x": 5 }; // "{"和"}"解析为代码块
} catch(err) {// SyntaxError
}

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

声明和表达式的行为有个微妙的不同

首先,即使声明位于源代码的最后,函数声明仍然要比作用域中其他表达式先被解析和计算。下面例子示范了fn函数在alert执行时就已经被定义了,即使它在alert后面:

alert(fn());

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

函数声明的另一个重要特性就是,根据条件声明函数是不符合标准的,并且在不同环境中表现不同。绝对不要使用根据条件声明的函数,而应该使用函数表达式。

// 千万别这么写!
//有些浏览器会声明“foo”为返回“first”的那个,
// 另一些则会声明为返回“second”的那个

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(); 

如果你对函数声明的实际生成规则好奇的话,就往下看。否则可以跳过下面的摘录。

函数声明只允许出现在程序或另一个函数体中。按照语法,它们不能出现在代码块中({..})——例如if,while或for语句。因为代码块只能包含语句,不能包含SourceElement,也就是函数声明。如果仔细观察生成规则,就能发现,只有当表达式是表达式语句(ExpressionStatement)的一部分时,它才被允许直接包含在代码块中。然而,表达式语句明确定义了 不能以“function”开头,这就是为何函数声明不能直接出现在语句或代码块中(记住,代码块也只是一系列语句)。
因为这些限制,不管是函数声明还是函数表达式,只要直接出现在代码块中(如上例),就会被认为是一个语法错误(syntax error)。问题是,我见到的几乎所有实现都没有严格遵从该规则( BESENDMDScript是例外)。他们用专有方式来解释(原文:They interpret them in proprietary ways instead)。

值得一提的是,按照规范,实现(implementations)允许引入语法扩展(见第十六章),但仍然完全一致。这正是现如今这么多客户端存在的情况。Some of them interpret function declarations in blocks as any other function declarations —只是为了把函数声明提升到作用域顶端;另一些引入不同的语法,遵循稍微复杂的规则。

函数语句

其中一个语法扩展就是函数语句,目前在基于Gecko的浏览器中实现(测试于Mac OS X中的Firefox 1-3.7a1pre)。不知为何,无论好的坏的方面,这个扩展似乎并不广为人知(MDC提及了该扩展,但很简单)。请记住,我们在此仅以学习为目的讨论,满足我们的好奇心;除非你正在写针对基于Gecko的环境的脚本,否则我不推荐依赖该扩展。

所以,这些非标准的构念有这些特性:

1.在任何允许使用纯语句的地方,都可以使用函数语句。这也当然包括代码块:if (true) {function f(){ }}else {function f(){ }} 2.函数语句像任何其他语句一样解析,包括条件执行:if (true) {function foo(){ return 1; }}else {function foo(){ return 2; }}foo(); // 1// 注意,其他环境把这里的“foo”解读为函数声明,//第二个“foo”覆写了第一个, 并产生结果"2",而不是“1” 3.函数声明并不在变量实例化的时候被声明。它们被声明于运行时,就像函数表达式一样。然而,一旦声明了,函数语句的标识符在函数作用域内就可用了。该标识符的可用性使得函数语句区别于函数表达式(你会在下一章看到命名函数表达式的确切行为)。//此时,“foo”还没有被声明typeof foo; // "undefined"if (true) {// 一旦进入代码块,“foo”就变成被声明状态,//在整个作用域内可用function foo(){ return 1; }}else {// 没进入这个代码块,//这里的“foo”永远不会被声明function foo(){ return 2; }}typeof foo; // "function" 通常来说,我们可以根据之前的例子,用标准代码模拟函数语句行为:var foo;if (true) {foo = function foo(){ return 1; };}else {foo = function foo() { return 2; };} 4.函数语句的字符串表示与函数声明以及命名函数表达式类似(在本例中包括“foo”标识符):if (true) {function foo(){ return 1; }}String(foo); // function foo() { return 1; } 5.最终,在早期(低于FireFox 3)基于Gecko的实现中出现了一个bug,那就无法用函数语句覆写函数声明://函数声明function foo(){ return 1; }if (true) {//用函数语句覆写function foo(){ return 2; }}foo(); // 低于FF 3的结果是1,FF 3.5及更高版本是2// 然而,覆写函数表达式就不会这样var foo = function(){ return 1; };if (true) {function foo(){ return 2; }}foo(); // 在所有版本中结果都是2 注意,旧版Safari(至少1.2.3, 2.0到2.0.4以及3.0.4,更早版本也可能)中,执行函数语句的方式与SpiderMonkey相同。本章所有例子,除了最后一个“bug”例子,在这些版本的Safari中产生与Firefox相同的结果。另一个遵循相同语法的浏览器就是黑莓浏览器(8230机型起,9000和9350机型)。这种行为的多样性,再次印证了依赖这些扩展是多么糟糕的主意。

命名函数表达式

函数表达式确实常见。web开发中的一个常见模式就是,基于某种功能测试复刻函数定义,以获得最佳实践。这些复刻通常出现在相同作用域,所以总是很有必要使用函数表达式。总之,如目前所知,函数声明不应该按条件执行:

// `contains` is part of "APE Javascript library" (http://dhtmlkitchen.com/ape/) by Garrett Smith
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;};
})(); 

很明显,当一个函数表达式有一个名字(标识符),它就是命名函数表达式(named function expression)了。你在的一个例子中看到的——var bar=function foo(){};——恰恰就是一个命名函数表达式,其名字是foo。一个重要细节需要谨记:它的名字只在新定义的函数的作用域中可用;规范要求一个标识符不该跨作用域使用:

var f = function foo(){return typeof foo; // "foo"在最近的大括号内可用
};
// `foo`在外面无效
typeof foo; // "undefined"
f(); // "function" 

所以命名函数表达式有什么特别吗?为什么我们要给它们命名?

因为命名了的函数能够提升代码调试体验。当我们调试一个程序时,有一个描述性的子项的调用栈非常有用。

调试工具(debugger)中的函数名

当一个函数有一个相关链的标识符,调试工具在检查调用栈时将其作为函数名。某些调试工具(比如Firebug)会帮你显示函数名,即使是匿名函数。不幸的是,这些调试工具通常依赖简单的解析规则;这种抽象通常脆弱,经常产生错误结果。

来看一个简单例子:

function foo(){return bar();
}
function bar(){return baz();
}
function baz(){debugger;
}
foo();
//这里,我们用函数声明定义三个函数
// 当调试工具停在“debugger”语句,
// (firebug中的)调用栈很具有描述性:
baz
bar
foo
expr_test.html() 

可见expr_test.html的全局作用域调用foo,foo调用bar,bar调用baz。Firebug也会匿名函数解析一个名字:

function foo(){return bar();
}
var bar = function(){return baz();
}
function baz(){debugger;
}
foo();

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

但不足之处在于,若一个函数表达式变得非常复杂,调试工具所做的工作就会变得无用;我们以一个闪亮的问号来代替函数名:

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() 

当一个函数被赋值给多个变量,另一个混乱出现了:

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和另一个函数交换了引用——报出“spoofed”的那个。这样的解析——简单情况下很棒——在复杂脚本中无用。

综上,命名函数表达式是获得可靠、健壮调用栈检查的唯一方式。让我们来重写一下之前的例子:

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() 

JScript bug

不幸的是,JScript(IE的ECMAScript实现)彻底搞乱了命名函数表达式。那时候命名函数表达式被很多人反对,JScript要为负责。可悲的是,即使是上一个版本的JScript(5.8,IE 8),仍然保留着每一个下面说到的怪癖。

让我们来看看这个破玩意到底哪里不对劲。理解这些问题能使我们正确处理它们。注意,我把这些差异拆分到不同例子中——清晰起见——即使它们更像是一个主要bug的一系列后果。

例#1: 函数表达式标识符泄漏进封闭作用域

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

记得吗?我提到过,一个命名函数的标识符在封闭作用域中无效。但是,JScript并不认同这点——上面例子中的g解析到了一个函数对象上。这是最广泛观察到的差异。这种污染封闭作用域的行为是危险的——因为作用域可能是全局的。这种bug不容易排查。

例#2: 命名函数表达式被当成是声明和表达式

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

正如我之前解释的,一个特定上下文中的函数声明要比其他表达式先被解析。上面的例子证明了JScript确实把命名函数表达式当作是函数声明。你可以看到,在声明之前可以解析了。

例#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。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值