函數是JavaScript 中最有趣的部分之一。它們本質上是十分簡單和過程化的,但也可以是非常複雜和動態的。一些額外的功能可以通過使用閉包來實現。此外,由於所有的函數都是對象,所以使用函數指針非常簡單。這些令JavaScript 函數不僅有趣而且強大。以下幾節描繪了幾種在JavaScript 中使用 ...
函數是JavaScript 中最有趣的部分之一。它們本質上是十分簡單和過程化的,但也可以是非常複雜和動態的。一些額外的功能可以通過使用閉包來實現。此外,由於所有的函數都是對象,所以使用函數指針非常簡單。這些令JavaScript 函數不僅有趣而且強大。以下幾節描繪了幾種在JavaScript 中使用函數的高級方法。
22.1.1 安全的類型檢測
JavaScript 內置的類型檢測機制並非完全可靠。事實上,發生錯誤否定及錯誤肯定的情況也不在少數。比如說typeof 操作符吧,由於它有一些無法預知的行為,經常會導致檢測數據類型時得到不靠譜的結果。Safari(直至第4 版)在對正則表達式應用typeof 操作符時會返回"function",因此很難確定某個值到底是不是函數。
再比如,instanceof 操作符在存在多個全局作用域(像一個頁麵包含多個frame)的情況下,也是問題多多。一個經典的例子(第5 章也提到過)就是像下麵這樣將對象標識為數組。
var isArray = value instanceof Array;
以上代碼要返回true,value 必須是一個數組,而且還必須與Array 構造函數在同個全局作用域中。(別忘了,Array 是window 的屬性。)如果value 是在另個frame 中定義的數組,那麼以上代碼就會返回false。
在檢測某個對象到底是原生對象還是開發人員自定義的對象的時候,也會有問題。出現這個問題的原因是瀏覽器開始原生支持JSON 對象了。因為很多人一直在使用Douglas Crockford 的JSON 庫,而該庫定義了一個全局JSON 對象。於是開發人員很難確定頁面中的JSON 對象到底是不是原生的。
解決上述問題的辦法都一樣。大家知道,在任何值上調用Object 原生的toString()方法,都會返回一個[object NativeConstructorName]格式的字元串。每個類在內部都有一個[[Class]]屬性,這個屬性中就指定了上述字元串中的構造函數名。舉個例子吧。
alert(Object.prototype.toString.call(value)); //"[object Array]"
由於原生數組的構造函數名與全局作用域無關,因此使用toString()就能保證返回一致的值。利用這一點,可以創建如下函數:
function isArray(value) { return Object.prototype.toString.call(value) == "[object Array]"; }
同樣,也可以基於這一思路來測試某個值是不是原生函數或正則表達式:
function isFunction(value) { return Object.prototype.toString.call(value) == "[object Function]"; } function isRegExp(value) { return Object.prototype.toString.call(value) == "[object RegExp]"; }
不過要註意,對於在IE 中以COM 對象形式實現的任何函數,isFunction()都將返回false(因為它們並非原生的JavaScript 函數,請參考第10 章中更詳細的介紹)。
這一技巧也廣泛應用於檢測原生JSON 對象。Object 的toString()方法不能檢測非原生構造函數的構造函數名。因此,開發人員定義的任何構造函數都將返回[object Object]。有些JavaScript 庫會包含與下麵類似的代碼。
var isNativeJSON = window.JSON && Object.prototype.toString.call(JSON) == "[object JSON]";
在Web 開發中能夠區分原生與非原生JavaScript 對象非常重要。只有這樣才能確切知道某個對象到底有哪些功能。這個技巧可以對任何對象給出正確的結論。
請註意,Object.prototpye.toString()本身也可能會被修改。本節討論的技巧假設Object.prototpye.toString()是未被修改過的原生版本。
22.1.2 作用域安全的構造函數
第6 章講述了用於自定義對象的構造函數的定義和用法。你應該還記得,構造函數其實就是一個使用new 操作符調用的函數。當使用new 調用時,構造函數內用到的this 對象會指向新創建的對象實例,如下麵的例子所示:
function Person(name, age, job) { this.name = name; this.age = age; this.job = job; } var person = new Person("Nicholas", 29, "Software Engineer");
上面這個例子中,Person 構造函數使用this 對象給三個屬性賦值:name、age 和job。當和new操作符連用時,則會創建一個新的Person 對象,同時會給它分配這些屬性。問題出在當沒有使用new操作符來調用該構造函數的情況上。由於該this 對象是在運行時綁定的,所以直接調用Person(),this 會映射到全局對象window 上,導致錯誤對象屬性的意外增加。例如:
var person = Person("Nicholas", 29, "Software Engineer"); alert(window.name); //"Nicholas" alert(window.age); //29 alert(window.job); //"Software Engineer"
運行一下
這裡,原本針對Person 實例的三個屬性被加到window 對象上,因為構造函數是作為普通函數調用的,忽略了new 操作符。這個問題是由this 對象的晚綁定造成的,在這裡this 被解析成了window對象。由於window 的name 屬性是用於識別鏈接目標和frame 的,所以這裡對該屬性的偶然覆蓋可能會導致該頁面上出現其他錯誤。這個問題的解決方法就是創建一個作用域安全的構造函數。
作用域安全的構造函數在進行任何更改前,首先確認this 對象是正確類型的實例。如果不是,那麼會創建新的實例並返回。請看以下例子:
function Person(name, age, job) { if (this instanceof Person) { this.name = name; this.age = age; this.job = job; } else { return new Person(name, age, job); } } var person1 = Person("Nicholas", 29, "Software Engineer"); alert(window.name); //"" alert(person1.name); //"Nicholas" var person2 = new Person("Shelby", 34, "Ergonomist"); alert(person2.name); //"Shelby"
這段代碼中的Person 構造函數添加了一個檢查並確保this 對象是Person 實例的if 語句,它表示要麼使用new 操作符,要麼在現有的Person 實例環境中調用構造函數。任何一種情況下,對象初始化都能正常進行。如果this 並非Person 的實例,那麼會再次使用new 操作符調用構造函數並返回結果。最後的結果是,調用Person 構造函數時無論是否使用new 操作符,都會返回一個Person 的新實例,這就避免了在全局對象上意外設置屬性。關於作用域安全的構造函數的貼心提示。實現這個模式後,你就鎖定了可以調用構造函數的環境。
如果你使用構造函數竊取模式的繼承且不使用原型鏈,那麼這個繼承很可能被破壞。這裡有個例子:
function Polygon(sides) { if (this instanceof Polygon) { this.sides = sides; this.getArea = function() { return 0; }; } else { return new Polygon(sides); } } function Rectangle(width, height) { Polygon.call(this, 2); this.width = width; this.height = height; this.getArea = function() { return this.width * this.height; }; } var rect = new Rectangle(5, 10); alert(rect.sides); //undefined
在這段代碼中,Polygon 構造函數是作用域安全的,然而Rectangle 構造函數則不是。新創建一個Rectangle 實例之後,這個實例應該通過Polygon.call()來繼承Polygon 的sides 屬性。但是,由於Polygon 構造函數是作用域安全的,this 對象並非Polygon 的實例,所以會創建並返回一個新的Polygon 對象。Rectangle 構造函數中的this 對象並沒有得到增長,同時Polygon.call()返回的值也沒有用到,所以Rectangle 實例中就不會有sides 屬性。
如果構造函數竊取結合使用原型鏈或者寄生組合則可以解決這個問題。考慮以下例子:
function Polygon(sides) { if (this instanceof Polygon) { this.sides = sides; this.getArea = function() { return 0; }; } else { return new Polygon(sides); } } function Rectangle(width, height) { Polygon.call(this, 2); this.width = width; this.height = height; this.getArea = function() { return this.width * this.height; }; } Rectangle.prototype = new Polygon(); var rect = new Rectangle(5, 10); alert(rect.sides); //2
上面這段重寫的代碼中,一個Rectangle 實例也同時是一個Polygon 實例,所以Polygon.call()會照原意執行,最終為Rectangle 實例添加了sides 屬性。
多個程式員在同一個頁面上寫JavaScript 代碼的環境中,作用域安全構造函數就很有用了。屆時,對全局對象意外的更改可能會導致一些常常難以追蹤的錯誤。除非你單純基於構造函數竊取來實現繼承,推薦作用域安全的構造函數作為最佳實踐。
22.1.3 惰性載入函數
因為瀏覽器之間行為的差異,多數JavaScript 代碼包含了大量的if 語句,將執行引導到正確的代碼中。看看下麵來自上一章的createXHR()函數。
function createXHR() { if (typeof XMLHttpRequest != "undefined") { return new XMLHttpRequest(); } else if (typeof ActiveXObject != "undefined") { if (typeof arguments.callee.activeXString != "string") { var versions = ["MSXML2.XMLHttp.6.0", "MSXML2.XMLHttp.3.0", "MSXML2.XMLHttp"], i, len; for (i = 0, len = versions.length; i < len; i++) { try { new ActiveXObject(versions[i]); arguments.callee.activeXString = versions[i]; break; } catch(ex) { //跳過 } } } return new ActiveXObject(arguments.callee.activeXString); } else { throw new Error("No XHR object available."); } }
每次調用createXHR()的時候,它都要對瀏覽器所支持的能力仔細檢查。首先檢查內置的XHR,然後測試有沒有基於ActiveX 的XHR,最後如果都沒有發現的話就拋出一個錯誤。每次調用該函數都是這樣,即使每次調用時分支的結果都不變:如果瀏覽器支持內置XHR,那麼它就一直支持了,那麼這種測試就變得沒必要了。即使只有一個if 語句的代碼,也肯定要比沒有if 語句的慢,所以如果if 語句不必每次執行,那麼代碼可以運行地更快一些。解決方案就是稱之為惰性載入的技巧。
惰性載入表示函數執行的分支僅會發生一次。有兩種實現惰性載入的方式,第一種就是在函數被調用時再處理函數。在第一次調用的過程中,該函數會被覆蓋為另外一個按合適方式執行的函數,這樣任何對原函數的調用都不用再經過執行的分支了。例如,可以用下麵的方式使用惰性載入重寫createXHR()。
function createXHR() { if (typeof XMLHttpRequest != "undefined") { createXHR = function() { return new XMLHttpRequest(); }; } else if (typeof ActiveXObject != "undefined") { createXHR = function() { if (typeof arguments.callee.activeXString != "string") { var versions = ["MSXML2.XMLHttp.6.0", "MSXML2.XMLHttp.3.0", "MSXML2.XMLHttp"], i, len; for (i = 0, len = versions.length; i < len; i++) { try { new ActiveXObject(versions[i]); arguments.callee.activeXString = versions[i]; break; } catch(ex) { //skip } } } return new ActiveXObject(arguments.callee.activeXString); }; } else { createXHR = function() { throw new Error("No XHR object available."); }; } return createXHR(); }
運行一下
在這個惰性載入的createXHR()中,if 語句的每一個分支都會為createXHR 變數賦值,有效覆蓋了原有的函數。最後一步便是調用新賦的函數。下一次調用createXHR()的時候,就會直接調用被分配的函數,這樣就不用再次執行if 語句了。
第二種實現惰性載入的方式是在聲明函數時就指定適當的函數。這樣,第一次調用函數時就不會損失性能了,而在代碼首次載入時會損失一點性能。以下就是按照這一思路重寫前面例子的結果。
var createXHR = (function() { if (typeof XMLHttpRequest != "undefined") { return function() { return new XMLHttpRequest(); }; } else if (typeof ActiveXObject != "undefined") { return function() { if (typeof arguments.callee.activeXString != "string") { var versions = ["MSXML2.XMLHttp.6.0", "MSXML2.XMLHttp.3.0", "MSXML2.XMLHttp"], i, len; for (i = 0, len = versions.length; i < len; i++) { try { new ActiveXObject(versions[i]); arguments.callee.activeXString = versions[i]; break; } catch(ex) { //skip } } } return new ActiveXObject(arguments.callee.activeXString); }; } else { return function() { throw new Error("No XHR object available."); }; } })();
運行一下
這個例子中使用的技巧是創建一個匿名、自執行的函數,用以確定應該使用哪一個函數實現。實際的邏輯都一樣。不一樣的地方就是第一行代碼(使用var 定義函數)、新增了自執行的匿名函數,另外每個分支都返回正確的函數定義,以便立即將其賦值給createXHR()。
惰性載入函數的優點是只在執行分支代碼時犧牲一點兒性能。至於哪種方式更合適,就要看你的具體需求而定了。不過這兩種方式都能避免執行不必要的代碼。
22.1.4 函數綁定
另一個日益流行的高級技巧叫做函數綁定。函數綁定要創建一個函數,可以在特定的this 環境中以指定參數調用另一個函數。該技巧常常和回調函數與事件處理程式一起使用,以便在將函數作為變數傳遞的同時保留代碼執行環境。請看以下例子:
var handler = { message: "Event handled", handleClick: function(event) { alert(this.message); } }; var btn = document.getElementById("my-btn"); EventUtil.addHandler(btn, "click", handler.handleClick);
在上面這個例子中,創建了一個叫做handler 的對象。handler.handleClick()方法被分配為一個DOM 按鈕的事件處理程式。當按下該按鈕時,就調用該函數,顯示一個警告框。雖然貌似警告框應該顯示Event handled , 然而實際上顯示的是undefiend 。這個問題在於沒有保存handler.handleClick()的環境,所以this 對象最後是指向了DOM按鈕而非handler(在IE8 中,this 指向window。)可以如下麵例子所示,使用一個閉包來修正這個問題。
var handler = { message: "Event handled", handleClick: function(event) { alert(this.message); } }; var btn = document.getElementById("my-btn"); EventUtil.addHandler(btn, "click", function(event) { handler.handleClick(event); });
這個解決方案在onclick 事件處理程式內使用了一個閉包直接調用handler.handleClick()。
當然,這是特定於這段代碼的解決方案。創建多個閉包可能會令代碼變得難於理解和調試。因此,很多JavaScript 庫實現了一個可以將函數綁定到指定環境的函數。這個函數一般都叫bind()。
一個簡單的bind()函數接受一個函數和一個環境,並返回一個在給定環境中調用給定函數的函數,並且將所有參數原封不動傳遞過去。語法如下:
function bind(fn, context) { return function() { return fn.apply(context, arguments); }; }
這個函數似乎簡單,但其功能是非常強大的。在bind()中創建了一個閉包,閉包使用apply()調用傳入的函數,並給apply()傳遞context 對象和參數。註意這裡使用的arguments 對象是內部函數的,而非bind()的。當調用返回的函數時,它會在給定環境中執行被傳入的函數並給出所有參數。
bind()函數按如下方式使用:
var handler = { message: "Event handled", handleClick: function(event) { alert(this.message); } }; var btn = document.getElementById("my-btn"); EventUtil.addHandler(btn, "click", bind(handler.handleClick, handler));
在這個例子中,我們用bind()函數創建了一個保持了執行環境的函數,並將其傳給EventUtil.addHandler()。event 對象也被傳給了該函數,如下所示:
var handler = { message: "Event handled", handleClick: function(event) { alert(this.message + ":" + event.type); } }; var btn = document.getElementById("my-btn"); EventUtil.addHandler(btn, "click", bind(handler.handleClick, handler));
handler.handleClick()方法和平時一樣獲得了event 對象,因為所有的參數都通過被綁定的函數直接傳給了它。
ECMAScript 5 為所有函數定義了一個原生的bind()方法,進一步簡單了操作。換句話說,你不用再自己定義bind()函數了,而是可以直接在函數上調用這個方法。例如:
var handler = { message: "Event handled", handleClick: function(event) { alert(this.message + ":" + event.type); } }; var btn = document.getElementById("my-btn"); EventUtil.addHandler(btn, "click", handler.handleClick.bind(handler));
運行一下
原生的bind()方法與前面介紹的自定義bind()方法類似,都是要傳入作為this 值的對象。支持原生bind()方法的瀏覽器有IE9+、Firefox 4+和Chrome。
只要是將某個函數指針以值的形式進行傳遞,同時該函數必須在特定環境中執行,被綁定函數的效用就突顯出來了。它們主要用於事件處理程式以及 setTimeout() 和 setInterval()。然而,被綁定函數與普通函數相比有更多的開銷,它們需要更多記憶體,同時也因為多重函數調用稍微慢一點,所以最好只在必要時使用。
22.1.5 函數柯里化
與函數綁定緊密相關的主題是函數柯里化(function currying),它用於創建已經設置好了一個或多個參數的函數。函數柯里化的基本方法和函數綁定是一樣的:使用一個閉包返回一個函數。兩者的區別在於,當函數被調用時,返回的函數還需要設置一些傳入的參數。請看以下例子。
function add(num1, num2) { return num1 + num2; } function curriedAdd(num2) { return add(5, num2); } alert(add(2, 3)); //5 alert(curriedAdd(3)); //8
這段代碼定義了兩個函數:add()和curriedAdd()。後者本質上是在任何情況下第一個參數為5的add()版本。儘管從技術上來說curriedAdd()並非柯里化的函數,但它很好地展示了其概念。
柯里化函數通常由以下步驟動態創建:調用另一個函數併為它傳入要柯里化的函數和必要參數。下麵是創建柯里化函數的通用方式。
function curry(fn) { var args = Array.prototype.slice.call(arguments, 1); return function() { var innerArgs = Array.prototype.slice.call(arguments); var finalArgs = args.concat(innerArgs); return fn.apply(null, finalArgs); }; }
curry()函數的主要工作就是將被返回函數的參數進行排序。curry()的第一個參數是要進行柯里化的函數,其他參數是要傳入的值。為了獲取第一個參數之後的所有參數,在arguments 對象上調用了slice()方法,並傳入參數1 表示被返回的數組包含從第二個參數開始的所有參數。然後args 數組包含了來自外部函數的參數。在內部函數中,創建了innerArgs 數組用來存放所有傳入的參數(又一次用到了slice())。有了存放來自外部函數和內部函數的參數數組後,就可以使用concat()方法將它們組合為finalArgs,然後使用apply()將結果傳遞給該函數。註意這個函數並沒有考慮到執行環境,所以調用apply()時第一個參數是null。curry()函數可以按以下方式應用。
function add(num1, num2) { return num1 + num2; } var curriedAdd = curry(add, 5); alert(curriedAdd(3)); //8
在這個例子中,創建了第一個參數綁定為5 的add()的柯里化版本。當調用curriedAdd()並傳入3 時,3 會成為add()的第二個參數,同時第一個參數依然是5,最後結果便是和8。你也可以像下麵例子這樣給出所有的函數參數:
function add(num1, num2) { return num1 + num2; } var curriedAdd = curry(add, 5, 12); alert(curriedAdd()); //17
運行一下
在這裡,柯里化的add()函數兩個參數都提供了,所以以後就無需再傳遞它們了。
函數柯里化還常常作為函數綁定的一部分包含在其中,構造出更為複雜的bind()函數。例如:
function bind(fn, context) { var args = Array.prototype.slice.call(arguments, 2); return function() { var innerArgs = Array.prototype.slice.call(arguments); var finalArgs = args.concat(innerArgs); return fn.apply(context, finalArgs); }; }
對curry()函數的主要更改在於傳入的參數個數,以及它如何影響代碼的結果。curry()僅僅接受一個要包裹的函數作為參數,而bind()同時接受函數和一個object 對象。這表示給被綁定的函數的參數是從第三個開始而不是第二個,這就要更改slice()的第一處調用。另一處更改是在倒數第3 行將object 對象傳給apply()。當使用bind()時,它會返回綁定到給定環境的函數,並且可能它其中某些函數參數已經被設好。當你想除了event 對象再額外給事件處理程式傳遞參數時,這非常有用,例如:
var handler = { message: "Event handled", handleClick: function(name, event) { alert(this.message + ":" + name + ":" + event.type); } }; var btn = document.getElementById("my-btn"); EventUtil.addHandler(btn, "click", bind(handler.handleClick, handler, "my-btn"));
在這個更新過的例子中,handler.handleClick()方法接受了兩個參數:要處理的元素的名字和event 對象。作為第三個參數傳遞給bind()函數的名字,又被傳遞給了handler.handleClick(),而handler.handleClick()也會同時接收到event 對象。
ECMAScript 5 的bind()方法也實現函數柯里化,只要在this 的值之後再傳入另一個參數即可。
var handler = { message: "Event handled", handleClick: function(name, event) { alert(this.message + ":" + name + ":" + event.type); } }; var btn = document.getElementById("my-btn"); EventUtil.addHandler(btn, "click", handler.handleClick.bind(handler, "my-btn"));
運行一下
JavaScript 中的柯里化函數和綁定函數提供了強大的動態函數創建功能。使用bind()還是curry()要根據是否需要object 對象響應來決定。它們都能用於創建複雜的演算法和功能,當然兩者都不應濫用,因為每個函數都會帶來額外的開銷。