欧美bbbwbbbw肥妇,免费乱码人妻系列日韩,一级黄片

命名函數(shù)表達式探秘

作者:Juriy "kangax" Zaytsev
譯者:為之漫筆
發(fā)表時間:2009年6月17日 最近修改:2009年10月9日
翻譯時間:2009年12月9日 修訂時間:2009年12月22日
  1. 前言
  2. 函數(shù)表達式與函數(shù)聲明
  3. 函數(shù)語句
  4. 命名函數(shù)表達式
  5. 調(diào)試器中的函數(shù)名
  6. JScript的bug
  7. JScript的內(nèi)存管理
  8. 測試
  9. Safari中存在的bug
  10. SpiderMonkey的怪癖
  11. 解決方案
  12. 替代方案
  13. WebKit的displayName
  14. 對未來的思考
  15. 致謝

前言

我覺得很奇怪,網(wǎng)上好像一直沒有人認真地討論過命名函數(shù)表達式(Named Function Expression,即“有名字函數(shù)表達式”,與“匿名函數(shù)”相對?!g者注)。而這也許正是各種各樣的誤解隨處可見的一個原因。在這篇文章里,我打算從理論和實踐兩個方面出發(fā),對這些令人驚嘆的JavaScript結構的優(yōu)缺點給出一個結論。

簡單來講,命名函數(shù)表達式只有一個用處——在調(diào)試器或性能分析程序中描述函數(shù)的名稱。沒錯,也可以使用函數(shù)名實現(xiàn)遞歸,但你很快就會知道,目前來看這通常是不切實際的。當然,如果你不關注調(diào)試,那就沒什么可擔心的。否則,就應該往下看一看,看看在跨瀏覽器開發(fā)中都會出現(xiàn)哪些小毛病(glitch),也看看應該怎樣解決它們。

一開始呢,我會先介紹一下什么是函數(shù)表達式,以及現(xiàn)代調(diào)試器如何處理它們之類的內(nèi)容。要是你比較心急,請直接跳到“最終方案”部分,該部分詳細說明了怎樣才能安全地使用這些結構。

函數(shù)表達式與函數(shù)聲明

在ECMAScript中,有兩個最常用的創(chuàng)建函數(shù)對象的方法,即使用函數(shù)表達式或者使用函數(shù)聲明。這兩種方法之間的區(qū)別可謂 相當?shù)亓钊死Щ?/strong>;至少我是相當?shù)乩Щ?。對此,ECMA規(guī)范只明確了一點,即函數(shù)聲明 必須始終帶有一個標識符(Identifier)——也就是函數(shù)名唄,而函數(shù)表達式 則可省略這個標識符:

函數(shù)聲明:
function Identifier ( FormalParameterList opt ){ FunctionBody }

函數(shù)表達式:
function Identifier opt ( FormalParameterList opt ){ FunctionBody }

顯然,在省略標識符的情況下, “表達式” 也就只能是表達式了??梢遣皇÷詷俗R符呢?誰知道它是一個函數(shù)聲明,還是一個函數(shù)表達式——畢竟,這種情況下二者是完全一樣的?。繉嵺`表明,ECMAScript是通過上下文來區(qū)分這兩者的:假如 function foo(){} 是一個賦值表達式的一部分,則認為它是一個函數(shù)表達式。而如果 function foo(){} 被包含在一個函數(shù)體內(nèi),或者位于程序(的最上層)中,則將它作為一個函數(shù)聲明來解析。

  function foo(){}; // 聲明,因為它是程序的一部分
  var bar = function foo(){}; // 表達式,因為它是賦值表達式(AssignmentExpression)的一部分

  new function bar(){}; // 表達式,因為它是New表達式(NewExpression)的一部分

  (function(){
    function bar(){}; // 聲明,因為它是函數(shù)體(FunctionBody)的一部分
  })();

還有一種不那么顯而易見的函數(shù)表達式,就是被包含在一對圓括號中的函數(shù)—— (function foo(){})。將這種形式看成表達式同樣是因為上下文的關系:(和)構成一個分組操作符,而分組操作符只能包含表達式:

下面再多看幾個例子吧:

  
  function foo(){}; // 函數(shù)聲明
  (function foo(){}); // 函數(shù)表達式:注意它被包含在分組操作符中
  
  try {
    (var x = 5); // 分組操作符只能包含表達式,不能包含語句(這里的var就是語句)
  } catch(err) {
    // SyntaxError(因為“var x = 5”是一個語句,而不是表達式——對表達式求值必須返回值,但對語句求值則未必返回值?!g者注)
  }

不知道大家有沒有印象,在使用 eval 對JSON求值的時候,JSON字符串通常是被包含在一對圓括號中的—— eval('(' + json + ')')。這樣做的原因當然也不例外——分組操作符,也就是那對圓括號,會導致解析器強制將JSON的花括號當成表達式而不代碼塊來解析:

  try {
    { "x": 5 }; // {和}會被作為塊來解析
  } catch(err) {
    // SyntaxError(“'x':5”只是構建對象字面量的語法,但該語法不能出現(xiàn)在外部的語句塊中?!g者注)
  }
  
  ({ "x": 5 }); // 分組操作符會導致解析器強制將{和}作為對象字面量來解析

聲明和表達式的行為存在著十分微妙而又十分重要的差別。

首先,函數(shù)聲明會在任何表達式被解析和求值之前先行被解析和求值。即使聲明位于源代碼中的最后一行,它也會先于同一作用域中位于最前面的表達式被求值。還是看個例子更容易理解。在下面這個例子中,函數(shù) fn 是在 alert 后面聲明的。但是,在 alert 執(zhí)行的時候,fn已經(jīng)有定義了:

  alert(fn());

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

函數(shù)聲明還有另外一個重要的特點,即通過條件語句控制函數(shù)聲明的行為并未標準化,因此不同環(huán)境下可能會得到不同的結果。有鑒于此,奉勸大家千萬不要在條件語句中使用函數(shù)聲明,而要使用函數(shù)表達式。

  // 千萬不要這樣做!
  // 有的瀏覽器會把foo聲明為返回first的那個函數(shù)
  // 而有的瀏覽器則會讓foo返回second

  if (true) {
    function foo() {
      return 'first';
    }
  }
  else {
    function foo() {
      return 'second';
    }
  }
  foo();

  // 記住,這種情況下要使用函數(shù)表達式:
  var foo;
  if (true) {
    foo = function() {
      return 'first';
    };
  }
  else {
    foo = function() {
      return 'second';
    };
  }
  foo();

想知道使用函數(shù)聲明的實際規(guī)則到底是什么?繼續(xù)往下看吧。嗯,有人不想知道?那請?zhí)^下面這段摘錄的文字。

FunctionDeclaration(函數(shù)聲明)只能出現(xiàn)在Program(程序)FunctionBody(函數(shù)體)內(nèi)。從句法上講,它們 不能出現(xiàn)在Block(塊){ ... })中,例如不能出現(xiàn)在 if、whilefor 語句中。因為 Block(塊) 中只能包含Statement(語句), 而不能包含FunctionDeclaration(函數(shù)聲明)這樣的SourceElement(源元素)。另一方面,仔細看一看產(chǎn)生規(guī)則也會發(fā)現(xiàn),唯一可能讓Expression(表達式)出現(xiàn)在Block(塊)中情形,就是讓它作為ExpressionStatement(表達式語句)的一部分。但是,規(guī)范明確規(guī)定了ExpressionStatement(表達式語句)不能以關鍵字function開頭。而這實際上就是說,FunctionExpression(函數(shù)表達式)同樣也不能出現(xiàn)在Statement(語句)Block(塊)中(別忘了Block(塊)就是由Statement(語句)構成的)。

由于存在上述限制,只要函數(shù)出現(xiàn)在塊中(像上面例子中那樣),實際上就應該將其看作一個語法錯誤,而不是什么函數(shù)聲明或表達式。但問題是,我還沒見過哪個實現(xiàn)是按照上述規(guī)則來解析這些函數(shù)的;好像每個實現(xiàn)都有自己的一套。

有必要提醒大家一點,根據(jù)規(guī)范的描述,實現(xiàn)可以引入語法擴展(見第16部分),只不過任何情況下都不能違反規(guī)定。而目前的諸多客戶端也正是照此辦理的。其中有一些會把塊中的函數(shù)聲明當作一般的函數(shù)聲明來解析——把它們提升到封閉作用域的頂部;另一些則引入了不同的語義并采用了稍復雜一些的規(guī)則。

函數(shù)語句

在諸如此類的對ECMAScript的語法擴展中,有一項就是函數(shù)語句,基于Gecko的瀏覽器(在Mac OS X平臺的Firefox 1-3.7a1pre中測試過)目前都實現(xiàn)了該項擴展??墒遣恢罏槭裁矗芏嗳撕孟穸疾恢肋@項擴展,也就更談不上對其優(yōu)劣的評價了(MDC(Mozilla Developer Center,Mozilla開發(fā)者中心)提到過這個問題,但是只有那么三言兩語)。請大家記住,我們是抱著學習和滿足自己好奇心的態(tài)度來討論函數(shù)語句的。因此,除非你只針對基于Gecko的環(huán)境編寫腳本,否則我不建議你使用這個擴展

閑話少說,下面我們就來看看這些非標準的結構有哪些特點:

  1. 一般語句可以出現(xiàn)的地方,函數(shù)語句也可以出現(xiàn)。當然包括中:
  2.   if (true) {
        function f(){ }
      }
      else {
        function f(){ }
      }
    
  3. 函數(shù)語句可以像其他語句一樣被解析,包含基于條件執(zhí)行的情形:
  4.   if (true) {
        function foo(){ return 1; }
      }
      else {
        function foo(){ return 2; }
      }
      foo(); // 1
      // 注意其他類型的客戶端會把這里的foo解析為函數(shù)聲明
      // 因此,第二個foo會覆蓋第一個,結果返回2而不返回1
    
  5. 函數(shù)語句不是在變量初始化期間聲明的,而是在運行時聲明的——與函數(shù)表達式一樣。不過,一旦聲明,函數(shù)語句的標識符就在函數(shù)的整個作用域有效了。標識符有效性正是導致函數(shù)語句與函數(shù)表達式不同的關鍵所在(下一節(jié)將會展示命名函數(shù)表達式的具體行為)。
      // 此時,foo還沒有聲明
      typeof foo; // "undefined"
      if (true) {
        // 一進入這個塊,foo就被聲明并在整個作用域中有效了
        function foo(){ return 1; }
      }
      else {
        // 永遠不會進入這個塊,因此這里的foo永遠不會被聲明
        function foo(){ return 2; }
      }
      typeof foo; // "function"
      
    
    通常,可以通過下面這樣符合標準(但更繁瑣一點)的代碼來模擬前例中函數(shù)語句的行為:
      var foo;
      if (true) {
        foo = function foo(){ return 1; };
      }
      else {
        foo = function foo() { return 2; };
      }
    
  6. 函數(shù)語句與函數(shù)聲明或命名函數(shù)表達式的字符串表示類似(而且包含標識符——即此例中的foo):
      if (true) {
        function foo(){ return 1; }
      }
      String(foo); // function foo() { return 1; }
    
  7. 最后,早期基于Gecko的實現(xiàn)(Firefox 3及以前版本)中存在一個bug,即函數(shù)語句覆蓋函數(shù)聲明的方式不正確。在這些早期的實現(xiàn)中,函數(shù)語句不知何故不能覆蓋函數(shù)聲明:
      // 函數(shù)聲明
      function foo(){ return 1; }
      if (true) {
        // 使用函數(shù)語句來重寫
        function foo(){ return 2; }
      }
      foo(); // FF及以前版本返回1,F(xiàn)F3.5及以后版本返回2
      
      // 但是,如果前面是函數(shù)表達式,則沒有這個問題
      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,可能也包括更早的版本)實現(xiàn)了與SpiderMonkey完全一樣的函數(shù)語句。本節(jié)所有的例子(不包括最后一個bug示例),在Safari的那些版本中都會得到與Firefox完全相同的結果。此外,Blackberry(至少包括8230、9000和9530)瀏覽器好像也具有類似的行為。上述這種行為的差異化再次說明——千萬不能盲目地依賴這些擴展?。ㄈ缦滤?,可以根據(jù)特性測試來使用函數(shù)表達式?!g者注)!

命名函數(shù)表達式

函數(shù)表達式實際上還是很常見的。Web開發(fā)中有一個常用的模式,即基于對某種特性的測試來“偽裝”函數(shù)定義,從而實現(xiàn)性能最優(yōu)化。由于這種偽裝通常都出現(xiàn)在相同的作用域中,因此基本上一定要使用函數(shù)表達式。畢竟,如前所述,不應該根據(jù)條件來執(zhí)行函數(shù)聲明:

  // 這里的contains取自APE Javascript庫的源代碼,網(wǎng)址為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;
    }
  })();

提到命名函數(shù)表達式,很顯然,指的就是有名字(技術上稱為標識符)的函數(shù)表達式。在最前面的例子中,var bar = function foo(){};實際上就是一個以foo作為函數(shù)名字的函數(shù)表達式。對此,有一個細節(jié)特別重要,請大家一定要記住,即這個名字只在新定義的函數(shù)的作用域中有效——規(guī)范要求標識符不能在外圍的作用域中有效:

  var f = function foo(){
    return typeof foo; // foo只在內(nèi)部作用域中有效
  };
  // foo在“外部”永遠是不可見的
  typeof foo; // "undefined"
  f(); // "function"

那么,這些所謂的命名函數(shù)表達式到底有什么用呢?為什么還要給它們起個名字呢?

原因就是有名字的函數(shù)可以讓調(diào)試過程更加方便。在調(diào)試應用程序時,如果調(diào)用棧中的項都有各自描述性的名字,那么調(diào)試過程帶給人的就是另一種完全不同的感受。

調(diào)試器中的函數(shù)名

在函數(shù)有相應標識符的情況下,調(diào)試器會將該標識符作為函數(shù)的名字顯示在調(diào)用棧中。有的調(diào)試器(例如Firebug)甚至會為匿名函數(shù)起個名字并顯示出來,讓它們與那些引用函數(shù)的變量具有相同的角色??蛇z憾的是,這些調(diào)試器通常只使用簡單的解析規(guī)則,而依據(jù)簡單的解析規(guī)則提取出來的“名字”有時候沒有多大價值,甚至會得到錯誤結果。(Such extraction is usually quite fragile and often produces false results. )

下面我們來看一個簡單的例子:

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

  // 這里使用函數(shù)聲明定義了3個函數(shù)
  // 當調(diào)試器停止在debugger語句時,
  // Firgbug的調(diào)用??雌饋矸浅G逦?
  baz
  bar
  foo
  expr_test.html()

這樣,我們就知道foo調(diào)用了bar,而后者接著又調(diào)用了baz(而foo本身又在expr_test.html文檔的全局作用域中被調(diào)用)。但真正值得稱道的,則是Firebug會在我們使用匿名表達式的情況下,替我們解析函數(shù)的“名字”:

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

  // 調(diào)用棧:
  baz
  bar()
  foo
  expr_test.html()

相反,不那么令人滿意的情況是,當函數(shù)表達式復雜一些時(現(xiàn)實中差不多總是如此),調(diào)試器再如何盡力也不會起多大的作用。結果,我們只能在調(diào)用棧中顯示函數(shù)名字的位置上赫然看到一個問號:

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

  // 調(diào)用棧:
  baz
  (?)()
  foo
  expr_test.html()

此外,當把一個函數(shù)賦值給多個變量時,還會出現(xiàn)一個令人困惑的問題:

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

  // 調(diào)用棧:
  bar()
  foo
  expr_test.html()

可見,調(diào)用棧中顯示的是foo調(diào)用了bar。但實際情況顯然并非如此。之所以會造成這種困惑,完全是因為baz與另一個函數(shù)——包含代碼alert('spoofed');的函數(shù)——“交換了”引用所致。實事求是地說,這種解析方式在簡單的情況下固然好,但對于不那么簡單的大多數(shù)情況而言就沒有什么用處了。

歸根結底,只有命名函數(shù)表達式才是產(chǎn)生可靠的棧調(diào)用信息的唯一途徑。下面我們有意使用命名函數(shù)表達式來重寫前面的例子。請大家注意,從自執(zhí)行包裝塊中返回的兩個函數(shù)都被命名為了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();

  // 這樣,我們就又可以看到清晰的調(diào)用棧信息了!
  baz
  bar
  foo
  expr_test.html()

在我們?yōu)榘l(fā)現(xiàn)這根救命稻草而歡呼雀躍之前,請大家稍安勿躁,再聽我聊一聊大家所衷愛的JScript。

JScript的bug

令人討厭的是,JScript(也就是IE的ECMAScript實現(xiàn))嚴重混淆了命名函數(shù)表達式。JScript搞得現(xiàn)如今很多人都站出來反對命名函數(shù)表達式。而且,直到JScript的最近一版——IE8中使用的5.8版——仍然存在下列的所有怪異問題。

下面我們就來看看IE在它的這個“破”實現(xiàn)中到底都搞出了哪些花樣。唉,只有知已知彼,才能百戰(zhàn)不殆嘛。請注意,為了清晰起見,我會通過一個個相對獨立的小例子來說明這些問題,雖然這些問題很可能是一個主bug引起的一連串的后果。

例1:函數(shù)表達式的標識符滲透到外部(enclosing)作用域中

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

還有人記得嗎,我們說過:命名函數(shù)表達式的標識符在其外部作用域中是無效的? 好啦,JScript明目張膽地違反了這一規(guī)定——上面例子中的標識符g被解析為函數(shù)對象。這是最讓人頭疼的一個問題了。這樣,任何標識符都可能會在不經(jīng)意間“污染”某個外部作用域——甚至是全局作用域。而且,這種污染常常就是那些難以捕獲的bug的來源。

例2:將命名函數(shù)表達式同時當作函數(shù)聲明和函數(shù)表達式

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

如前所述,在特定的執(zhí)行環(huán)境中,函數(shù)聲明會先于任何表達式被解析。上面這個例子展示了JScript實際上是把命名函數(shù)表達式當作函數(shù)聲明了;因為它在“實際的”聲明之前就解析了g。

這個例子進而引出了下一個例子:

例3:命名函數(shù)表達式會創(chuàng)建兩個截然不同的函數(shù)對象!

    var f = function g(){};
    f === g; // false

    f.expando = 'foo';
    g.expando; // undefined

問題至此就比較嚴重了?;蛘呖梢哉f修改其中一個對象對另一個絲毫沒有影響——這簡直就是胡鬧!通過例子可以看出,出現(xiàn)兩個不同的對象會存在什么風險。假如你想利用緩存機制,在f的屬性中保存某個信息,然后又想當然地認為可以通過引用相同對象的g的同名屬性取得該信息,那么你的麻煩可就大了。

再來看一個稍微復雜點的情況。

例4:只管順序地解析函數(shù)聲明而忽略條件語句塊

    var f = function g() {
        return 1;
    };
    if (false) {
        f = function g(){
            return 2;
        };
    }
    g(); // 2

要查找這個例子中的bug就要困難一些了。但導致bug的原因卻非常簡單。首先,g被當作函數(shù)聲明解析,而由于JScript中的函數(shù)聲明不受條件代碼塊約束(與條件代碼塊無關),所以在“該死的”if分支中,g被當作另一個函數(shù)——function g(){ return 2 }——又被聲明了一次。然后,所有“常規(guī)的”表達式被求值,而此時f被賦予了另一個新創(chuàng)建的對象的引用。由于在對表達式求值的時候,永遠不會進入“該死的”if分支,因此f就會繼續(xù)引用第一個函數(shù)——function g(){ return 1 }。分析到這里,問題就很清楚了:假如你不夠細心,在f中調(diào)用了g(在執(zhí)行遞歸操作的時候會這樣做?!g者注),那么實際上將會調(diào)用一個毫不相干的g函數(shù)對象(即返回2的那個函數(shù)對象?!g者注)。

聰明的讀者可能會聯(lián)想到:在將不同的函數(shù)對象與arguments.callee進行比較時,這個問題會有所表現(xiàn)嗎?callee到底是引用f還是引用g呢?下面我們就來看一看:

  var f = function g(){
    return [
      arguments.callee == f,
      arguments.callee == g
    ];
  };
  f(); // [true, false]
  g(); // [false, true]

看到了吧,arguments.callee引用的始終是被調(diào)用的函數(shù)。實際上,這應該是件好事兒,原因你一會兒就知道了。

另一個“意外行為”的好玩的例子,當我們在不包含聲明的賦值語句中使用命名函數(shù)表達式時可以看到。不過,此時函數(shù)的名字必須與引用它的標識符相同才行:

  (function(){
    f = function f(){};
  })();

眾所周知(但愿如此?!g者注),不包含聲明的賦值語句(注意,我們不建議使用,這里只是出于示范需要才用的)在這里會創(chuàng)建一個全局屬性f。而這也是標準實現(xiàn)的行為??墒?,JScript的bug在這里又會出點亂子。由于JScript把命名函數(shù)表達式當作函數(shù)聲明來解析(參見前面的“例2”),因此在變量聲明階段,f會被聲明為局部變量。然后,在函數(shù)執(zhí)行時,賦值語句已經(jīng)不是未聲明的了(因為f已經(jīng)被聲明為局部變量了?!g者注),右手邊的function f(){}就會被直接賦給剛剛創(chuàng)建的局部變量f。而全局作用域中的f根本不會存在。

看完這個例子后,相信大家就會明白,如果你對JScript的“怪異”行為缺乏了解,你的代碼中出現(xiàn)“嚴重不符合預期”的行為就不難理解了。

明白了JScript的缺陷以后,要采取哪些預防措施就非常清楚了。首先,要注意防范標識符泄漏(滲透)(不讓標識符污染外部作用域)。其次,應該永遠不引用被用作函數(shù)名稱的標識符;還記得前面例子中那個討人厭的標識符g嗎?——如果我們能夠當g不存在,可以避免多少不必要的麻煩哪。因此,關鍵就在于始終要通過f或者arguments.callee來引用函數(shù)。如果你使用了命名函數(shù)表達式,那么應該只在調(diào)試的時候利用那個名字。最后,還要記住一點,一定要把NFE(Named Funciont Expresssions,命名函數(shù)表達式)聲明期間錯誤創(chuàng)建的函數(shù)清理干凈。

嗯,對于上面最后一點,我覺得還要再啰嗦兩句:

JScript的內(nèi)存管理

熟悉上述JScript缺陷之后,再使用這些有毛病的結構,就會發(fā)現(xiàn)內(nèi)存占用方面的潛在問題。下面看一個簡單的例子:

  var f = (function(){
    if (true) {
      return function g(){};
    }
    return function g(){};
  })();

我們知道,這里匿名(函數(shù))調(diào)用返回的函數(shù)——帶有標識符g的函數(shù)——被賦值給了外部的f。我們也知道,命名函數(shù)表達式會導致產(chǎn)生多余的函數(shù)對象,而該對象與返回的函數(shù)對象不是一回事。由于有一個多余的g函數(shù)被“截留”在了返回函數(shù)的閉包中,因此內(nèi)存問題就出現(xiàn)了。這是因為(if語句)內(nèi)部(的)函數(shù)與討厭的g是在同一個作用域中被聲明的。在這種情況下 ,除非我們顯式地斷開對(匿名調(diào)用返回的)g函數(shù)的引用,否則那個討厭的家伙會一直占著內(nèi)存不放。

  var f = (function(){
    var f, g;
    if (true) {
      f = function g(){};
    }
    else {
      f = function g(){};
    }
    // 廢掉g,這樣它就不會再引用多余的函數(shù)了
    g = null;
    return f;
  })();

請注意,這里也明確聲明了變量g,因此賦值語句g = null就不會在符合標準的客戶端(如非JScript實現(xiàn))中創(chuàng)建全局變量g了。通過廢掉g的引用,垃圾收集器就可以把g引用的那個隱式創(chuàng)建的函數(shù)對象清除了。

在解決JScript NFE內(nèi)存泄漏問題的過程中,我運行了一系列簡單的測試,以便確定廢掉g能夠釋放內(nèi)存。

測試

這里的測試很簡單。就是通過命名函數(shù)表達式創(chuàng)建10000個函數(shù),把它們保存在一個數(shù)組中。過一會兒,看看這些函數(shù)到底占用了多少內(nèi)存。然后,再廢掉這些引用并重復這一過程。下面是我使用的一個測試用例:

  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

這個結果大致驗證了我的想法——顯式地清除多余的引用確實可以釋放內(nèi)存,但釋放的內(nèi)存空間相對不多。在創(chuàng)建10000個函數(shù)對象的情況下,大約有3MB左右。對于大型應用程序,以及需要長時間運行或者在低內(nèi)存設備(如手持設備)上運行的程序而言,這是絕對需要考慮的。但對小型腳本而言,這點差別可能也算不了什么。

有讀者可能認為本文到此差不多就該結尾了——實際上還差得遠呢 :)。我還想再多談一點,這些內(nèi)容涉及的是Safari 2.x。

Safari中存在的bug

在Safari較早的版本——Safari 2.x系列中,也存在一些鮮為人知的與NFE有關的bug。我在Web上看到有人說Safari 2.x不支持NFE 。實際上不是那么回事。Safari確實支持NFE,只不過它的實現(xiàn)中存在bug而已(很快你就會看到)。

在某些情況下,Safari 2.x遇到函數(shù)表達式時會出現(xiàn)不能完全解析程序的問題。而且,此時的Safari不會拋出任何錯誤(例如SyntaxError),只會“默默地知難而退”:

  (function f(){})(); // <== NFE
  alert(1); // 由于前面的表達式破壞了整個程序,因此這一行永遠不會執(zhí)行

經(jīng)過多次測試,我得出一個結論:Safari 2.x 不能解析非賦值表達式中的命名函數(shù)表達式。下面是一些賦值表達式的例子:

    // 變量聲明
    var f = 1;

    // 簡單賦值
    f = 2, g = 3;

    // 返回語句
    (function(){
      return (f = 2);
    })();

換句話說,把命名函數(shù)表達式放到一個賦值表達式中會讓Safari“很高興”:

  (function f(){}); // 失敗

  var f = function f(){}; // 沒問題

  (function(){
    return function f(){}; // 失敗
  })();

  (function(){
    return (f = function f(){}); // 沒問題
  })();

  setTimeout(function f(){ }, 100); // 失敗
  
  Person.prototype = {
    say: function say() { ... } // 失敗
  }
  
  Person.prototype.say = function say(){ ... }; // 沒問題

同時這也就意味著,在不使用賦值表達式的情況下,我們不能使用習以為常的模式返回命名函數(shù)表達式:


  // 以下返回命名函數(shù)表達式的常見模式,對Safari 2.x來說是不兼容的:
  (function(){
    if (featureTest) {
      return function f(){};
    }
    return function f(){};
  })();

  // 在Safari 2.x中,應該使用以下稍麻煩一點的方式:
  (function(){
    var f;
    if (featureTest) {
      f = function f(){};
    }
    else {
      f = function f(){};
    }
    return f;
  })();

  // 或者,像下面這樣也行:
  (function(){
    var f;
    if (featureTest) {
      return (f = function f(){});
    }
    return (f = function f(){});
  })();

  /* 
    可是,這樣一來,就額外使用了一個對函數(shù)的引用,而該引用還被封閉在了返回函數(shù)的閉包中。
	為了最大限度地降低額外的內(nèi)存占用,可以考慮把所有命名函數(shù)表達式都賦值給一個變量。
  */

  var __temp;

  (function(){
    if (featureTest) {
      return (__temp = function f(){});
    }
    return (__temp = function f(){});
  })();

  ...

  (function(){
    if (featureTest2) {
      return (__temp = function g(){});
    }
    return (__temp = function g(){});
  })();

  /*
    這樣,后續(xù)的賦值語句通過“重用”前面的引用,達到了不過多占用內(nèi)存的目的。
  */

如果兼容Safari 2.x非常重要,就應該保證源代碼中不能出現(xiàn)任何“不兼容”的結構。雖然這樣做不免會讓人著急上火,可只要抓住了問題的根源,還是絕對能夠做到的。

對了,還有個小問題必須說明一下:在Safari 2.x中聲明命名函數(shù)時,函數(shù)的字符串表示不會包含函數(shù)的標識符:

  var f = function g(){};

  // 看到了嗎,函數(shù)的字符串表示中沒有標識符g
  String(f); // function () { }

這不算什么大問題。但正如我以前說過的,函數(shù)的反編譯結果是無論如何也不能相信的。

SpiderMonkey的怪癖

大家都知道,命名函數(shù)表達式的標識符只在函數(shù)的局部作用域中有效。但包含這個標識符的局部作用域又是什么樣子的嗎?其實非常簡單。在命名函數(shù)表達式被求值時,會創(chuàng)建一個特殊的對象,該對象的唯一目的就是保存一個屬性,而這個屬性的名字對應著函數(shù)標識符,屬性的值對應著那個函數(shù)。這個對象會被注入到當前作用域鏈的前端。然后,被“擴展”的作用域鏈又被用于初始化函數(shù)。

在這里(想象一下本山大叔在小品《火炬手》中發(fā)表獲獎感言的情景吧。——譯者注),有一點十分有意思,那就是ECMA-262定義這個(保存函數(shù)標識符的)“特殊”對象的方式。標準說“像調(diào)用new Object()表達式那樣”創(chuàng)建這個對象。如果從字面上來理解這句話,那么這個對象就應該是全局Object的一個實例。然而,只有一個實現(xiàn)是按照標準字面上的要求這么做的,這個實現(xiàn)就是SpiderMonkey。因此,在SpiderMonkey中,擴展Object.prototype有可能會干擾函數(shù)的局部作用域:

  
  Object.prototype.x = 'outer';
  
  (function(){
    
    var x = 'inner';
    
    /*
      函數(shù)foo的作用域鏈中有一個特殊的對象——用于保存函數(shù)的標識符。這個特殊的對象實際上就是{ foo: <function object> }。
      當通過作用域鏈解析x時,首先解析的是foo的局部環(huán)境。如果沒有找到x,則繼續(xù)搜索作用域鏈中的下一個對象。下一個對象
      就是保存函數(shù)標識符的那個對象——{ foo: <function object> },由于該對象繼承自Object.prototype,所以在此可以找到x。
      而這個x的值也就是Object.prototype.x的值(outer)。結果,外部函數(shù)的作用域(包含x = 'inner'的作用域)就不會被解析了。
    */
    
    (function foo(){
      
      alert(x); // 提示框中顯示:outer
    
    })();
  })();
  

不過,更高版本的SpiderMonkey改變了上述行為,原因可能是認為那是一個安全漏洞。也就是說,“特殊”對象不再繼承Object.prototype了。不過,如果你使用Firefox 3或者更低版本,還可以“重溫”這種行為。

另一個把內(nèi)部對象實現(xiàn)為全局Object對象的是黑莓(Blackberry)瀏覽器。目前,它的活動對象(Activation Object)仍然繼承Object.prototype??墒?,ECMA-262并沒有說活動對象也要“像調(diào)用new Object()表達式那樣”來創(chuàng)建(或者說像創(chuàng)建保存NFE標識符的對象一樣創(chuàng)建)。 人家規(guī)范只說了活動對象是規(guī)范中的一種機制。

好,那我們下面就來看看黑莓瀏覽器的行為吧:

  
  Object.prototype.x = 'outer';
  
  (function(){
    
    var x = 'inner';
    
    (function(){
      
      /*
      在沿著作用域鏈解析x的過程中,首先會搜索局部函數(shù)的活動對象。當然,在該對象中找不到x。
      可是,由于活動對象繼承自Object.prototype,因此搜索x的下一個目標就是Object.prototype;而
      Object.prototype中又確實有x的定義。結果,x的值就被解析為——outer。跟前面的例子差不多,
      包含x = 'inner'的外部函數(shù)的作用域(活動對象)就不會被解析了。
      */
      
      alert(x); // 提示框中顯示:outer
      
    })();
  })();

雖然這有點讓人不可思議,但更令人匪夷所思的則是函數(shù)中的變量甚至會與已有的Object.prototype的成員發(fā)生沖突:

  (function(){
    
    var constructor = function(){ return 1; };
    
    (function(){
      
      constructor(); // 求值結果是{}(即相當于調(diào)用了Object.prototype.constructor()。——譯者注)而不是1
      
      constructor === Object.prototype.constructor; // true
      toString === Object.prototype.toString; // true
      
      // ……
      
    })();
  })();

解決方案

  var fn = (function(){

    // 聲明要引用函數(shù)的變量
    var f;

    // 有條件地創(chuàng)建命名函數(shù)
    // 并將其引用賦值給f
    if (true) {
      f = function F(){ }
    }
    else if (false) {
      f = function F(){ }
    }
    else {
      f = function F(){ }
    }

    // 聲明一個與函數(shù)名(標識符)對應的變量,并賦值為null
    // 這實際上是給相應標識符引用的函數(shù)對象作了一個標記,
    // 以便垃圾回收器知道可以回收它了
    var F = null;

    // 返回根據(jù)條件定義的函數(shù)
    return f;
  })();

最后,我要給出一個應用上述“技術”的實例。這是一個跨瀏覽器的addEvent函數(shù)的代碼:


  // 1) 使用獨立的作用域包含聲明
  var addEvent = (function(){

    var docEl = document.documentElement;

    // 2) 聲明要引用函數(shù)的變量
    var fn;

    if (docEl.addEventListener) {

      // 3) 有意給函數(shù)一個描述性的標識符
      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創(chuàng)建的addEvent函數(shù)
    //    一定要保證在賦值前使用var關鍵字
    //    除非函數(shù)頂部已經(jīng)聲明了addEvent
    var addEvent = null;

    // 5) 最后返回由fn引用的函數(shù)
    return fn;
  })();

替代方案

不要忘了,如果我們不想在調(diào)用棧中保留描述性的名字,實際上還有其他選擇。換句話說,就是還存在不必使用命名函數(shù)表達式的方案。首先,很多時候都可以通過聲明而非表達式定義函數(shù)。這個方案只適合不需要創(chuàng)建多個函數(shù)的情形:

  var hasClassName = (function(){

    // 定義私有變量
    var cache = { };

    // 使用函數(shù)聲明
    function hasClassName(element, className) {
      var _className = '(?:^|\\s+)' + className + '(?:\\s+|$)';
      var re = cache[_className] || (cache[_className] = new RegExp(_className));
      return re.test(element.className);
    }

    // 返回函數(shù)
    return hasClassName;
  })();

顯然,當存在多個分支函數(shù)定義時,這個方案就不能勝任了。不過,我最早見過托比·蘭吉(Tobiel Langel)使用過一個很有味道的模式。他的這種模式是提前使用函數(shù)聲明來定義所有函數(shù),并分別為這些函數(shù)指定不同的標識符

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

雖然這個方案很優(yōu)雅,但也不是沒有缺點。第一,由于使用不同的標識符,導致喪失了命名的一致性。且不說這樣好還是壞,最起碼它不夠清晰。有人喜歡使用相同的名字,但也有人根本不在乎字眼上的差別??僧吘?,不同的名字會讓人聯(lián)想到所用的不同實現(xiàn)。例如,在調(diào)試器中看到attachEvent,我們就知道addEvent是基于attachEvent的實現(xiàn)(即基于IE的事件模型?!g者注)。當然,基于實現(xiàn)來命名的方式也不一定都行得通。假如我們要提供一個API,并按照這種方式把函數(shù)命名為inner。那么API用戶的很容易就會被相應實現(xiàn)的細節(jié)搞得暈頭轉(zhuǎn)向。(也許是因為inner這個名字太通用,不同實現(xiàn)中可能都會有,因此容易讓人分不清這個API到底基于哪個實現(xiàn)?!g者注)

要解決這個問題,當然就得想一套更合理的命名方案了。但關鍵是不要再額外制造麻煩。我現(xiàn)在能想起來的方案大概有如下幾個:

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

另外,托比使用的模式還存在一個小問題,即增加內(nèi)存占用。提前創(chuàng)建N個不同名字的函數(shù),等于有N-1的函數(shù)是用不到的。具體來講,如果document.documentElement中包含attachEvent,那么addEventListeneraddEventAsProperty則根本就用不著了??墒?,他們都占著內(nèi)存哪;而且,這些內(nèi)存將永遠都得不到釋放,原因跟JScript臭哄哄的命名表達式相同——這兩個函數(shù)都被“截留”在返回的那個函數(shù)的閉包中了。

不過,增加內(nèi)存占用這個問題確實沒什么大不了的。如果某個庫——例如Prototype.js——采用了這種模式,無非也就是多創(chuàng)建一兩百個函數(shù)而已。只要不是(在運行時)重復地創(chuàng)建這些函數(shù),而是只(在加載時)創(chuàng)建一次,那么就沒有什么好擔心的。

WebKit的displayName

WebKit團隊在這個問題采取了有點兒另類的策略。囿于函數(shù)(包括匿名和命名函數(shù))如此之差的表現(xiàn)力,WebKit引入了一個“特殊的”displayName屬性(本質(zhì)上是一個字符串),如果開發(fā)人員為函數(shù)的這個屬性賦值,則該屬性的值將在調(diào)試器或性能分析器中被顯示在函數(shù)“名稱”的位置上。弗朗西斯科·托依瑪斯基(Francisco Tolmasky)詳細地解釋了這個策略的原理和實現(xiàn)。

對未來的思考

將來的ECMAScript-262第5版(目前還是草案)會引入所謂的嚴格模式(strict mode)。開啟嚴格模式的實現(xiàn)會禁用語言中的那些不穩(wěn)定、不可靠和不安全的特性。據(jù)說出于安全方面的考慮,arguments.callee屬性將在嚴格模式下被“封殺”。因此,在處于嚴格模式時,訪問arguments.callee會導致TypeError(參見ECMA-262第5版的10.6節(jié))。而我之所以在此提到嚴格模式,是因為如果在基于第5版標準的實現(xiàn)中無法使用arguments.callee來執(zhí)行遞歸操作,那么使用命名函數(shù)表達式的可能性就會大大增加。從這個意義上來說,理解命名函數(shù)表達式的語義及其bug也就顯得更加重要了。

  // 此前,你可能會使用arguments.callee
  (function(x) {
    if (x <= 1) return 1;
    return x * arguments.callee(x - 1);
  })(10);
  
  // 但在嚴格模式下,有可能就要使用命名函數(shù)表達式
  (function factorial(x) {
    if (x <= 1) return 1;
    return x * factorial(x - 1);
  })(10);
  
  // 要么就退一步,使用沒有那么靈活的函數(shù)聲明
  function factorial(x) {
    if (x <= 1) return 1;
    return x * factorial(x - 1);
  }
  factorial(10);

致謝

理查德· 康福德(Richard Cornford),是他率先解釋了JScript中命名函數(shù)表達式所存在的bug。理查德解釋了我在這篇文章中提及的大多數(shù)bug,所以我強烈建議大家去看看他的解釋。我還要感謝Yann-Erwan Perio(這是中國人嗎?——譯者注)道格拉斯·克勞克佛德(Douglas Crockford),他們早在2003年就在comp.lang.javascript論壇中提及并討論NFE問題了

約翰-戴維·道爾頓(John-David Dalton)對“最終解決方案”提出了很好的建議。

托比·蘭吉的點子被我用在了“替代方案”中。

蓋瑞特·史密斯(Garrett Smith)德米特里·蘇斯尼科(Dmitry Soshnikov)對本文的多方面作出了補充和修正。

要提建議或者反饋錯誤嗎?可以mailto:kangax@gmail.com給我寫封郵件,隨便,怎么都行。

發(fā)表時間:2009年6月17日 最近修改:2009年10月9日