命名函数表达式探秘

函数表达式与函数声明

在ECMAScript中,有两个最经常使用的建立函数对象的方法,即便用函数表达式或者使用函数声明。这两种方法之间的区别可谓 至关地使人困惑;至少我是至关地困惑。对此,ECMA规范只明确了一点,即函数声明 必须始终带有一个标识符(Identifier)——也就是函数名呗,而函数表达式 则可省略这个标识符:html

函数声明: function Identifier ( FormalParameterList opt ){ FunctionBody }
函数表达式: function Identifier opt ( FormalParameterList opt ){ FunctionBody }

显然,在省略标识符的状况下, “表达式” 也就只能是表达式了。可要是不省略标识符呢?谁知道它是一个函数声明,仍是一个函数表达式——毕竟,这种状况下两者是彻底同样的啊?实践代表,ECMAScript是经过上下文来区分这二者的:假如 function foo(){} 是一个赋值表达式的一部分,则认为它是一个函数表达式。而若是 function foo(){} 被包含在一个函数体内,或者位于程序(的最上层)中,则将它做为一个函数声明来解析。 json

 function foo(){}; // 声明,由于它是程序的一部分
  var bar = function foo(){}; // 表达式,由于它是赋值表达式(AssignmentExpression)的一部分
  new function bar(){}; // 表达式,由于它是New表达式(NewExpression)的一部分
  (function(){
    function bar(){}; // 声明,由于它是函数体(FunctionBody)的一部分
  })();

还有一种不那么显而易见的函数表达式,就是被包含在一对圆括号中的函数—— (function foo(){})。将这种形式当作表达式一样是由于上下文的关系:(和)构成一个分组操做符,而分组操做符只能包含表达式: 数组

下面再多看几个例子吧: 浏览器

 function foo(){}; // 函数声明
  (function foo(){}); // 函数表达式:注意它被包含在分组操做符中
  try {
    (var x = 5); // 分组操做符只能包含表达式,不能包含语句(这里的var就是语句)
  } catch(err) {
    // SyntaxError(由于“var x = 5”是一个语句,而不是表达式——对表达式求值必须返回值,但对语句求值则未必返回值。——译者注)
  }

不知道你们有没有印象,在使用 eval 对JSON求值的时候,JSON字符串一般是被包含在一对圆括号中的—— eval('(' + json + ')')。这样作的缘由固然也不例外——分组操做符,也就是那对圆括号,会致使解析器强制将JSON的花括号当成表达式而不代码块来解析: 缓存

  try {
    { "x": 5 }; // {和}会被做为块来解析
  } catch(err) {
    // SyntaxError(“'x':5”只是构建对象字面量的语法,但该语法不能出如今外部的语句块中。——译者注)
  }
  ({ "x": 5 }); // 分组操做符会致使解析器强制将{和}做为对象字面量来解析

声明和表达式的行为存在着十分微妙而又十分重要的差异。 安全

首先,函数声明会在任何表达式被解析和求值以前先行被解析和求值。即便声明位于源代码中的最后一行,它也会先于同一做用域中位于最前面的表达式被求值。仍是看个例子更容易理解。在下面这个例子中,函数 fn 是在 alert 后面声明的。可是,在 alert 执行的时候,fn已经有定义了: 闭包

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

 

函数声明还有另一个重要的特色,即经过条件语句控制函数声明的行为并未标准化,所以不一样环境下可能会获得不一样的结果。有鉴于此,奉劝你们千万不要在条件语句中使用函数声明,而要使用函数表达式。 函数

 // 千万不要这样作!
  // 有的浏览器会把foo声明为返回first的那个函数
  // 而有的浏览器则会让foo返回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();

想知道使用函数声明的实际规则究竟是什么?继续往下看吧。嗯,有人不想知道?那请跳过下面这段摘录的文字。 性能

FunctionDeclaration(函数声明)只能出如今Program(程序)或FunctionBody(函数体)内。从句法上讲,它们 不能出如今Block(块)({ ... })中,例如不能出如今 if、while 或 for 语句中。由于 Block(块) 中只能包含Statement(语句), 而不能包含FunctionDeclaration(函数声明)这样的SourceElement(源元素)。另外一方面,仔细看一看产生规则也会发现,惟一可能让Expression(表达式)出如今Block(块)中情形,就是让它做为ExpressionStatement(表达式语句)的一部分。可是,规范明确规定了ExpressionStatement(表达式语句)不能以关键字function开头。而这实际上就是说,FunctionExpression(函数表达式)一样也不能出如今Statement(语句)或Block(块)中(别忘了Block(块)就是由Statement(语句)构成的)。 测试

因为存在上述限制,只要函数出如今块中(像上面例子中那样),实际上就应该将其看做一个语法错误,而不是什么函数声明或表达式。但问题是,我还没见过哪一个实现是按照上述规则来解析这些函数的;好像每一个实现都有本身的一套。

命名函数表达式

函数表达式实际上仍是很常见的。Web开发中有一个经常使用的模式,即基于对某种特性的测试来“假装”函数定义,从而实现性能最优化。因为这种假装一般都出如今相同的做用域中,所以基本上必定要使用函数表达式。毕竟,如前所述,不该该根据条件来执行函数声明:

 // 这里的contains取自APE Javascript库的源代码,网址为http://dhtmlkitchen.com/ape/,做者盖瑞特·斯密特(Garrett Smit)
  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只在内部做用域中有效
  };
  // foo在“外部”永远是不可见的
  typeof foo; // "undefined"
  f(); // "function"

那么,这些所谓的命名函数表达式到底有什么用呢?为何还要给它们起个名字呢?

缘由就是有名字的函数可让调试过程更加方便。在调试应用程序时,若是调用栈中的项都有各自描述性的名字,那么调试过程带给人的就是另外一种彻底不一样的感觉。

调试器中的函数名

在函数有相应标识符的状况下,调试器会将该标识符做为函数的名字显示在调用栈中。有的调试器(例如Firebug)甚至会为匿名函数起个名字并显示出来,让它们与那些引用函数的变量具备相同的角色。可遗憾的是,这些调试器一般只使用简单的解析规则,而依据简单的解析规则提取出来的“名字”有时候没有多大价值,甚至会获得错误结果。(Such extraction is usually quite fragile and often produces false results. )

下面咱们来看一个简单的例子:

function foo(){
    return bar();
  }
  function bar(){
    return baz();
  }
  function baz(){
    debugger;
  }
  foo();
  // 这里使用函数声明定义了3个函数
  // 当调试器中止在debugger语句时,
  // Firgbug的调用栈看起来很是清晰:
  baz
  bar
  foo
  expr_test.html()

这样,咱们就知道foo调用了bar,然后者接着又调用了baz(而foo自己又在expr_test.html文档的全局做用域中被调用)。但真正值得称道的,则是Firebug会在咱们使用匿名表达式的状况下,替咱们解析函数的“名字”:

function foo(){
    return bar();
  }
  var bar = function(){
    return baz();
  }
  function baz(){
    debugger;
  }
  foo();
  // 调用栈:
  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();
  // 调用栈:
  baz
  (?)()
  foo
  expr_test.html()

此外,当把一个函数赋值给多个变量时,还会出现一个使人困惑的问题:

function foo(){
    return baz();
  }
  var bar = function(){
    debugger;
  };
  var baz = bar;
  bar = function() { 
    alert('spoofed');
  }
  foo();
  // 调用栈:
  bar()
  foo
  expr_test.html()

可见,调用栈中显示的是foo调用了bar。但实际状况显然并不是如此。之因此会形成这种困惑,彻底是由于baz与另外一个函数——包含代码alert('spoofed');的函数——“交换了”引用所致。实事求是地说,这种解析方式在简单的状况下当然好,但对于不那么简单的大多数状况而言就没有什么用处了。

归根结底,只有命名函数表达式才是产生可靠的栈调用信息的惟一途径。下面咱们有意使用命名函数表达式来重写前面的例子。请你们注意,从自执行包装块中返回的两个函数都被命名为了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();
  // 这样,咱们就又能够看到清晰的调用栈信息了!
  baz
  bar
  foo
  expr_test.html()

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会被声明为局部变量。而后,在函数执行时,赋值语句已经不是未声明的了,右手边的function f(){}就会被直接赋给刚刚建立的局部变量f。而全局做用域中的f根本不会存在。

看完这个例子后,相信你们就会明白,若是你对JScript的“怪异”行为缺少了解,你的代码中出现“严重不符合预期”的行为就不难理解了。

JScript的内存管理

明白了JScript的缺陷之后,要采起哪些预防措施就很是清楚了。首先,要注意防范标识符泄漏(渗透)(不让标识符污染外部做用域)。其次,应该永远不引用被用做函数名称的标识符;还记得前面例子中那个讨人厌的标识符g吗?——若是咱们可以当g不存在,能够避免多少没必要要的麻烦哪。所以,关键就在于始终要经过f或者arguments.callee来引用函数。若是你使用了命名函数表达式,那么应该只在调试的时候利用那个名字。最后,还要记住一点,必定要把NFE(Named Funciont Expresssions,命名函数表达式)声明期间错误建立的函数清理干净

下面看一个简单的例子:

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引用的那个隐式建立的函数对象清除了。

测试

这里的测试很简单。就是经过命名函数表达式建立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左右。对于大型应用程序,以及须要长时间运行或者在低内存设备(如手持设备)上运行的程序而言,这是绝对须要考虑的。但对小型脚本而言,这点差异可能也算不了什么。

解决方案

 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) 清除由JScript建立的addEvent函数
    //    必定要保证在赋值前使用var关键字
    //    除非函数顶部已经声明了addEvent
    var addEvent = null;
    // 5) 最后返回由fn引用的函数
    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;
  })();

显然,当存在多个分支函数定义时,这个方案就不能胜任了。不过,我最先见过托比·兰吉(Tobiel Langel)使用过一个颇有味道的模式。他的这种模式是提早使用函数声明来定义全部函数,并分别为这些函数指定不一样的标识符

 var addEvent = (function(){
    var docEl = document.documentElement;
    function addEventListener(){
      /* ... */
    }
    function attachEvent(){
      /* ... */
    }
    function addEventAsProperty(){
      /* ... */
    }
    if (typeof docEl.addEventListener != 'undefined') {
      return addEventListener;
    }
    elseif (typeof docEl.attachEvent != 'undefined') {
      return attachEvent;
    }
    return addEventAsProperty;
  })();

虽然这个方案很优雅,但也不是没有缺点。第一,因为使用不一样的标识符,致使丧失了命名的一致性。且不说这样好仍是坏,最起码它不够清晰。有人喜欢使用相同的名字,但也有人根本不在意字眼上的差异。可毕竟,不一样的名字会让人联想到所用的不一样实现。例如,在调试器中看到attachEvent,咱们就知道addEvent是基于attachEvent的实现。固然,基于实现来命名的方式也不必定都行得通。假如咱们要提供一个API,并按照这种方式把函数命名为inner。那么API用户的很容易就会被相应实现的细节搞得晕头转向。

要解决这个问题,固然就得想一套更合理的命名方案了。但关键是不要再额外制造麻烦。我如今能想起来的方案大概有以下几个:

'addEvent', 'altAddEvent', 'fallbackAddEvent'
  // 或者
  'addEvent', 'addEvent2', 'addEvent3'
  // 或者
  'addEvent_addEventListener', 'addEvent_attachEvent', 'addEvent_asProperty'

另外,托比使用的模式还存在一个小问题,即增长内存占用。提早建立N个不一样名字的函数,等于有N-1的函数是用不到的。具体来说,若是document.documentElement中包含attachEvent,那么addEventListener 和addEventAsProperty则根本就用不着了。但是,他们都占着内存哪;并且,这些内存将永远都得不到释放,缘由跟JScript臭哄哄的命名表达式相同——这两个函数都被“截留”在返回的那个函数的闭包中了。

不过,增长内存占用这个问题确实没什么大不了的。若是某个库——例如Prototype.js——采用了这种模式,无非也就是多建立一两百个函数而已。只要不是(在运行时)重复地建立这些函数,而是只(在加载时)建立一次,那么就没有什么好担忧的。

对将来的思考

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

 // 此前,你可能会使用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);
相关文章
相关标签/搜索