<a name="a1"></a>
# 函數
熟練運用函數是JavaScript程序員的必備技能,因為在JavaScript中函數實在是太常用了。它能夠完成的任務種類非常之多,而在其他語言中則需要很多特殊的語法支持才能達到這種能力。
在本章將會介紹在JavaScript中定義函數的多種方式,包括函數表達式和函數聲明、以及局部作用域和變量聲明提前的工作原理。然后會介紹一些有用的模式,幫助你設計API(為你的函數提供更好的接口)、搭建代碼架構(使用盡可能少的全局對象)、并優化性能(避免不必要的操作)。
現在讓我們來一起揭秘JavaScript函數,我們首先從一些背景知識開始說起。
<a name="a2"></a>
## 背景知識
JavaScript的函數具有兩個主要特性,正是這兩個特性讓它們與眾不同。第一個特性是,函數是一等對象(first-class object),第二個是函數提供作用域支持。
函數是對象,那么:
- 可以在程序執行時動態創建函數
- 可以將函數賦值給變量,可以將函數的引用拷貝至另一個變量,可以擴充函數,除了某些特殊場景外均可被刪除。
- 可以將函數作為參數傳入另一個函數,也可以被當作返回值返回。
- 函數可以包含自己的屬性和方法
對于一個函數A來說,首先它是對象,擁有屬性和方法,其中某個屬性碰巧是另一個函數B,B可以接受函數作為參數,假設這個函數參數為C,當執行B的時候,返回另一個函數D。乍一看這里有一大堆相互關聯的函數。當你開始習慣函數的許多用法時,你會驚嘆原來函數是如此強大、靈活并富有表現力。通常說來,一說到JavaScript的函數,我們首先認為它是對象,它具有一個可以“執行”的特性,也就是說我們可以“調用”這個函數。
我們通過new Function()構造器來生成一個函數,這時可以明顯看出函數是對象:
// antipattern
// for demo purposes only
var add = new Function('a, b', 'return a + b');
add(1, 2); // returns 3
在這段代碼中,毫無疑問add()是一個對象,畢竟它是由構造函數創建的。這里并不推薦使用Function()構造器創建函數(和eval()一樣糟糕),因為程序邏輯代碼是以字符串的形式傳入構造器的。這樣的代碼可讀性差,寫起來也很費勁,你不得不對邏輯代碼中的引號做轉義處理,并需要特別關注為了讓代碼保持一定的可讀性而保留的空格和縮進。
函數的第二個重要特性是它能提供作用域支持。在JavaScript中沒有塊級作用域(譯注:在JavaScript1.7中提供了塊級作用域部分特性的支持,可以通過let來聲明塊級作用域內的“局部變量”),也就是說不能通過花括號來創建作用域,JavaScript中只有函數作用域(譯注:這里作者的表述只針對函數而言,此外JavaScript還有全局作用域)。在函數內所有通過var聲明的變量都是局部變量,在函數外部是不可見的。剛才所指花括號無法提供作用域支持的意思是說,如果在if條件句內、或在for或while循環體內用var定義了變量,這個變量并不是屬于if語句或for(while)循環的局部變量,而是屬于它所在的函數。如果不在任何函數內部,它會成為全局變量。在第二章里提到我們要減少對全局命名空間的污染,那么使用函數則是控制變量的作用域的不二之選。
<a name="a3"></a>
### 術語釋義
首先我們先簡單討論下創建函數相關的術語,因為精確無歧義的術語約定和我們所討論的各種模式一樣重要。
看下這個代碼片段:
// named function expression
var add = function add(a, b) {
return a + b;
};
這段代碼描述了一個函數,這種描述稱為“帶有命名的函數表達式”。
如果函數表達式將名字省略掉(比如下面的示例代碼),這時它是“無名字的函數表達式”,通常我們稱之為“匿名函數”,比如:
// function expression, a.k.a. anonymous function
var add = function (a, b) {
return a + b;
};
因此“函數表達式”是一個更廣義的概念,“帶有命名的函數表達式”是函數表達式的一種特殊形式,僅僅當需要給函數定義一個可選的名字時使用。
當省略第二個add,它就成了無名字的函數表達式,這不會對函數定義和調用語法造成任何影響。帶名字和不帶名字唯一的區別是函數對象的name屬性是否是一個空字符串。name屬性屬于語言的擴展(未在ECMA標準中定義),但很多環境都實現了。如果不省略第二個add,那么屬性add.name則是"add",name屬性在用Firebug的調試過程中非常有用,還能讓函數遞歸調用自身,其他情況可以省略它。
最后來看一下“函數聲明”,函數聲明的語法和其他語言中的語法非常類似:
function foo() {
// function body goes here
}
從語法角度講,帶有命名的函數表達式和函數聲明非常像,特別是當不需要將函數表達式賦值給一個變量的時候(在本章后面所講到的回調模式中有類似的例子)。多數情況下,函數聲明和帶命名的函數表達式在外觀上沒有多少不同,只是它們在函數執行時對上下文的影響有所區別,下一小節會講到。
兩種語法的一個區別是末尾的分號。函數聲明末尾不需要分號,而函數表達式末尾是需要分號的。推薦你始終不要丟掉函數表達式末尾的分號,即便JavaScript可以進行分號補全,也不要冒險這樣做。
>另外我們經常看到“函數直接量”。它用來表示函數表達式或帶命名的函數表達式。由于這個術語是有歧義的,所以最好不要用它。
<a name="a4"></a>
### 聲明 vs 表達式:命名與提前
那么,到底應該用哪個呢?函數聲明還是函數表達式?在不能使用函數聲明語法的場景下,只能使用函數表達式了。下面這個例子中,我們給函數傳入了另一個函數對象作為參數,以及給對象定義方法:
// this is a function expression,
// pased as an argument to the function `callMe`
callMe(function () {
// I am an unnamed function expression
// also known as an anonymous function
});
// this is a named function expression
callMe(function me() {
// I am a named function expression
// and my name is "me"
});
// another function expression
var myobject = {
say: function () {
// I am a function expression
}
};
函數聲明只能出現在“程序代碼”中,也就是說在別的函數體內或在全局。這個定義不能賦值給變量或屬性,同樣不能作為函數調用的參數。下面這個例子是函數聲明的合法用法,這里所有的函數foo(),bar()和local()都使用函數聲明來定義:
// global scope
function foo() {}
function local() {
// local scope
function bar() {}
return bar;
}
<a name="a5"></a>
### 函數的name屬性
選擇函數定義模式的另一個考慮是只讀屬性name的可用性。盡管標準規范中并未規定,但很多運行環境都實現了name屬性,在函數聲明和帶有名字的函數表達式中是有name的屬性定義的。在匿名函數表達式中,則不一定有定義,這個是和實現相關的,在IE中是無定義的,在Firefox和Safari中是有定義的,但是值為空字符串。
function foo() {} // declaration
var bar = function () {}; // expression
var baz = function baz() {}; // named expression
foo.name; // "foo"
bar.name; // ""
baz.name; // "baz"
在Firebug或其他工具中調試程序時name屬性非常有用,它可以用來顯示當前正在執行的函數。同樣可以通過name屬性來遞歸的調用函數自身。如果你對這些場景不感興趣,那么請盡可能的使用匿名函數表達式,這樣會更簡單、且冗余代碼更少。
和函數聲明相比而言,函數表達式的語法更能說明函數是一種對象,而不是某種特別的語言寫法。
>我們可以將一個帶名字的函數表達式賦值給變量,變量名和函數名不同,這在技術上是可行的。比如:`var foo = function bar(){};`。然而,這種用法的行為在瀏覽器中的兼容性不佳(特別是IE中),因此并不推薦大家使用這種模式。
<a name="a6"></a>
### 函數提前
通過前面的講解,你可能以為函數聲明和帶名字的函數表達式是完全等價的。事實上不是這樣,主要區別在于“聲明提前”的行為。
>術語“提前”并未在ECMAScript中定義,但是并沒有其他更好的方法來描述這種行為了。
我們知道,不管在函數內何處聲明變量,變量都會自動提前至函數體的頂部。對于函數來說亦是如此,因為他們也是一種對象,賦值給了變量。需要注意的是,函數聲明定義的函數不僅能讓聲明提前,還能讓定義提前,看一下這段示例代碼:
// antipattern
// for illustration only
// global functions
function foo() {
alert('global foo');
}
function bar() {
alert('global bar');
}
function hoistMe() {
console.log(typeof foo); // "function"
console.log(typeof bar); // "undefined"
foo(); // "local foo"
bar(); // TypeError: bar is not a function
// function declaration:
// variable 'foo' and its implementation both get hoisted
function foo() {
alert('local foo');
}
// function expression:
// only variable 'bar' gets hoisted
// not the implementation
var bar = function () {
alert('local bar');
};
}
hoistMe();
在這段代碼中,和普通的變量一樣,hoistMe()函數中的foo和bar被“搬運”到了頂部,覆蓋了全局的foo和bar。不同之處在于,局部的foo()定義提前至頂部并能正常工作,盡管定義它的位置并不靠前。bar()的定義并未提前,只是聲明提前了。因此當程序執行到bar()定義的位置之前,它的值都是undefined,并不是函數(防止當前上下文查找到作用域鏈上的全局的bar(),也就“覆蓋”了全局的bar())。
到目前為止我們介紹了必要的背景知識和函數定義相關的術語,下面開始介紹一些JavaScript所提供的函數相關的好的模式,我們從回調模式開始。同樣,再次強調JavaScript函數的兩個特殊特性,掌握這兩點至關重要:
- 函數是對象
- 函數提供局部變量作用域
<a name="a7"></a>
## 回調模式
函數是對象,也就意味著函數可以當作參數傳入另外一個函數中。當你給函數writeCode()傳入一個函數參數introduceBugs(),在某個時刻writeCode()執行了(或調用了)introduceBugs()。在這種情況下,我們說introduceBugs()是一個“回調函數”,簡稱“回調”:
function writeCode(callback) {
// do something...
callback();
// ...
}
function introduceBugs() {
// ... make bugs
}
writeCode(introduceBugs);
注意introduceBugs()是如何作為參數傳入writeCode()的,當作參數的函數不帶括號。括號的意思是執行函數,而這里我們希望傳入一個引用,讓writeCode()在合適的時機執行它(調用它)。
<a name="a8"></a>
### 一個回調的例子
我們從一個例子開始,首先介紹無回調的情況,然后在作修改。假設你有一個通用的函數,用來完成某種復雜的邏輯并返回一大段數據。假設我們用findNodes()來命名這個通用函數,這個函數用來對DOM樹進行遍歷,并返回我所感興趣的頁面節點:
var findNodes = function () {
var i = 100000, // big, heavy loop
nodes = [], // stores the result
found; // the next node found
while (i) {
i -= 1;
// complex logic here...
nodes.push(found);
}
return nodes;
};
保持這個函數的功能的通用性并一貫返回DOM節點組成的數組,并不會發生對節點的實際操作,這是一個不錯的注意。可以將操作節點的邏輯放入另外一個函數中,比如放入一個hide()函數中,這個函數用來隱藏頁面中的節點元素:
var hide = function (nodes) {
var i = 0, max = nodes.length;
for (; i < max; i += 1) {
nodes[i].style.display = "none";
}
};
// executing the functions
hide(findNodes());
這個實現的效率并不高,因為它將findNodes()所返回的節點數組重新遍歷了一遍。最好在findNodes()中選擇元素的時候就直接應用hide()操作,這樣就能避免第二次的遍歷,從而提高效率。但如果將hide()的邏輯寫死在findNodes()的函數體內,findNodes()就變得不再通用了(譯注:如果我將hide()的邏輯替換成其他邏輯怎么辦呢?),因為修改邏輯和遍歷邏輯耦合在一起了。如果使用回調模式,則可以將隱藏節點的邏輯寫入回調函數,將其傳入findNodes()中適時執行:
// refactored findNodes() to accept a callback
var findNodes = function (callback) {
var i = 100000,
nodes = [],
found;
// check if callback is callable
if (typeof callback !== "function") {
callback = false;
}
while (i) {
i -= 1;
// complex logic here...
// now callback:
if (callback) {
callback(found);
}
nodes.push(found);
}
return nodes;
};
這里的實現比較直接,findNodes()多作了一個額外工作,就是檢查回調函數是否存在,如果存在的話就執行它。回調函數是可選的,因此修改后的findNodes()也是和之前一樣使用,是可以兼容舊代碼和舊API的。
這時hide()的實現就非常簡單了,因為它不用對元素列表做任何遍歷了:
// a callback function
var hide = function (node) {
node.style.display = "none";
};
// find the nodes and hide them as you go
findNodes(hide);
正如代碼中所示,回調函數可以是事先定義好的,也可以是一個匿名函數,你也可以將其稱作main函數,比如這段代碼,我們利用同樣的通用函數findNodes()來完成顯示元素的操作:
// passing an anonymous callback
findNodes(function (node) {
node.style.display = "block";
});
<a name="a9"></a>
### 回調和作用域
在上一個例子中,執行回調函數的寫法是:
callback(parameters);
盡管這種寫法可以適用大多數的情況,而且足夠簡單,但還有一些場景,回調函數不是匿名函數或者全局函數,而是對象的方法。如果回調函數中使用this指向它所屬的對象,則回調邏輯往往并不像我們希望的那樣執行。
假設回調函數是paint(),它是myapp的一個方法:
var myapp = {};
myapp.color = "green";
myapp.paint = function (node) {
node.style.color = this.color;
};
函數findNodes()大致如下:
var findNodes = function (callback) {
// ...
if (typeof callback === "function") {
callback(found);
}
// ...
};
當你調用findNodes(myapp.paint),運行結果和我們期望的不一致,因為this.color未定義。因為findNodes()是全局函數,this指向的是全局對象。如果findNodes()是dom對象的方法(類似dom.findNodes()),那么回調函數內的this則指向dom,而不是myapp。
解決辦法是,除了傳入回調函數,還需將回調函數所屬的對象當作參數傳進去:
findNodes(myapp.paint, myapp);
同樣需要修改findNodes()的邏輯,增加對傳入的對象的綁定:
var findNodes = function (callback, callback_obj) {
//...
if (typeof callback === "function") {
callback.call(callback_obj, found);
}
// ...
};
在后續的章節會對call()和apply()有更詳細的講述。
其實還有一種替代寫法,就是將函數當作字符串傳入findNodes(),這樣就不必再寫一次對象了,換句話說:
findNodes(myapp.paint, myapp);
可以寫成:
findNodes("paint", myapp);
在findNodes()中的邏輯則需要修改為:
var findNodes = function (callback, callback_obj) {
if (typeof callback === "string") {
callback = callback_obj[callback];
}
//...
if (typeof callback === "function") {
callback.call(callback_obj, found);
}
// ...
};
<a name="a10"></a>
### 異步事件監聽
JavaScript中的回調模式已經是我們的家常便飯了,比如,如果你給網頁中的元素綁定事件,則需要提供回調函數的引用,以便事件發生時能調用到它。這里有一個簡單的例子,我們將console.log()作為回調函數綁定了document的點擊事件:
document.addEventListener("click", console.log, false);
客戶端瀏覽器中的大多數編程都是事件驅動的,當網頁下載完成,則觸發load事件,當用戶和頁面產生交互時也會觸發多種事件,比如click、keypress、mouseover、mousemove等等。正是由于回調模式的靈活性,JavaScript天生適于事件驅動編程。回調模式能夠讓程序“異步”執行,換句話說,就是讓程序不按順序執行。
“不要打電話給我,我會打給你”,這是好萊塢很有名的一句話,很多電影都有這句臺詞。電影中的主角不可能同時應答很多個電話呼叫。在JavaScript的異步事件模型中也是同樣的道理。電影中是留下電話號碼,JavaScript中是提供一個回調函數,當時機成熟時就觸發回調。有時甚至提供了很多回調,有些回調壓根是沒用的,但由于這個事件可能永遠不會發生,因此這些回調的邏輯也不會執行。比如,假設你從此不再用“鼠標點擊”,那么你之前綁定的鼠標點擊的回調函數則永遠也不會執行。
<a name="a11"></a>
### 超時
另外一個最常用的回調模式是在調用超時函數時,超時函數是瀏覽器window對象的方法,共有兩個:setTimeout()和setInterval()。這兩個方法的參數都是回調函數。
var thePlotThickens = function () {
console.log('500ms later...');
};
setTimeout(thePlotThickens, 500);
再次需要注意,函數thePlotThickens是作為變量傳入setTimeout的,它不帶括號,如果帶括號的話則立即執行了,這里只是用到這個函數的引用,以便在setTimeout的邏輯中調用到它。也可以傳入字符串“thePlotThickens()”,但這是一種反模式,和eval()一樣不推薦使用。
<a name="a12"></a>
### 庫中的回調
回調模式非常簡單,但又很強大。可以隨手拈來靈活運用,因此這種模式在庫的設計中也非常得寵。庫的代碼要盡可能的保持通用和重用,而回調模式則可幫助庫的作者完成這個目標。你不必預料和實現你所想到的所有情形,因為這會讓庫變的膨脹而臃腫,而且大多數用戶并不需要這些多余的特性支持。相反,你將精力放在核心功能的實現上,提供回調的入口作為“鉤子”,可以讓庫的方法變得可擴展、可定制。
<a name="a13"></a>
## 返回函數
函數是對象,因此當然可以作為返回值。也就是說,函數不一定非要返回一坨數據,函數可以返回另外一個定制好的函數,或者可以根據輸入的不同按需創造另外一個函數。
這里有一個簡單的例子:一個函數完成了某種功能,可能是一次性初始化,然后都基于這個返回值進行操作,這個返回值恰巧是另一個函數:
var setup = function () {
alert(1);
return function () {
alert(2);
};
};
// using the setup function
var my = setup(); // alerts 1
my(); // alerts 2
因為setup()把返回的函數作了包裝,它創建了一個閉包,我們可以用這個閉包來存儲一些私有數據,這些私有數據可以通過返回的函數進行操作,但在函數外部不能直接讀取到這些私有數據。比如這個例子中提供了一個計數器,每次調用這個函數計數器都會加一:
var setup = function () {
var count = 0;
return function () {
return (count += 1);
};
};
// usage
var next = setup();
next(); // returns 1
next(); // 2
next(); // 3
<a name="a14"></a>
## 自定義函數
我們動態定義函數,并將函數賦值給變量。如果將你定義的函數賦值給已經存在的函數變量的話,則新函數會覆蓋舊函數。這樣做的結果是,舊函數的引用就丟棄掉了,變量中所存儲的引用值替換成了新的。這樣看起來這個變量指代的函數邏輯就發生了變化,或者說函數進行了“重新定義”或“重寫”。說起來有些拗口,實際上并不復雜,來看一個例子:
var scareMe = function () {
alert("Boo!");
scareMe = function () {
alert("Double boo!");
};
};
// using the self-defining function
scareMe(); // Boo!
scareMe(); // Double boo!
當函數中包含一些初始化操作,并希望這些初始化只執行一次,那么這種模式是非常適合這個場景的。因為能避免的重復執行則盡量避免,函數的一部分可能再也不會執行到。在這個場景中,函數執行一次后就被重寫為另外一個函數了。
使用這種模式可以幫助提高應用的執行效率,因為重新定義的函數執行更少的代碼。
>這種模式的另外一個名字是“函數的懶惰定義”,因為直到函數執行一次后才重新定義,可以說它是“某個時間點之后才存在”,簡稱“懶惰定義”。
這種模式有一種明顯的缺陷,就是之前給原函數添加的功能在重定義之后都丟失了。如果將這個函數定義為不同的名字,函數賦值給了很多不同的變量,或作為對象的方法使用,那么新定義的函數有可能不會執行,原始的函數會照舊執行(譯注:由于函數的賦值是引用的賦值,函數賦值給多個變量只是將引用賦值給了多個變量,當某一個變量定義了新的函數,也只是變量的引用值發生變化,原函數本身依舊存在,當程序中存在某個變量的引用還是舊函數的話,舊函數還是會依舊執行)。
讓我們來看一個例子,scareMe()函數在這里作為一等對象來使用:
1. 給他增加了一個屬性
2. 函數對象賦值給一個新變量
3. 函數依舊可以作為方法來調用
看一下這段代碼:
// 1. adding a new property
scareMe.property = "properly";
// 2. assigning to a different name
var prank = scareMe;
// 3. using as a method
var spooky = {
boo: scareMe
};
// calling with a new name
prank(); // "Boo!"
prank(); // "Boo!"
console.log(prank.property); // "properly"
// calling as a method
spooky.boo(); // "Boo!"
spooky.boo(); // "Boo!"
console.log(spooky.boo.property); // "properly"
// using the self-defined function
scareMe(); // Double boo!
scareMe(); // Double boo!
console.log(scareMe.property); // undefined
從結果來看,當自定義函數被賦值給一個新的變量的時候,這段使用自定義函數的代碼的執行結果與我們期望的結果可能并不一樣。每當prank()運行的時候,它都彈出“Boo!”。同時它也重寫了scareMe()函數,但是prank()自己仍然能夠使用之前的定義,包括屬性property。在這個函數被作為spooky對象的boo()方法調用的時候,結果也一樣。所有的這些調用,在第一次的時候就已經修改了全局的scareMe()的指向,所以當它最終被調用的時候,它的函數體已經被修改為彈出“Double boo”。它也就不能獲取到新添加的屬性“property”。
<a name="a15"></a>
## 立即執行的函數
立即執行的函數是一種語法模式,它會使函數在定義后立即執行。看這個例子:
(function () {
alert('watch out!');
}());
這種模式本質上只是一個在創建后就被執行的函數表達式(具名或者匿名)。“立即執行的函數”這種說法并沒有在ECMAScript標準中被定義,但它作為一個名詞,有助于我們的描述和討論。
這種模式由以下幾個部分組成:
- 使用函數表達式定義一個函數。(不能使用函數聲明。)
- 在最后加入一對括號,這會使函數立即被執行。
- 把整個函數包裹到一對括號中(只在沒有將函數賦值給變量時需要)。
下面這種語法也很常見(注意右括號的位置),但是JSLint傾向于第一種:
(function () {
alert('watch out!');
})();
這種模式很有用,它為我們提供一個作用域的沙箱,可以在執行一些初始化代碼的時候使用。設想這樣的場景:當頁面加載的時候,你需要運行一些代碼,比如綁定事件、創建對象等等。所有的這些代碼都只需要運行一次,所以沒有必要創建一個帶有名字的函數。但是這些代碼需要一些臨時變量,而這些變量在初始化完之后又不會再次用到。顯然,把這些變量作為全局變量聲明是不合適的。正因為如此,我們才需要立即執行的函數。它可以把你所有的代碼包裹到一個作用域里面,而不會暴露任何變量到全局作用域中:
(function () {
var days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
today = new Date(),
msg = 'Today is ' + days[today.getDay()] + ', ' + today.getDate();
alert(msg);
}()); // "Today is Fri, 13"
如果這段代碼沒有被包裹到立即執行函數中,那么變量days、today、msg都會是全局變量,而這些變量僅僅是由因為初始化而遺留下來的垃圾,沒有任何用處。
<a name="a16"></a>
### 立即執行的函數的參數
立即執行的函數也可以接受參數,看這個例子:
// prints:
// I met Joe Black on Fri Aug 13 2010 23:26:59 GMT-0800 (PST)
(function (who, when) {
console.log("I met " + who + " on " + when);
}("Joe Black", new Date()));
通常的做法,會把全局對象當作一個參數傳給立即執行的函數,以保證在函數內部也可以訪問到全局對象,而不是使用window對象,這樣可以使得代碼在非瀏覽器環境中使用時更具可移植性。
值得注意的是,一般情況下盡量不要給立即執行的函數傳入太多的參數,否則會有一件麻煩的事情,就是你在閱讀代碼的時候需要頻繁地上下滾動代碼。
<a name="a17"></a>
### 立即執行的函數的返回值
和其它的函數一樣,立即執行的函數也可以返回值,并且這些返回值也可以被賦值給變量:
var result = (function () {
return 2 + 2;
}());
如果省略括號的話也可以達到同樣的目的,因為如果需要將返回值賦給變量,那么第一對括號就不是必需的。省略括號的代碼是這樣子:
var result = function () {
return 2 + 2;
}();
這種寫法更簡潔,但是同時也容易造成誤解。如果有人在閱讀代碼的時候忽略了最后的一對括號,那么他會以為result指向了一個函數。而事實上result是指向這個函數運行后的返回值,在這個例子中是4。
還有一種寫法也可以得到同樣的結果:
var result = (function () {
return 2 + 2;
})();
前面的例子中,立即執行的函數返回的是一個基本類型的數值。但事實上,除了基本類型以外,一個立即執行的函數可以返回任意類型的值,甚至返回一個函數都可以。你可以利用立即執行的函數的作用域來存儲一些私有的數據,這些數據只能在返回的內層函數中被訪問。
在下面的例子中,立即執行的函數的返回值是一個函數,這個函數會簡單地返回res的值,并且它被賦給了變量getResult。而res是一個預先計算好的變量,它被存儲在立即執行函數的閉包中:
var getResult = (function () {
var res = 2 + 2;
return function () {
return res;
};
}());
在定義一個對象的屬性的時候也可以使用立即執行的函數。設想一下這樣的場景:你需要定義一個對象的屬性,這個屬性在對象的生命周期中都不會改變,但是在定義之前,你需要做一點額外的工作來得到正確的值。這種情況下你就可以使用立即執行的函數來包裹那些額外的工作,然后將它的返回值作為對象屬性的值。下面是一個例子:
var o = {
message: (function () {
var who = "me",
what = "call";
return what + " " + who;
}()),
getMsg: function () {
return this.message;
}
};
// usage
o.getMsg(); // "call me"
o.message; // "call me"
在這個例子中,o.message是一個字符串,而不是一個函數,但是它需要一個函數在腳本載入后來得到這個屬性值。
<a name="a18"></a>
### 好處和用法
立即執行的函數應用很廣泛。它可以幫助我們做一些不想留下全局變量的工作。所有定義的變量都只是立即執行的函數的本地變量,你完全不用擔心臨時變量會污染全局對象。
> 立即執行的函數還有一些名字,比如“自調用函數”或者“自執行函數”,因為這些函數會在被定義后立即執行自己。
這種模式也經常被用到書簽代碼中,因為書簽代碼會在任何一個頁面運行,所以需要非常苛刻地保持全局命名空間干凈。
這種模式也可以讓你包裹一些獨立的特性到一個封閉的模塊中。設想你的頁面是靜態的,在沒有JavaScript的時候工作正常,然后,本著漸進增強的精神,你給頁面加入了一點增加代碼。這時候,你就可以把你的代碼(也可以叫“模塊”或者“特性”)放到一個立即執行的函數中并且保證頁面在有沒有它的時候都可以正常工作。然后你就可以加入更多的增強特性,或者對它們進行移除、進行獨立測試或者允許用戶禁用等等。
你可以使用下面的模板定義一段函數代碼,我們叫它module1:
// module1 defined in module1.js
(function () {
// all the module 1 code ...
}());
套用這個模板,你就可以編寫其它的模塊。然后在發布到線上的時候,你就可以決定在這個時間節點上哪些特性是可以使用的,然后使用發布腳本將它們打包上線。
<a name="a19"></a>
## 立即初始化的對象
還有另外一種可以避免污染全局作用域的方法,和前面描述的立即執行的函數相似,叫做“立即初始化的對象”模式。這種模式使用一個帶有init()方法的對象來實現,這個方法在對象被創建后立即執行。初始化的工作由init()函數來完成。
下面是一個立即初始化的對象模式的例子:
({
// here you can define setting values
// a.k.a. configuration constants
maxwidth: 600,
maxheight: 400,
// you can also define utility methods
gimmeMax: function () {
return this.maxwidth + "x" + this.maxheight;
},
// initialize
init: function () {
console.log(this.gimmeMax());
// more init tasks...
}
}).init();
在語法上,當你使用這種模式的時候就像在使用對象字面量創建一個普通對象一樣。除此之外,還需要將對象字面量用括號括起來,這樣能讓JavaScript引擎知道這是一個對象字面量,而不是一個代碼塊(if或者for循環之類)。在括號后面,緊接著就執行了init()方法。
你也可以將對象字面量和init()調用一起寫到括號里面。簡單地說,下面兩種語法都是有效的:
({...}).init();
({...}.init());
這種模式的好處和自動執行的函數模式是一樣的:在做一些一次性的初始化工作的時候保護全局作用域不被污染。從語法上看,這種模式似乎比只包含一段代碼在一個匿名函數中要復雜一些,但是如果你的初始化工作比較復雜(這種情況很常見),它會給整個初始化工作一個比較清晰的結構。比如,一些私有的輔助性函數可以被很輕易地看出來,因為它們是這個臨時對象的屬性,但是如果是在立即執行的函數模式中,它們很可能只是一些散落的函數。
這種模式的一個弊端是,JavaScript壓縮工具可能不能像壓縮一段包裹在函數中的代碼一樣有效地壓縮這種模式的代碼。這些私有的屬性和方法不被會重命名為一些更短的名字,因為從壓縮工具的角度來看,保證壓縮的可靠性更重要。在寫作本書的時候,Google出品的Closure Compiler的“advanced”模式是唯一會重命名立即初始化的對象的屬性的壓縮工具。一個壓縮后的樣例是這樣:
({d:600,c:400,a:function(){return this.d+"x"+this.c},b:function(){console.log(this.a())}}).b();
> 這種模式主要用于一些一次性的工作,并且在init()方法執行完后就無法再次訪問到這個對象。如果希望在這些工作完成后保持對對象的引用,只需要簡單地在init()的末尾加上return this;即可。
<a name="a20"></a>
## 條件初始化
條件初始化(也叫條件加載)是一種優化模式。當你知道某種條件在整個程序生命周期中都不會變化的時候,那么對這個條件的探測只做一次就很有意義。瀏覽器探測(或者特征檢測)是一個典型的例子。
舉例說明,當你探測到XMLHttpRequest被作為一個本地對象支持時,就知道瀏覽器不會在程序執行過程中改變這一情況,也不會出現突然需要去處理ActiveX對象的情況。當環境不發生變化的時候,你的代碼就沒有必要在需要在每次XHR對象時探測一遍(并且得到同樣的結果)。
另外一些可以從條件初始化中獲益的場景是獲得一個DOM元素的computed styles或者是綁定事件處理函數。大部分程序員在他們的客戶端編程生涯中都編寫過事件綁定和取消綁定相關的組件,像下面的例子:
// BEFORE
var utils = {
addListener: function (el, type, fn) {
if (typeof window.addEventListener === 'function') {
el.addEventListener(type, fn, false);
} else if (typeof document.attachEvent === 'function') { // IE
el.attachEvent('on' + type, fn);
} else { // older browsers
el['on' + type] = fn;
}
},
removeListener: function (el, type, fn) {
// pretty much the same...
}
};
這段代碼的問題就是效率不高。每當你執行utils.addListener()或者utils.removeListener()時,同樣的檢查都會被重復執行。
如果使用條件初始化,那么瀏覽器探測的工作只需要在初始化代碼的時候執行一次。在初始化的時候,代碼探測一次環境,然后重新定義這個函數在剩下來的程序生命周期中應該怎樣工作。下面是一個例子,看看如何達到這個目的:
// AFTER
// the interface
var utils = {
addListener: null,
removeListener: null
};
// the implementation
if (typeof window.addEventListener === 'function') {
utils.addListener = function (el, type, fn) {
el.addEventListener(type, fn, false);
};
utils.removeListener = function (el, type, fn) {
el.removeEventListener(type, fn, false);
};
} else if (typeof document.attachEvent === 'function') { // IE
utils.addListener = function (el, type, fn) {
el.attachEvent('on' + type, fn);
};
utils.removeListener = function (el, type, fn) {
el.detachEvent('on' + type, fn);
};
} else { // older browsers
utils.addListener = function (el, type, fn) {
el['on' + type] = fn;
};
utils.removeListener = function (el, type, fn) {
el['on' + type] = null;
};
}
說到這里,要特別提醒一下關于瀏覽器探測的事情。當你使用這個模式的時候,不要對瀏覽器特性過度假設。舉個例子,如果你探測到瀏覽器不支持window.addEventListener,不要假設這個瀏覽器是IE,也不要認為它不支持原生的XMLHttpRequest,雖然這個結論在整個瀏覽器歷史上的某個點是正確的。當然,也有一些情況是可以放心地做一些特性假設的,比如.addEventListener和.removeEventListerner,但是通常來講,瀏覽器的特性在發生變化時都是獨立的。最好的策略就是分別探測每個特性,然后使用條件初始化,使這種探測只做一次。
<a name="a21"></a>
## 函數屬性——Memoization模式
函數也是對象,所以它們可以有屬性。事實上,函數也確實本來就有一些屬性。比如,對一個函數來說,不管是用什么語法創建的,它會自動擁有一個length屬性來標識這個函數期待接受的參數個數:
function func(a, b, c) {}
console.log(func.length); // 3
任何時候都可以給函數添加自定義屬性。添加自定義屬性的一個有用場景是緩存函數的執行結果(返回值),這樣下次同樣的函數被調用的時候就不需要再做一次那些可能很復雜的計算。緩存一個函數的運行結果也就是為大家所熟知的Memoization。
在下面的例子中,myFunc函數創建了一個cache屬性,可以通過myFunc.cache訪問到。這個cache屬性是一個對象(hash表),傳給函數的參數會作為對象的key,函數執行結果會作為對象的值。函數的執行結果可以是任何的復雜數據結構:
var myFunc = function (param) {
if (!myFunc.cache[param]) {
var result = {};
// ... expensive operation ...
myFunc.cache[param] = result;
}
return myFunc.cache[param];
};
// cache storage
myFunc.cache = {};
上面的代碼假設函數只接受一個參數param,并且這個參數是基本類型(比如字符串)。如果你有更多更復雜的參數,則通常需要對它們進行序列化。比如,你需要將arguments對象序列化為JSON字符串,然后使用JSON字符串作為cache對象的key:
var myFunc = function () {
var cachekey = JSON.stringify(Array.prototype.slice.call(arguments)),
result;
if (!myFunc.cache[cachekey]) {
result = {};
// ... expensive operation ...
myFunc.cache[cachekey] = result;
}
return myFunc.cache[cachekey];
};
// cache storage
myFunc.cache = {};
需要注意的是,在序列化的過程中,對象的“標識”將會丟失。如果你有兩個不同的對象,卻碰巧有相同的屬性,那么他們會共享同樣的緩存內容。
前面代碼中的函數名還可以使用arguments.callee來替代,這樣就不用將函數名硬編碼。不過盡管現階段這個辦法可行,但是仍然需要注意,arguments.callee在ECMAScript 5的嚴格模式中是不被允許的:
var myFunc = function (param) {
var f = arguments.callee,
result;
if (!f.cache[param]) {
result = {};
// ... expensive operation ...
f.cache[param] = result;
}
return f.cache[param];
};
// cache storage
myFunc.cache = {};
<a name="a22"></a>
## 配置對象
配置對象模式是一種提供更簡潔的API的方法,尤其是當你正在寫一個即將被其它程序調用的類庫之類的代碼的時候。
軟件在開發和維護過程中需要不斷改變是一個不爭的事實。這樣的事情總是以一些有限的需求開始,但是隨著開發的進行,越來越多的功能會不斷被加進來。
設想一下你正在寫一個名為addPerson()的函數,它接受一個姓和一個名,然后在列表中加入一個人:
function addPerson(first, last) {...}
然后你意識到,生日也必須要存儲,此外,性別和地址也作為可選項存儲。所以你修改了函數,添加了一些新的參數(還得非常小心地將可選參數放到最后):
function addPerson(first, last, dob, gender, address) {...}
這個時候,函數已經顯得有點長了。然后,你又被告知需要添加一個用戶名,并且不是可選的。現在這個函數的調用者需要將所有的可選參數傳進來,并且得非常小心地保證不弄混參數的順序:
addPerson("Bruce", "Wayne", new Date(), null, null, "batman");
傳一大串的參數真的很不方便。一個更好的辦法就是將它們替換成一個參數,并且把這個參數弄成對象;我們叫它conf,是“configuration”(配置)的縮寫:
addPerson(conf);
然后這個函數的使用者就可以這樣:
var conf = {
username: "batman",
first: "Bruce",
last: "Wayne"
};
addPerson(conf);
配置對象模式的好處是:
- 不需要記住參數的順序
- 可以很安全地跳過可選參數
- 擁有更好的可讀性和可維護性
- 更容易添加和移除參數
配置對象模式的壞處是:
- 需要記住參數的名字
- 參數名字不能被壓縮
舉些實例,這個模式對創建DOM元素的函數或者是給元素設定CSS樣式的函數會非常實用,因為元素和CSS樣式可能會有很多但是大部分可選的屬性。
<a name="a23"></a>
## 柯里化 (Curry)
在本章剩下的部分,我們將討論一下關于柯里化和部分應用的話題。但是在我們開始這個話題之前,先看一下到底什么是函數應用。
<a name="a24"></a>
### 函數應用
在一些純粹的函數式編程語言中,對函數的描述不是被調用(called或者invoked),而是被應用(applied)。在JavaScript中也有同樣的東西——我們可以使用Function.prototype.apply()來應用一個函數,因為在JavaScript中,函數實際上是對象,并且他們擁有方法。
下面是一個函數應用的例子:
// define a function
var sayHi = function (who) {
return "Hello" + (who ? ", " + who : "") + "!";
};
// invoke a function
sayHi(); // "Hello"
sayHi('world'); // "Hello, world!"
// apply a function
sayHi.apply(null, ["hello"]); // "Hello, hello!"
從上面的例子中可以看出來,調用一個函數和應用一個函數有相同的結果。apply()接受兩個參數:第一個是在函數內部綁定到this上的對象,第二個是一個參數數組,參數數組會在函數內部變成一個類似數組的arguments對象。如果第一個參數為null,那么this將指向全局對象,這正是當你調用一個函數(且這個函數不是某個對象的方法)時發生的事情。
當一個函數是一個對象的方法時,我們不再像前面的例子一樣傳入null。(譯注:主要是為了保證方法中的this綁定到一個有效的對象而不是全局對象。)在下面的例子中,對象被作為第一個參數傳給apply():
var alien = {
sayHi: function (who) {
return "Hello" + (who ? ", " + who : "") + "!";
}
};
alien.sayHi('world'); // "Hello, world!"
sayHi.apply(alien, ["humans"]); // "Hello, humans!"
在這個例子中,sayHi()中的this指向alien。而在上一個例子中,this是指向的全局對象。(譯注:這個例子的代碼有誤,最后一行的sayHi并不能訪問到alien的sayHi方法,需要使用alien.sayHi.apply(alien, ["humans"])才可正確運行。另外,在sayHi中也沒有出現this。)
正如上面兩個例子所展現出來的一樣,我們將所謂的函數調用當作函數應用的一種語法糖并沒有什么太大的問題。
需要注意的是,除了apply()之外,Function.prototype對象還有一個call()方法,但是它仍然只是apply()的一種語法糖。(譯注:這兩個方法的區別在于,apply()只接受兩個參數,第二個參數為需要傳給函數的參數數組,而call()則接受任意多個參數,從第二個開始將參數依次傳給函數。)不過有種情況下使用這個語法糖會更好:當你的函數只接受一個參數的時候,你可以省去為唯一的一個元素創建數組的工作:
// the second is more efficient, saves an array
sayHi.apply(alien, ["humans"]); // "Hello, humans!"
sayHi.call(alien, "humans"); // "Hello, humans!"
<a name="a25"></a>
### 部分應用
現在我們知道了,調用一個函數實際上就是給它應用一堆參數,那是否能夠只傳一部分參數而不傳全部呢?這實際上跟我們手工處理數學函數非常類似。
假設已經有了一個add()函數,它的工作是把x和y兩個數加到一起。下面的代碼片段展示了當x為5、y為4時的計算步驟:
// for illustration purposes
// not valid JavaScript
// we have this function
function add(x, y) {
return x + y;
}
// and we know the arguments
add(5, 4);
// step 1 -- substitute one argument
function add(5, y) {
return 5 + y;
}
// step 2 -- substitute the other argument
function add(5, 4) {
return 5 + 4;
}
在這個代碼片段中,step 1和step 2并不是有效的JavaScript代碼,但是它展示了我們手工計算的過程。首先獲得第一個參數的值,然后將未知的x和已知的值5替換到函數中。然后重復這個過程,直到替換掉所有的參數。
step 1是一個所謂的部分應用的例子:我們只應用了第一個參數。當你執行一個部分應用的時候并不能獲得結果(或者是解決方案),取而代之的是另一個函數。
下面的代碼片段展示了一個虛擬的partialApply()方法的用法:
var add = function (x, y) {
return x + y;
};
// full application
add.apply(null, [5, 4]); // 9
// partial application
var newadd = add.partialApply(null, [5]);
// applying an argument to the new function
newadd.apply(null, [4]); // 9
正如你所看到的一樣,部分應用給了我們另一個函數,這個函數可以在稍后調用的時候接受其它的參數。這實際上跟add(5)(4)是等價的,因為add(5)返回了一個函數,這個函數可以使用(4)來調用。我們又一次看到,熟悉的add(5, 4)也差不多是add(5)(4)的一種語法糖。
現在,讓我們回到地球:并不存在這樣的一個partialApply()函數,并且函數的默認表現也不會像上面的例子中那樣。但是你完全可以自己去寫,因為JavaScript的動態特性完全可以做到這樣。
讓函數理解并且處理部分應用的過程,叫柯里化(Currying)。
<a name="a26"></a>
### 柯里化(Currying)
柯里化和辛辣的印度菜可沒什么關系;它來自數學家Haskell Curry。(Haskell編程語言也是因他而得名。)柯里化是一個變換函數的過程。柯里化的另外一個名字也叫sch?nfinkelisation,來自另一位數學家——Moses Sch?nfinkelisation——這種變換的最初發明者。
所以我們怎樣對一個函數進行柯里化呢?其它的函數式編程語言也許已經原生提供了支持并且所有的函數已經默認柯里化了。在JavaScript中我們可以修改一下add()函數使它柯里化,然后支持部分應用。
來看一個例子:
// a curried add()
// accepts partial list of arguments
function add(x, y) {
var oldx = x, oldy = y;
if (typeof oldy === "undefined") { // partial
return function (newy) {
return oldx + newy;
};
}
// full application
return x + y;
}
// test
typeof add(5); // "function"
add(3)(4); // 7
// create and store a new function
var add2000 = add(2000);
add2000(10); // 2010
在這段代碼中,第一次調用add()時,在返回的內層函數那里創建了一個閉包。這個閉包將原來的x和y的值存儲到了oldx和oldy中。當內層函數執行的時候,oldx會被使用。如果沒有部分應用,即x和y都傳了值,那么這個函數會簡單地將他們相加。這個add()函數的實現跟實際情況比起來有些冗余,僅僅是為了更好地說明問題。下面的代碼片段中展示了一個更簡潔的版本,沒有oldx和oldy,因為原始的x已經被存儲到了閉包中,此外我們復用了y作為本地變量,而不用像之前那樣新定義一個變量newy:
// a curried add
// accepts partial list of arguments
function add(x, y) {
if (typeof y === "undefined") { // partial
return function (y) {
return x + y;
};
}
// full application
return x + y;
}
在這些例子中,add()函數自己處理了部分應用。有沒有可能用一種更為通用的方式來做同樣的事情呢?換句話說,我們能不能對任意一個函數進行處理,得到一個新函數,使它可以處理部分參數?下面的代碼片段展示了一個通用函數的例子,我們叫它schonfinkelize(),正是用來做這個的。我們使用schonfinkelize()這個名字,一部分原因是它比較難發音,另一部分原因是它聽起來比較像動詞(使用“curry”則不是那么明確),而我們剛好需要一個動詞來表明這是一個函數轉換的過程。
這是一個通用的柯里化函數:
function schonfinkelize(fn) {
var slice = Array.prototype.slice,
stored_args = slice.call(arguments, 1);
return function () {
var new_args = slice.call(arguments),
args = stored_args.concat(new_args);
return fn.apply(null, args);
};
}
這個schonfinkelize可能顯得比較復雜了,只是因為在JavaScript中arguments不是一個真的數組。從Array.prototype中借用slice()方法幫助我們將arguments轉換成數組,以便能更好地對它進行操作。當schonfinkelize()第一次被調用的時候,它使用slice變量存儲了對slice()方法的引用,同時也存儲了調用時的除去第一個之外的參數(stored\_args),因為第一個參數是要被柯里化的函數。schonfinkelize()返回了一個函數。當這個返回的函數被調用的時候,它可以(通過閉包)訪問到已經存儲的參數stored\_args和slice。新的函數只需要合并老的部分應用的參數(stored\_args)和新的參數(new\_args),然后將它們應用到原來的函數fn(也可以在閉包中訪問到)即可。
現在有了通用的柯里化函數,就可以做一些測試了:
// a normal function
function add(x, y) {
return x + y;
}
// curry a function to get a new function
var newadd = schonfinkelize(add, 5);
newadd(4); // 9
// another option -- call the new function directly
schonfinkelize(add, 6)(7); // 13
用來做函數轉換的schonfinkelize()并不局限于單個參數或者單步的柯里化。這里有些更多用法的例子:
// a normal function
function add(a, b, c, d, e) {
return a + b + c + d + e;
}
// works with any number of arguments
schonfinkelize(add, 1, 2, 3)(5, 5); // 16
// two-step currying
var addOne = schonfinkelize(add, 1);
addOne(10, 10, 10, 10); // 41
var addSix = schonfinkelize(addOne, 2, 3);
addSix(5, 5); // 16
<a name="a27"></a>
### 什么時候使用柯里化
當你發現自己在調用同樣的函數并且傳入的參數大部分都相同的時候,就是考慮柯里化的理想場景了。你可以通過傳入一部分的參數動態地創建一個新的函數。這個新函數會存儲那些重復的參數(所以你不需要再每次都傳入),然后再在調用原始函數的時候將整個參數列表補全,正如原始函數期待的那樣。
<a name="a28"></a>
##小結
在JavaScript中,開發者對函數的理解和運用的要求是比較苛刻的。在本章中,主要討論了有關函數的一些背景知識和術語。介紹了JavaScript函數中兩個重要的特性,也就是:
1. 函數是一等對象,他們可以被作為值傳遞,也可以擁有屬性和方法。
2. 函數擁有本地作用域,而大括號不產生塊級作用域。另外需要注意的是,變量的聲明會被提前到本地作用域頂部。
創建一個函數的語法有:
1. 帶有名字的函數表達式
2. 函數表達式(和上一種一樣,但是沒有名字),也就是為大家熟知的“匿名函數”
3. 函數聲明,與其它語言的函數語法相似
在介紹完背景和函數的語法后,介紹了一些有用的模式,按分類列出:
1. API模式,它們幫助我們為函數給出更干凈的接口,包括:
- 回調模式
傳入一個函數作為參數
- 配置對象
幫助保持函數的參數數量可控
- 返回函數
函數的返回值是另一個函數
- 柯里化
新函數在已有函數的基礎上再加上一部分參數構成
2. 初始化模式,這些模式幫助我們用一種干凈的、結構化的方法來做一些初始化工作(在web頁面和應用中非常常見),通過一些臨時變量來保證不污染全局命名空間。這些模式包括:
- 立即執行的函數
當它們被定義后立即執行
- 立即初始化的對象
初始化工作被放入一個匿名對象,這個對象提供一個可以立即被執行的方法
- 條件初始化
使分支代碼只在初始化的時候執行一次,而不是在整個程序生命周期中反復執行
3. 性能模式,這些模式幫助提高代碼的執行速度,包括:
- Memoization
利用函數的屬性,使已經計算過的值不用再次計算
- 自定義函數
重寫自身的函數體,使第二次及后續的調用做更少的工作