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

2023-05-10 09:44 更新

前言

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

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

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

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

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

  函數(shù)聲明:

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

  函數(shù)表達(dá)式:

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

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

function foo(){} // 聲明,因?yàn)樗浅绦虻囊徊糠?var bar = function foo(){}; // 表達(dá)式,因?yàn)樗琴x值表達(dá)式的一部分

new function bar(){}; // 表達(dá)式,因?yàn)樗莕ew表達(dá)式

(function(){
function bar(){} // 聲明,因?yàn)樗呛瘮?shù)體的一部分
})();

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

function foo(){} // 函數(shù)聲明
(function foo(){}); // 函數(shù)表達(dá)式:包含在分組操作符內(nèi)

try {
(var x = 5); // 分組操作符,只能包含表達(dá)式而不能包含語(yǔ)句:這里的var就是語(yǔ)句
} catch(err) {
// SyntaxError
}

你可以會(huì)想到,在使用eval對(duì)JSON進(jìn)行執(zhí)行的時(shí)候,JSON字符串通常被包含在一個(gè)圓括號(hào)里:eval('(' + json + ')'),這樣做的原因就是因?yàn)榉纸M操作符,也就是這對(duì)括號(hào),會(huì)讓解析器強(qiáng)制將JSON的花括號(hào)解析成表達(dá)式而不是代碼塊。

try {
{ "x": 5 }; // "{" 和 "}" 做解析成代碼塊
} catch(err) {
// SyntaxError
}

({ "x": 5 }); // 分組操作符強(qiáng)制將"{" 和 "}"作為對(duì)象字面量來解析

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

alert(fn());

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

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

// 千萬別這樣做!
// 因?yàn)橛械臑g覽器會(huì)返回first的這個(gè)function,而有的瀏覽器返回的卻是第二個(gè)

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

// 相反,這樣情況,我們要用函數(shù)表達(dá)式
var foo;
if (true) {
foo = function() {
  return 'first';
};
}
else {
foo = function() {
  return 'second';
};
}
foo();

函數(shù)聲明的實(shí)際規(guī)則如下:

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

函數(shù)語(yǔ)句

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

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

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

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

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

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

// 此刻,foo還沒用聲明
typeof foo; // "undefined"
if (true) {
// 進(jìn)入這里以后,foo就被聲明在整個(gè)作用域內(nèi)了
function foo(){ return 1; }
}
else {
// 從來不會(huì)走到這里,所以這里的foo也不會(huì)被聲明
function foo(){ return 2; }
}
typeof foo; // "function"

不過,我們可以使用下面這樣的符合標(biāo)準(zhǔn)的代碼來模式上面例子中的函數(shù)語(yǔ)句:

var foo;
if (true) {
foo = function foo(){ return 1; };
}
else {
foo = function foo() { return 2; };
}

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

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

5.另外一個(gè),早期基于Gecko的實(shí)現(xiàn)(Firefox 3及以前版本)中存在一個(gè)bug,即函數(shù)語(yǔ)句覆蓋函數(shù)聲明的方式不正確。在這些早期的實(shí)現(xiàn)中,函數(shù)語(yǔ)句不知何故不能覆蓋函數(shù)聲明:

// 函數(shù)聲明
function foo(){ return 1; }
if (true) {
// 用函數(shù)語(yǔ)句重寫
function foo(){ return 2; }
}
foo(); // FF3以下返回1,F(xiàn)F3.5以上返回2

// 不過,如果前面是函數(shù)表達(dá)式,則沒用問題
var foo = function(){ return 1; };
if (true) {
function foo(){ return 2; }
}
foo(); // 所有版本都返回2

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

命名函數(shù)表達(dá)式

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

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

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

既然,這么要求,那命名函數(shù)表達(dá)式到底有啥用???為啥要取名?

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

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

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

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

// 這里我們使用了3個(gè)帶名字的函數(shù)聲明
// 所以當(dāng)調(diào)試器走到debugger語(yǔ)句的時(shí)候,F(xiàn)irebug的調(diào)用棧上看起來非常清晰明了 
// 因?yàn)楹苊靼椎仫@示了名稱
baz
bar
foo
expr_test.html()

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

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

// Call stack
baz
bar() //看到了么? 
foo
expr_test.html()

然后,當(dāng)函數(shù)表達(dá)式稍微復(fù)雜一些的時(shí)候,調(diào)試器就不那么聰明了,我們只能在調(diào)用棧中看到問號(hà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
(?)() // 這里可是問號(hào)哦
foo
expr_test.html()

另外,當(dāng)把函數(shù)賦值給多個(gè)變量的時(shí)候,也會(huì)出現(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()

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

歸根結(jié)底,只有給函數(shù)表達(dá)式取個(gè)名字,才是最委托的辦法,也就是使用命名函數(shù)表達(dá)式。我們來使用帶名字的表達(dá)式來重寫上面的例子(注意立即調(diào)用的表達(dá)式塊里返回的2個(gè)函數(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,又學(xué)了一招吧?不過在高興之前,我們?cè)倏纯床煌瑢こ5腏Script吧。

JScript的Bug

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

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

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

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

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

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

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

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

特性環(huán)境下,函數(shù)聲明會(huì)優(yōu)先于任何表達(dá)式被解析,上面的例子展示的是JScript實(shí)際上是把命名函數(shù)表達(dá)式當(dāng)成函數(shù)聲明了,因?yàn)樗趯?shí)際聲明之前就解析了g。

這個(gè)例子引出了下一個(gè)例子。
例3:命名函數(shù)表達(dá)式會(huì)創(chuàng)建兩個(gè)截然不同的函數(shù)對(duì)象!

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

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

看到這里,大家會(huì)覺得問題嚴(yán)重了,因?yàn)樾薷娜魏我粋€(gè)對(duì)象,另外一個(gè)沒有什么改變,這太惡了。通過這個(gè)例子可以發(fā)現(xiàn),創(chuàng)建2個(gè)不同的對(duì)象,也就是說如果你想修改f的屬性中保存某個(gè)信息,然后想當(dāng)然地通過引用相同對(duì)象的g的同名屬性來使用,那問題就大了,因?yàn)楦揪筒豢赡堋?/p>

再來看一個(gè)稍微復(fù)雜的例子:

例4:僅僅順序解析函數(shù)聲明而忽略條件語(yǔ)句塊

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

這個(gè)bug查找就難多了,但導(dǎo)致bug的原因卻非常簡(jiǎn)單。首先,g被當(dāng)作函數(shù)聲明解析,由于JScript中的函數(shù)聲明不受條件代碼塊約束,所以在這個(gè)很惡的if分支中,g被當(dāng)作另一個(gè)函數(shù)function g(){ return 2 },也就是又被聲明了一次。然后,所有“常規(guī)的”表達(dá)式被求值,而此時(shí)f被賦予了另一個(gè)新創(chuàng)建的對(duì)象的引用。由于在對(duì)表達(dá)式求值的時(shí)候,永遠(yuǎn)不會(huì)進(jìn)入“這個(gè)可惡if分支,因此f就會(huì)繼續(xù)引用第一個(gè)函數(shù)function g(){ return 1 }。分析到這里,問題就很清楚了:假如你不夠細(xì)心,在f中調(diào)用了g,那么將會(huì)調(diào)用一個(gè)毫不相干的g函數(shù)對(duì)象。

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

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

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

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

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

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

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

對(duì)于,上面最后一點(diǎn),我們還得再解釋一下。

JScript的內(nèi)存管理

知道了這些不符合規(guī)范的代碼解析bug以后,我們?nèi)绻盟脑?,就?huì)發(fā)現(xiàn)內(nèi)存方面其實(shí)是有問題的,來看一個(gè)例子:

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

我們知道,這個(gè)匿名函數(shù)調(diào)用返回的函數(shù)(帶有標(biāo)識(shí)符g的函數(shù)),然后賦值給了外部的f。我們也知道,命名函數(shù)表達(dá)式會(huì)導(dǎo)致產(chǎn)生多余的函數(shù)對(duì)象,而該對(duì)象與返回的函數(shù)對(duì)象不是一回事。所以這個(gè)多余的g函數(shù)就死在了返回函數(shù)的閉包中了,因此內(nèi)存問題就出現(xiàn)了。這是因?yàn)閕f語(yǔ)句內(nèi)部的函數(shù)與g是在同一個(gè)作用域中被聲明的。這種情況下 ,除非我們顯式斷開對(duì)g函數(shù)的引用,否則它一直占著內(nèi)存不放。

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

通過設(shè)置g為null,垃圾回收器就把g引用的那個(gè)隱式函數(shù)給回收掉了,為了驗(yàn)證我們的代碼,我們來做一些測(cè)試,以確保我們的內(nèi)存被回收了。

測(cè)試

測(cè)試很簡(jiǎn)單,就是命名函數(shù)表達(dá)式創(chuàng)建10000個(gè)函數(shù),然后把它們保存在一個(gè)數(shù)組中。等一會(huì)兒以后再看這些函數(shù)到底占用了多少內(nèi)存。然后,再斷開這些引用并重復(fù)這一過程。下面是測(cè)試代碼:

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

通過運(yùn)行在Windows XP SP2中的任務(wù)管理器可以看到如下結(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個(gè)函數(shù)對(duì)象才釋放大約3M的內(nèi)存,這對(duì)一些小型腳本不算什么,但對(duì)于大型程序,或者長(zhǎng)時(shí)間運(yùn)行在低內(nèi)存的設(shè)備里的時(shí)候,這是非常有必要的。

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

SpiderMonkey的怪癖

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

在這里,有一點(diǎn)十分有意思,那就是ECMA-262定義這個(gè)(保存函數(shù)標(biāo)識(shí)符的)“特殊”對(duì)象的方式。標(biāo)準(zhǔn)說“像調(diào)用new Object()表達(dá)式那樣”創(chuàng)建這個(gè)對(duì)象。如果從字面上來理解這句話,那么這個(gè)對(duì)象就應(yīng)該是全局Object的一個(gè)實(shí)例。然而,只有一個(gè)實(shí)現(xiàn)是按照標(biāo)準(zhǔn)字面上的要求這么做的,這個(gè)實(shí)現(xiàn)就是SpiderMonkey。因此,在SpiderMonkey中,擴(kuò)展Object.prototype有可能會(huì)干擾函數(shù)的局部作用域:

Object.prototype.x = 'outer';

(function(){

var x = 'inner';

/*函數(shù)foo的作用域鏈中有一個(gè)特殊的對(duì)象——用于保存函數(shù)的標(biāo)識(shí)符。這個(gè)特殊的對(duì)象實(shí)際上就是{ foo:  }。
  當(dāng)通過作用域鏈解析x時(shí),首先解析的是foo的局部環(huán)境。如果沒有找到x,則繼續(xù)搜索作用域鏈中的下一個(gè)對(duì)象。下一個(gè)對(duì)象
  就是保存函數(shù)標(biāo)識(shí)符的那個(gè)對(duì)象——{ foo:  },由于該對(duì)象繼承自O(shè)bject.prototype,所以在此可以找到x。
  而這個(gè)x的值也就是Object.prototype.x的值(outer)。結(jié)果,外部函數(shù)的作用域(包含x = 'inner'的作用域)就不會(huì)被解析了。 */

(function foo(){

  alert(x); // 提示框中顯示:outer

})();
})();

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

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

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

Object.prototype.x = 'outer';

(function(){

var x = 'inner';

(function(){

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

  alert(x); // 顯示:outer

})();
})();

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

(function(){

var constructor = function(){ return 1; };

(function(){

  constructor(); // 求值結(jié)果是{}(即相當(dāng)于調(diào)用了Object.prototype.constructor())而不是1

  constructor === Object.prototype.constructor; // true
  toString === Object.prototype.toString; // true

  // ……

})();
})();

要避免這個(gè)問題,要避免使用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(){ }
}

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

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

最后我們給出一個(gè)應(yīng)用上述技術(shù)的應(yīng)用實(shí)例,這是一個(gè)跨瀏覽器的addEvent函數(shù)代碼:

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

var docEl = document.documentElement;

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

if (docEl.addEventListener) {

  // 3) 有意給函數(shù)一個(gè)描述性的標(biāo)識(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í),如果我們不想要這個(gè)描述性名字的話,我們就可以用最簡(jiǎn)單的形式來做,也就是在函數(shù)內(nèi)部聲明一個(gè)函數(shù)(而不是函數(shù)表達(dá)式),然后返回該函數(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;
})();

顯然,當(dāng)存在多個(gè)分支函數(shù)定義時(shí),這個(gè)方案就不行了。不過有種模式貌似可以實(shí)現(xiàn):那就是提前使用函數(shù)聲明來定義所有函數(shù),并分別為這些函數(shù)指定不同的標(biāo)識(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;
})();

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

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

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

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

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

WebKit的displayName

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

未來考慮

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

// 此前,你可能會(huì)使用arguments.callee
(function(x) {
if (x return 1;
return x * arguments.callee(x - 1);
})(10);

// 但在嚴(yán)格模式下,有可能就要使用命名函數(shù)表達(dá)式
(function factorial(x) {
if (x return 1;
return x * factorial(x - 1);
})(10);

// 要么就退一步,使用沒有那么靈活的函數(shù)聲明
function factorial(x) {
if (x return 1;
return x * factorial(x - 1);
}
factorial(10);

致謝

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

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

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

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

英文原文:http://kangax.github.com/nfe/

參考譯文:連接訪問 (SpiderMonkey的怪癖之后的章節(jié)參考該文)

以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)