深入理解JavaScript系列(2):揭秘命名函数表达式

来源:互联网 发布:parsley.js 中文提示 编辑:程序博客网 时间:2024/05/01 16:59

前言

函数表达式和函数声明

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

  函数声明:

  function 函数名称 (参数:可选){ 函数体 }

  函数表达式:

  function 函数名称(可选)(参数:可选){ 函数体 }

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

  function foo(){} // 声明,因为它是程序的一部分  var bar = function foo(){}; // 表达式,因为它是赋值表达式的一部分  new function bar(){}; // 表达式,因为它是new表达式  (function(){    function bar(){} // 声明,因为它是函数体的一部分  })();

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

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

表达式和声明存在着十分微妙的差别,首先,函数声明会在任何表达式被解析和求值之前先被解析和求值,即使你的声明在代码的最后一行,它也会在同作用域内第一个表达式之前被解析/求值,
  alert(fn());  function fn() {    return 'Hello world!';  }

函数语句

命名函数表达式

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

调试器中的函数名


JScript的Bug

例1:函数表达式的标示符泄露到外部作用域
  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

看到这里,大家会觉得问题严重了,因为修改任何一个对象,另外一个没有什么改变,这太恶了。通过这个例子可以发现,创建2个不同的对象,也就是说如果你想修改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函数对象。

你可能会问,将不同的对象和arguments.callee相比较时,有什么样的区别呢?我们来看看:
 var f = function g(){    return [      arguments.callee == f,      arguments.callee == g    ];  };  f(); // [true, false]  g(); // [false, true]
可以看到,arguments.callee的引用一直是被调用的函数

JScript的内存管理

知道了这些不符合规范的代码解析bug以后,我们如果用它的话,就会发现内存方面其实是有问题的,来看一个例子:
  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为null以后它就不会再占内存了    g = null;    return f;  })();
通过设置g为null,垃圾回收器就把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中的任务管理器可以看到如下结果:
  IE6:    without `null`:   7.6K -> 20.3K    with `null`:      7.6K -> 18K  IE7:    without `null`:   14K -> 29.7K    with `null`:      14K -> 27K

替代方案

其实,如果我们不想要这个描述性名字的话,我们就可以用最简单的形式来做,也就是在函数内部声明一个函数(而不是函数表达式),然后返回该函数:
  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;    }    elseif (typeof docEl.attachEvent != 'undefined') {      return attachEvent;    }    return addEventAsProperty;  })();

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

另外,这种模式还存在一个小问题,即增加内存占用。提前创建N个不同名字的函数,等于有N-1的函数是用不到的。具体来讲,如果document.documentElement 中包含attachEvent,那么addEventListener 和addEventAsProperty则根本就用不着了。可是,他们都占着内存哪;而且,这些内存将永远都得不到释放,原因跟JScript臭哄哄的命名表达式相同——这两个函数都被“截留”在返回的那个函数的闭包中了。

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