命名函数
介绍
简而言之,命名化的函数表达式只对一个有用——在解析器和调试器中的描述性的函数名。所以,存在着在递归中使用函数名的可能,但是你讲看到这将是不可行的。以下你将看到你将面对的跨浏览器问题和一些解决技巧。
函数表达式VS函数声明
在ECMAScript中常见的创建函数的方式是函数声明和函数表达式。两者之间的区别很让人困惑,至少对我而言是这样的。在ECMA的说明中只是讲清楚了函数声明必须要有一个标识符(如果你喜欢,可以叫做函数名),然而函数表达式可以省略函数名:
函数声明 :
function Identifier ( FormalParameterList opt ){ FunctionBody }函数表达式 :
function Identifier opt ( FormalParameterList opt ){ FunctionBody }
我们可以看到函数名被省略的时候,函数就只能叫做函数表达式。
但是如果函数名存在呢?
怎么判别函数表达式和函数声明呢——他们看起来是那么的相似?
鉴别两个的不同是它们基于的环境。如果一个function foo(){}是一个赋值表达式的一部分,那么它将被视为函数表达式。
如果function foo(){}是在一个函数体中,或者是在(顶级)程序本身,那么它将是函数声明。
function foo(){} // 函数声明,因为它是(顶级)程序的一部分 var bar = function foo(){}; // 函数表达式,因为它是赋值表达式的一部分。 new function bar(){}; // 函数表达式,因为它是new表达式的一部分 (function(){ function bar(){} // 函数声明,因为他是函数体的一部分 })();
一个函数表达式比较不起眼的例子是函数被包含在大括号中,也就是 (function foo(){})。之所以成为函数表达式是因为它的环境 "(" and ")" :组成了一个分组操作符()而分组操作符()只能包含一个表达式:
function foo(){} // 函数声明function declaration (function foo(){}); //函数表达式:归功于分组操作符 function expression: due to grouping operator try { (var x = 5); // 分组操作符里面只能包含函数表达式,而不是一个声明语句(var用来声明) } catch(err) { // SyntaxError }
你可能想到用eval来执行JSON,字符总是包含在括号中——eval('(' + json + ')')。这当然是因为相同个原因——分组操作符,也就是圆括号,强迫JSON左右的方括号被解析成表达式而不是当做一个语句块。
try { { "x": 5 }; // 这里“{”和“}”将被当做语句块 } catch(err) { // SyntaxError } ({ "x": 5 }); //分组操作符强迫“{”和“}”解析成对象字面量
这里有些函数表达式和函数声明的细微差别。
1.函数声明在任何函数表达式之前被解析和计算,即使函数声明书写在程序的最后,它也会优先于任何的函数表达式。
alert(fn()); function fn() { return 'Hello world!'; }
2.另一个重要特性是根据条件语句来选择性的定义不同的函数声明是不符合规范的,并且在不同的浏览器中效果不一样。所以,你永远不要这样做,这样的情况下应该选择函数表达式:
// Never do this! // Some browsers will declare `foo` as the one returning 'first', // while others — returning 'second' if (true) { function foo() { return 'first'; } } else { function foo() { return 'second'; } } foo(); // Instead, use function expressions: var foo; if (true) { foo = function() { return 'first'; }; } else { foo = function() { return 'second'; }; } foo();
函数声明只能出现在函数体和顶级程序中,语法上而言,他们不能出现在语句块中({ ... }
) ——例如if,while亦或for语句中。
因为语句块只能包含Statement,不能包块源对象Element Source(也就是函数声明)。函数表达式在语句块中被允许的唯一解释是函数表达式是表达式语句的一部分。
然而,表达式语句明确的表示不能以“function”关键字开头,所以这就是为什么函数声明不能在Statement和语句块中出现(注意语句块也是一系列的Statement)。
因为这些限制,所以只要函数直接出现在语句块中就被视为语法错误,而不是被视为一个函数声明亦或函数表达式。问题是几乎没有严格按照上面规则执行的浏览器,它们按照自己的规则解析。有些浏览器将在语句块中的函数声明解析成和其他的函数声明一样——进行函数声明提前;其他的浏览器则引入不同的语法并且按照复杂的规格执行。
函数语句
一个ECMAScript的语法扩展就是函数语句,最近被基于Gecko的浏览器实现(在Mac OS X上的Firefox1-3.7上测试)。除非是书写专门针对基于Gecko的环境,不然我不建议使用这种扩展。所以下面展示了一些这些非标准的用于创建函数的语句的特性
- 函数语句可以存在任何语序普通语句存在的地方,包括语句块中:
if (true) { function f(){ } } else { function f(){ } }
- 函数语句被解释成语句,和普通语句被解析成语句机制一样,包括在条件语句的执行中:
if (true) { function foo(){ return 1; } } else { function foo(){ return 2; } } foo(); // 1 //注意在其他的浏览器中将解析‘foo’成为函数声明,重写第一次定义的‘foo’,最终返回的结果是‘2’而不是‘1’
- 函数语句不是在变量实例化的时候被声明,而是在运行的时候被声明,就像函数表达式一样。但是,一旦声明,函数名可以在函数整个作用域中使用。这正是函数语句和函数表达式之间的不同。
// at this point, `foo` is not yet declared typeof foo; // "undefined" if (true) { // once block is entered, `foo` becomes declared and available to the entire scope function foo(){ return 1; } } else { // this block is never entered, and `foo` is never redeclared function foo(){ return 2; } } typeof foo; // "function"
var foo; if (true) { foo = function foo(){ return 1; }; } else { foo = function foo() { return 2; }; }
- 字符串表示的函数语句和函数声明以及命名的函数表达式类似(包括函数名——在这个例子中是‘foo’)
if (true) { function foo(){ return 1; } } String(foo); // function foo() { return 1; }
- 最后,在基于Gecko的执行环境中出现的bug是函数语句将重写函数声明(出现在Firefox版本小于等于3的情况下)早期版本中函数声明却不能重写函数语句。
// function declaration function foo(){ return 1; } if (true) { // overwritting with function statement function foo(){ return 2; } } foo(); // 1 in FF<= 3, 2 in FF3.5 and later // however, this doesn't happen when overwriting function expression var foo = function(){ return 1; }; if (true) { function foo(){ return 2; } } foo(); // 2 in all versions
注意老版本的Safari(至少1.2.3,2.0-2.0.4和3.0.4版本亦或更早的版本)中函数语句的执行遵从SpiderMonkey(js的一种解释引擎)。“函数语句”章节下的所有例子(除了最后关于“bug”的例子)也就是在firefox下执行的例子的结果和这些早期版本的Safari实现的效果一样。另一个符合相同语法的是Blackberry。函数语句在不同浏览器下面的不同表现再次说明了使用函数语句来创建函数是一个很糟糕的想法。
命名的函数表达式
可以经常看到函数表达式。web开发中一个比较常见的模式是基于不同的特性分开来定义函数,来满足最佳性能。这些分开的定义通常发生在同一个作用域中,所以使用函数表达式非常有必要。因为毕竟函数声明不能被有条件的执行(不能出现在if中)。
// `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; }; })();
很明显的是,当函数有函数名的时候,它被叫做命名的函数表达式。就像你在第一个例子中看到的——var bar = function foo(){}:就是一个命名的函数表达式,用foo来当做函数名。需要注意的是函数名只有在函数中才能被使用,不能在函数外使用。
var f = function foo(){ return typeof foo; // "foo" is available in this inner scope }; // `foo` is never visible "outside" typeof foo; // "undefined" f(); // "function"
所以命名函数有什么特殊呢?体现在调试的时候,用描述性的项目来操纵一个调用栈将产生巨大的不同。
在调试器中的函数名
当一个函数有对应的函数名时,在检查调用栈的时候,调试器展现作为函数名的标识符。一些调试器(例如Firebug)甚至会显示匿名函数的函数名——将函数名和函数赋值给的变量同名,不幸的是,这些调试器只是依赖简单地解析法则,所以常常产生错误测结果。
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试图解析函数名即使是一个匿名函数被调用。
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”。
以上的所有原因是命名函数表达式是唯一可以获得一个真正强大的堆栈检查(What it all boils down to is the fact that named function expressions is the only way to get a truly robust stack inspection)。让我们用命名的函数表达式重写上面的代码。注意从自执行的包装器中返回的bar函数:
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(); // And, once again, we have a descriptive call stack! baz bar foo expr_test.html()
JScript bugs
不幸的是,JScript(比如IE的ECMAScript执行环境)将命名函数表达式弄得一团糟。
很多人之所以不推荐函数表达式,得归咎于JScript。即时IE8中的JScript的5.8版本也有以下的怪癖行为。
Example #1: 函数表达式中的函数名可以在函数外使用
var f = function g(){}; typeof g; // "function"
记住我之前说的函数名只能在函数中使用,不能再函数外使用么?但是JScript不符合这个标准——上例中的g被解析成一个函数对象。这样讲会污染环境——有可能是全局环境——将导致对象的难追踪。
Example #2: 命名的函数表达式将被同时当做函数声明和函数表达式对待
typeof g; // "function" var f = function g(){};
就像我之前说的,函数声明有个函数声明提升。上面的例子说明了命名的函数表达式在JScript中被捅死当做函数声明和函数表达式被对待。这也引出了下面的例子:
Example #3: 命名的函数表达式创建了两个不同的函数对象
var f = function g(){}; f === g; // false f.expando = 'foo'; g.expando; // undefined
这正是有趣有让人烦恼的地方,因为改变其中个一个对象另一个对象不会随之改变。
Example #4:函数声明按照顺序解析并且不会被条件语句块影响
var f = function g() { return 1; }; if (false) { f = function g(){ return 2; }; } g(); // 2
像上面的例子更加难以追踪bug。发生的原理非常简单,第一,g被解析成函数声明,因为函数声明在JScript中是不受条件语句块的影响的,从if的false条件语句中g被声明成函数——function g(){ return 2 }。然后所有的常规表达式将被计算,f将被赋值给另一个刚刚被创建的函数对象。if的false条件句在计算表达式的时候将永远不会被执行,所以f保持指向第一个函数——function g(){ return 1 }。现在了解了,如果你不小心在f中调用g,你将调用一个完全不相关的g函数对象。
你也许会怀疑argumens.callee的影响,callee指向f还是g呢?
var f = function g(){ return [ arguments.callee == f, arguments.callee == g ]; }; f(); // [true, false] g(); // [false, true]
你可以看见argum.callee执行正在被触发的函数。
在没有声明的赋值语句中使用命名的函数表达式,你将看到另一个有趣的现象,但是只能是赋值给和函数名同名的变量。
(function(){ f = function f(){}; })();
没有声明的赋值语句(不推荐,这里只是用于演示目的)常常被当做全局变量f的属性。但是在JScript中f将被当做局部变量,所以左边的函数只是被赋值给这个局部变量f,全局变量从来都木有被创造。
看看JScript的不足,我们可以清晰的看到我们应该避免什么:
1.注意函数名会泄露到全局变量
2.永远不要使用函数名来指代函数,使用函数赋值给的变量名亦或argu.callee,如果你使用了函数名,想想函数的作用只是在调试的时候使用的
3.清除在命名函数表达式声明中创建的无关的函数。
最后一条解释可能还需要一些例子:
JScript memory management
Being familiar with JScript discrepancies, we can now see a potential problem with memory consumption when using these buggy constructs. Let’s look at a simple example:
var f = (function(){ if (true) { return function g(){}; } return function g(){}; })();
上面函数的作用是在匿名函数中返回一个函数名为g的函数,并赋值给外面的变量f。
我们知道了在JScript中创建了不必要的函数对象g,并且和f是两个完全不相关的对象,这就造成了一定的内存浪费,除非我们故意打断函数名对于函数的引用。
var f = (function(){ var f, g; if (true) { f = function g(){}; } else { f = function g(){}; } // null `g`, so that it doesn't reference extraneous function any longer g = null; return f; })();
注意我们在匿名函数内部声明了局部变量g,所以g=null赋值语句将不会产生局部变量。
把null赋值给g,我们允许了垃圾回收器来回收g函数对象,以此释放内存。
SpiderMonkey 怪癖
我们知道了命名的函数表达式的函数名只能在函数内部使用,为什么会这样呢?原因如下:
当命名的函数表达式被执行的时候,一个特殊的对象被创建。
这个对象的唯一作用是持有一个和函数名一样的属性,属性值和函数对应。
然后这个对象嵌入当前作用域链的最前面,然后这个被扩大的作用域链被用于初始化一个函数。
有趣的是ECMA-262定义特殊对象(持有函数名的对象)的方式。规则上说对象是通过new Obeject方式创建的,所以是内置Object对象的实例,然而只有一种javascript解析器SpiderMonkey是按照这样的方式解析的。在SpiderMonkey中,可以通过扩充Object.property的方式来处理函数局部变量。
Object.prototype.x = 'outer'; (function(){ var x = 'inner'; /* `foo`函数在这里有一个特殊的对象——保存了函数名。 对象实际上是`{ foo: <function object> }`。
当在作用域链中搜索x的时候,先在‘foo’的上下文中搜索。没有发现的话,在作用域链的上一级对象搜索,这个对象就是拥有函数名的对象————{ foo: <function object> }
因为对象继承自`Object.prototype`,所以x可以在这里发现,也就是`Object.prototype.x` (值是 'outer')。
外部的函数作用域(也就是 x === 'inner')将永远不会到达。 */ (function foo(){ alert(x); // alerts `outer` })(); })();
注意最新版本的SpiderMonkey实际上改变了这个行为,特殊的对象将不再继承Object.prototype
.但是你仍然可以在 Firefox <=3的版本中看见.
另一个将其作为全局对象的实例的是BlackBerry浏览器。这一次,是使用了Activation 对象(其继承自 Object.prototype)。
Object.prototype.x = 'outer'; (function(){ var x = 'inner'; (function(){ /* When `x` is being resolved against scope chain, this local function's Activation Object is searched first. There's no `x` in it, of course. However, since Activation Object inherits from `Object.prototype`, it is `Object.prototype` that's being searched for `x` next. `Object.prototype.x` does in fact exist and so `x` resolves to its value — 'outer'. As in the previous example, outer function's scope (Activation Object) with its own x === 'inner' is never even reached. */ alert(x); // alerts 'outer' })(); })();
和已经存在的 Object.prototype
成员将导致冲突。
(function(){ var constructor = function(){ return 1; }; (function(){ constructor(); // evaluates to an object `{ }`, not `1` constructor === Object.prototype.constructor; // true toString === Object.prototype.toString; // true // etc. })(); })();
解决Blackberry怪癖的方法很明显:避免使用Object.prototype
的属性来命名变量:toString
, valueOf
, hasOwnProperty
,等等。
JScript 解决方案
var fn = (function(){ // 声明一个变量,用于之后函数赋值给该变量 var f; // 有条件的创建一个命名的函数表达式 // 并将f指向该对象 if (true) { f = function F(){ }; } else if (false) { f = function F(){ }; } else { f = function F(){ }; } // 给函数名变量赋值null // 这样使得函数名变量可以被垃圾回收机制回收 var F = null; // 返回根据条件语句创建的函数 return f; })();
最后,我们将使用这个技术到真实生活中,当书写类似于跨浏览器的函数addEvent:
// 1) 在一个独立的作用域中声明函数 var addEvent = (function(){ var docEl = document.documentElement; // 2) 声明一个变量,之后的函数将赋值给该变量 var fn; if (docEl.addEventListener) { // 3) 确保给函数一个描述性的函数名 fn = function addEvent(element, eventName, callback) { element.addEventListener(eventName, callback, false); }; } else if (docEl.attachEvent) { fn = function addEvent(element, eventName, callback) { element.attachEvent('on' + eventName, callback); }; } else { fn = function addEvent(element, eventName, callback) { element['on' + eventName] = callback; }; } // 4)清楚‘addEvent’被JScript创建的函数名 // 确保在之前使用var声明变量名或者在函数的顶部声明了‘addEvent’ var addEvent = null; // 5)最后通过返回函数表达式赋值给的变量来返回函数 return fn; })();
可选择的解决方案
可以使用函数声明而不是函数表达式,这个方法只能定义一种函数才有用:
var hasClassName = (function(){ // 定义一些私有变量 var cache = { }; //使用函数声明 function hasClassName(element, className) { var _className = '(?:^|\\s+)' + className + '(?:\\s+|$)'; var re = cache[_className] || (cache[_className] = new RegExp(_className)); return re.test(element.className); } // 返回函数 return hasClassName; })();
函数声明明显不能在条件语句中使用,然而,可以在最开始的时候定义一系列的函数声明,然后在不同的情况下返回不同的函数声明,从而达到有选择性的返回不同函数的功能。
var addEvent = (function(){ var docEl = document.documentElement; function addEventListener(){ /* ... */ } function attachEvent(){ /* ... */ } function addEventAsProperty(){ /* ... */ } if (typeof docEl.addEventListener != 'undefined') { return addEventListener; } else if (typeof docEl.attachEvent != 'undefined') { return attachEvent; } return addEventAsProperty; })();
但是它有自己的不足,因为会造成内存损耗。将所有的函数在最开始声明,你讲蓄意的创建了n-1个没有用的函数,你可以看见,如果attacheEvent在document.documentElemnet中被发现,那么addEventListener和addEventAsProperty都将永远不会被使用,但是他们仍旧使用了内存。
更多的考虑
在ECMA-262,5th edition中介绍了严格模式,目的是不接受js中脆弱的,不可靠的亦或危险的语法代码。出于安全的考虑,argu.callee也被禁止。
在严格模式下面,使用arguments.callee将会报错TypeError。
之所以我在这里提出严格模式的概念是因为严格模式下不能使用arguments.callee将刀子更多的使用命名的函数表达式。所以理解命名函数表达式的语法以及bug很重要。
// Before, you could use arguments.callee (function(x) { if (x <= 1) return 1; return x * arguments.callee(x - 1); })(10); // In strict mode, an alternative solution is to use named function expression (function factorial(x) { if (x <= 1) return 1; return x * factorial(x - 1); })(10); // or just fall back to slightly less flexible function declaration function factorial(x) { if (x <= 1) return 1; return x * factorial(x - 1); } factorial(10);