鍍金池/ 教程/ HTML/ 揭秘命名函數(shù)表達式
代碼復用模式(避免篇)
S.O.L.I.D 五大原則之接口隔離原則 ISP
設(shè)計模式之狀態(tài)模式
JavaScript 核心(晉級高手必讀篇)
設(shè)計模式之建造者模式
JavaScript 與 DOM(上)——也適用于新手
設(shè)計模式之中介者模式
設(shè)計模式之裝飾者模式
設(shè)計模式之模板方法
設(shè)計模式之外觀模式
強大的原型和原型鏈
設(shè)計模式之構(gòu)造函數(shù)模式
揭秘命名函數(shù)表達式
深入理解J avaScript 系列(結(jié)局篇)
執(zhí)行上下文(Execution Contexts)
函數(shù)(Functions)
《你真懂 JavaScript 嗎?》答案詳解
設(shè)計模式之適配器模式
設(shè)計模式之組合模式
設(shè)計模式之命令模式
S.O.L.I.D 五大原則之單一職責 SRP
編寫高質(zhì)量 JavaScript 代碼的基本要點
求值策略
閉包(Closures)
對象創(chuàng)建模式(上篇)
This? Yes,this!
設(shè)計模式之代理模式
變量對象(Variable Object)
S.O.L.I.D 五大原則之里氏替換原則 LSP
面向?qū)ο缶幊讨话憷碚?/span>
設(shè)計模式之單例模式
Function 模式(上篇)
S.O.L.I.D 五大原則之依賴倒置原則 DIP
設(shè)計模式之迭代器模式
立即調(diào)用的函數(shù)表達式
設(shè)計模式之享元模式
設(shè)計模式之原型模式
根本沒有“JSON 對象”這回事!
JavaScript 與 DOM(下)
面向?qū)ο缶幊讨?ECMAScript 實現(xiàn)
全面解析 Module 模式
對象創(chuàng)建模式(下篇)
設(shè)計模式之職責鏈模式
S.O.L.I.D 五大原則之開閉原則 OCP
設(shè)計模式之橋接模式
設(shè)計模式之策略模式
設(shè)計模式之觀察者模式
代碼復用模式(推薦篇)
作用域鏈(Scope Chain)
Function 模式(下篇)
設(shè)計模式之工廠模式

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

前言

網(wǎng)上還沒用發(fā)現(xiàn)有人對命名函數(shù)表達式進去重復深入的討論,正因為如此,網(wǎng)上出現(xiàn)了各種各樣的誤解,本文將從原理和實踐兩個方面來探討 JavaScript 關(guān)于命名函數(shù)表達式的優(yōu)缺點。

簡單的說,命名函數(shù)表達式只有一個用戶,那就是在 Debug 或者 Profiler 分析的時候來描述函數(shù)的名稱,也可以使用函數(shù)名實現(xiàn)遞歸,但很快你就會發(fā)現(xiàn)其實是不切實際的。當然,如果你不關(guān)注調(diào)試,那就沒什么可擔心的了,否則,如果你想了解兼容性方面的東西的話,你還是應該繼續(xù)往下看看。

我們先開始看看,什么叫函數(shù)表達式,然后再說一下現(xiàn)代調(diào)試器如何處理這些表達式,如果你已經(jīng)對這方面很熟悉的話,請直接跳過此小節(jié)。

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

在 ECMAScript 中,創(chuàng)建函數(shù)的最常用的兩個方法是函數(shù)表達式和函數(shù)聲明,兩者期間的區(qū)別是有點暈,因為 ECMA 規(guī)范只明確了一點:函數(shù)聲明必須帶有標示符(Identifier)(就是大家常說的函數(shù)名稱),而函數(shù)表達式則可以省略這個標示符:

函數(shù)聲明:

function 函數(shù)名稱 (參數(shù):可選){ 函數(shù)體 }

函數(shù)表達式:

function 函數(shù)名稱(可選)(參數(shù):可選){ 函數(shù)體 }

所以,可以看出,如果不聲明函數(shù)名稱,它肯定是表達式,可如果聲明了函數(shù)名稱的話,如何判斷是函數(shù)聲明還是函數(shù)表達式呢?ECMAScript 是通過上下文來區(qū)分的,如果 function foo(){}是作為賦值表達式的一部分的話,那它就是一個函數(shù)表達式,如果 function foo(){}被包含在一個函數(shù)體內(nèi),或者位于程序的最頂部的話,那它就是一個函數(shù)聲明。

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

還有一種函數(shù)表達式不太常見,就是被括號括住的(function foo(){}),他是表達式的原因是因為括號 ()是一個分組操作符,它的內(nèi)部只能包含表達式,我們來看幾個例子:

  function foo(){} // 函數(shù)聲明
  (function foo(){}); // 函數(shù)表達式:包含在分組操作符內(nèi)
  try {
    (var x = 5); // 分組操作符,只能包含表達式而不能包含語句:這里的var就是語句
  } catch(err) {
    // SyntaxError
  }

你可以會想到,在使用 eval 對 JSON 進行執(zhí)行的時候,JSON 字符串通常被包含在一個圓括號里:eval('(' + json + ')'),這樣做的原因就是因為分組操作符,也就是這對括號,會讓解析器強制將JSON的花括號解析成表達式而不是代碼塊。

  try {
    { "x": 5 }; // "{" 和 "}" 做解析成代碼塊
  } catch(err) {
    // SyntaxError
  }
  ({ "x": 5 }); // 分組操作符強制將"{" 和 "}"作為對象字面量來解析

表達式和聲明存在著十分微妙的差別,首先,函數(shù)聲明會在任何表達式被解析和求值之前先被解析和求值,即使你的聲明在代碼的最后一行,它也會在同作用域內(nèi)第一個表達式之前被解析/求值,參考如下例子,函數(shù) fn 是在 alert 之后聲明的,但是在 alert 執(zhí)行的時候,fn已經(jīng)有定義了:

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

另外,還有一點需要提醒一下,函數(shù)聲明在條件語句內(nèi)雖然可以用,但是沒有被標準化,也就是說不同的環(huán)境可能有不同的執(zhí)行結(jié)果,所以這樣情況下,最好使用函數(shù)表達式:

  // 千萬別這樣做!
  // 因為有的瀏覽器會返回first的這個function,而有的瀏覽器返回的卻是第二個
  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ī)則如下:

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

函數(shù)語句

在 ECMAScript 的語法擴展中,有一個是函數(shù)語句,目前只有基于 Gecko 的瀏覽器實現(xiàn)了該擴展,所以對于下面的例子,我們僅是抱著學習的目的來看,一般來說不推薦使用(除非你針對 Gecko 瀏覽器進行開發(fā))。

1.一般語句能用的地方,函數(shù)語句也能用,當然也包括 Block 塊中:

  if (true) {
    function f(){ }
  }
  else {
    function f(){ }
  }

2.函數(shù)語句可以像其他語句一樣被解析,包含基于條件執(zhí)行的情形

  if (true) {
    function foo(){ return 1; }
  }
  else {
    function foo(){ return 2; }
  }
  foo(); // 1
  // 注:其它客戶端會將foo解析成函數(shù)聲明 
  // 因此,第二個foo會覆蓋第一個,結(jié)果返回2,而不是1

3.函數(shù)語句不是在變量初始化期間聲明的,而是在運行時聲明的——與函數(shù)表達式一樣。不過,函數(shù)語句的標識符一旦聲明能在函數(shù)的整個作用域生效了。標識符有效性正是導致函數(shù)語句與函數(shù)表達式不同的關(guān)鍵所在(下一小節(jié)我們將會展示命名函數(shù)表達式的具體行為)。

  // 此刻,foo還沒用聲明
  typeof foo; // "undefined"
  if (true) {
    // 進入這里以后,foo就被聲明在整個作用域內(nèi)了
    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; };
  }

4.函數(shù)語句和函數(shù)聲明(或命名函數(shù)表達式)的字符串表示類似,也包括標識符:

  if (true) {
    function foo(){ return 1; }
  }
  String(foo); // function foo() { return 1; }

5.另外一個,早期基于 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(); // FF3以下返回1,F(xiàn)F3.5以上返回2
  // 不過,如果前面是函數(shù)表達式,則沒用問題
  var foo = function(){ return 1; };
  if (true) {
    function foo(){ return 2; }
  }
  foo(); // 所有版本都返回2

再次強調(diào)一點,上面這些例子只是在某些瀏覽器支持,所以推薦大家不要使用這些,除非你就在特性的瀏覽器上做開發(fā)。

命名函數(shù)表達式

函數(shù)表達式在實際應用中還是很常見的,在 web 開發(fā)中友個常用的模式是基于對某種特性的測試來偽裝函數(shù)定義,從而達到性能優(yōu)化的目的,但由于這種方式都是在同一作用域內(nèi),所以基本上一定要用函數(shù)表達式:

  // 該代碼來自Garrett Smith的APE Javascript library庫(http://dhtmlkitchen.com/ape/) 
  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ù)表達式,理所當然,就是它得有名字,前面的例子 var bar = function foo(){};就是一個有效的命名函數(shù)表達式,但有一點需要記?。哼@個名字只在新定義的函數(shù)作用域內(nèi)有效,因為規(guī)范規(guī)定了標示符不能在外圍的作用域內(nèi)有效:

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

既然,這么要求,那命名函數(shù)表達式到底有啥用?。繛樯兑∶??

正如我們開頭所說:給它一個名字就是可以讓調(diào)試過程更方便,因為在調(diào)試的時候,如果在調(diào)用棧中的每個項都有自己的名字來描述,那么調(diào)試過程就太爽了,感受不一樣嘛。

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

如果一個函數(shù)有名字,那調(diào)試器在調(diào)試的時候會將它的名字顯示在調(diào)用的棧上。有些調(diào)試器(Firebug)有時候還會為你們函數(shù)取名并顯示,讓他們和那些應用該函數(shù)的便利具有相同的角色,可是通常情況下,這些調(diào)試器只安裝簡單的規(guī)則來取名,所以說沒有太大價格,我們來看一個例子:

  function foo(){
    return bar();
  }
  function bar(){
    return baz();
  }
  function baz(){
    debugger;
  }
  foo();
  // 這里我們使用了3個帶名字的函數(shù)聲明
  // 所以當調(diào)試器走到debugger語句的時候,F(xiàn)irebug的調(diào)用棧上看起來非常清晰明了 
  // 因為很明白地顯示了名稱
  baz
  bar
  foo
  expr_test.html()

通過查看調(diào)用棧的信息,我們可以很明了地知道 foo 調(diào)用了 bar,bar 又調(diào)用了 baz(而 foo 本身有在 expr_test.html 文檔的全局作用域內(nèi)被調(diào)用),不過,還有一個比較爽地方,就是剛才說的 Firebug 為匿名表達式取名的功能:

  function foo(){
    return bar();
  }
  var bar = function(){
    return baz();
  }
  function baz(){
    debugger;
  }
  foo();
  // Call stack
  baz
  bar() //看到了么? 
  foo
  expr_test.html()

然后,當函數(shù)表達式稍微復雜一些的時候,調(diào)試器就不那么聰明了,我們只能在調(diào)用棧中看到問號:

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

另外,當把函數(shù)賦值給多個變量的時候,也會出現(xiàn)令人郁悶的問題:

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

這時候,調(diào)用棧顯示的是 foo 調(diào)用了 bar,但實際上并非如此,之所以有這種問題,是因為 baz 和另外一個包含 alert('spoofed')的函數(shù)做了引用交換所導致的。

歸根結(jié)底,只有給函數(shù)表達式取個名字,才是最委托的辦法,也就是使用命名函數(shù)表達式。我們來使用帶名字的表達式來重寫上面的例子(注意立即調(diào)用的表達式塊里返回的 2 個函數(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()

OK,又學了一招吧?不過在高興之前,我們再看看不同尋常的 JScript 吧。

JScript的Bug

比較惡的是,IE 的 ECMAScrip t實現(xiàn) JScript 嚴重混淆了命名函數(shù)表達式,搞得現(xiàn)很多人都出來反對命名函數(shù)表達式,而且即便是最新的一版(IE8 中使用的 5.8 版)仍然存在下列問題。

下面我們就來看看IE在實現(xiàn)中究竟犯了那些錯誤,俗話說知已知彼,才能百戰(zhàn)不殆。我們來看看如下幾個例子:

例 1:函數(shù)表達式的標示符泄露到外部作用域

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

上面我們說過,命名函數(shù)表達式的標示符在外部作用域是無效的,但 JScript 明顯是違反了這一規(guī)范,上面例子中的標示符 g 被解析成函數(shù)對象,這就亂了套了,很多難以發(fā)現(xiàn)的 bug 都是因為這個原因?qū)е碌摹?/p>

注:IE9 貌似已經(jīng)修復了這個問題

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

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

特性環(huán)境下,函數(shù)聲明會優(yōu)先于任何表達式被解析,上面的例子展示的是 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),創(chuàng)建 2 個不同的對象,也就是說如果你想修改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,那么將會調(diào)用一個毫不相干的 g 函數(shù)對象。

你可能會問,將不同的對象和 arguments.callee 相比較時,有什么樣的區(qū)別呢?我們來看看:

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

可以看到,arguments.callee 的引用一直是被調(diào)用的函數(shù),實際上這也是好事,稍后會解釋。

還有一個有趣的例子,那就是在不包含聲明的賦值語句中使用命名函數(shù)表達式:

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

按照代碼的分析,我們原本是想創(chuàng)建一個全局屬性 f(注意不要和一般的匿名函數(shù)混淆了,里面用的是帶名字的生命),JScript 在這里搗亂了一把,首先他把表達式當成函數(shù)聲明解析了,所以左邊的f被聲明為局部變量了(和一般的匿名函數(shù)里的聲明一樣),然后在函數(shù)執(zhí)行的時候,f已經(jīng)是定義過的了,右邊的 function f(){}則直接就賦值給局部變量f了,所以f根本就不是全局屬性。

了解了 JScript 這么變態(tài)以后,我們就要及時預防這些問題了,首先防范標識符泄漏帶外部作用域,其次,應該永遠不引用被用作函數(shù)名稱的標識符;還記得前面例子中那個討人厭的標識符 g 嗎?——如果我們能夠當 g 不 存在,可以避免多少不必要的麻煩哪。因此,關(guān)鍵就在于始終要通過 f 或者 arguments.callee 來引用函數(shù)。如果你使用了命名函數(shù)表達式,那么應該只在調(diào)試的時候利用那個名字。最后,還要記住一點,一定要把命名函數(shù)表達式聲明期間錯誤創(chuàng)建的函數(shù)清理干凈。

對于,上面最后一點,我們還得再解釋一下。

JScript 的內(nèi)存管理

知道了這些不符合規(guī)范的代碼解析 bug 以后,我們?nèi)绻盟脑?,就會發(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 是在同一個作用域中被聲明的。這種情況下 ,除非我們顯式斷開對 g 函數(shù)的引用,否則它一直占著內(nèi)存不放。

  var f = (function(){
    var f, g;
    if (true) {
      f = function g(){};
    }
    else {
      f = function g(){};
    }
    // 設(shè)置g為null以后它就不會再占內(nèi)存了
    g = null;
    return f;
  })();

通過設(shè)置g為null,垃圾回收器就把g引用的那個隱式函數(shù)給回收掉了,為了驗證我們的代碼,我們來做一些測試,以確保我們的內(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 中的任務管理器可以看到如下結(jié)果:

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)存不是很多,10000 個函數(shù)對象才釋放大約 3M 的內(nèi)存,這對一些小型腳本不算什么,但對于大型程序,或者長時間運行在低內(nèi)存的設(shè)備里的時候,這是非常有必要的。

關(guān)于在 Safari 2.x 中 JS 的解析也有一些 bug,但介于版本比較低,所以我們在這里就不介紹了,大家如果想看的話,請仔細查看英文資料。

SpiderMonkey 的怪癖

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

在這里,有一點十分有意思,那就是 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> },由于該對象繼承自O(shè)bject.prototype,所以在此可以找到x。
      而這個x的值也就是Object.prototype.x的值(outer)。結(jié)果,外部函數(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ī)范中的一種機制。

那我們就來看看黑莓里都發(fā)生了什么:

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

不過神奇的還是,函數(shù)中的變量甚至會與已有的 Object.prototype 的成員發(fā)生沖突,來看看下面的代碼:

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

要避免這個問題,要避免使用 Object.prototype 里的屬性名稱,如 toString,valueOf, hasOwnProperty 等等。

JScript解決方案

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

最后我們給出一個應用上述技術(shù)的應用實例,這是一個跨瀏覽器的 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關(guān)鍵字
    //    除非函數(shù)頂部已經(jīng)聲明了addEvent
    var addEvent = null;
    // 5) 最后返回由fn引用的函數(shù)
    return fn;
  })();

替代方案

其實,如果我們不想要這個描述性名字的話,我們就可以用最簡單的形式來做,也就是在函數(shù)內(nèi)部聲明一個函數(shù)(而不是函數(shù)表達式),然后返回該函數(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ù)定義時,這個方案就不行了。不過有種模式貌似可以實現(xiàn):那就是提前使用函數(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)。當然,基于實現(xiàn)來命名的方式也不一定都行得通。假如我們要提供一個 API,并按照這種方式把函數(shù)命名為 inner。那么 API 用戶的很容易就會被相應實現(xiàn)的 細節(jié)搞得暈頭轉(zhuǎn)向。

要解決這個問題,當然就得想一套更合理的命名方案了。但關(guān)鍵是不要再額外制造麻煩。我現(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,那么 addEventListener 和 addEventAsProperty 則根本就用不著了。可是,他們都占著內(nèi)存哪;而且,這些內(nèi)存將永遠都得不到釋放,原因跟 JScript 臭哄哄的命名表達式相同——這兩個函數(shù)都被“截留”在返回的那個函數(shù)的閉包中了。

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

WebKit 的 displayName

WebKit 團隊在這個問題采取了有點兒另類的策略。介于匿名和命名函數(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);