命名函數表達式探祕

函數表達式與函數聲明

在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);
相關文章
相關標籤/搜索