說起來 ECMAScript 中什么有意思,我想那莫過于函數了——而有意思的根源,則在于函數實際上是對象。每個函數都是 Function 類型的實例,而且都與其他引用類型一樣具有屬性和方法。——《JavaScript高級程序設計(第3版)》(以下簡稱《J3》)
在《淺析JavaScript的對象系統》中我們從對象的角度對JS中的函數進行過簡要的描述,我們知道了函數是一種對象類型之一,函數有屬性和方法;不存在所謂的全局函數,任何一個函數包括你自定義的所有函數其實都是“掛”在某個對象上的方法。。。不過,函數有意思的地方可遠不止于此。下面就一起來看看這些有意思的點。
- 函數的聲明
定義一個函數有多種形式,常見的為函數聲明形式和函數表達式形式。
函數聲明形式和大多數其他語言差不多:
function f(v1, v2){
//do something
}
關鍵字(function
)、函數名(f
)、參數列表((v1, v2)
)、函數體({//do something}
)。嗯差不多就這樣,不過少了public
之類的訪問修飾符還有函數的返回類型。對于前者,JavaScript中并沒有訪問修飾符的概念,變量或函數的訪問控制是由JavaScript的作用域鏈和執行環境這一機制控制的,對此可以看一下這篇文章《JavaScript的執行環境和作用域鏈》以加深理解。對于后者,因為:
ECMAScript 中的函數在定義時不必指定是否返回值。實際上,任何函數在任何時候都可以通過 return 語句后跟要返回的值來實現返回值。——《J3》
這就是說,既然任何ECMAScript函數都可以在任何時候返回任何值,那么你就不可能定義函數的返回類型了,因為你無法確定它返回的會是數值、字符串還是對象或者其他類型。
對于返回值,這里再補充一點,沒有return
具體值或者只有一句return;
的函數實際上都返回undefined
值。
函數表達式形式則比較有意思了:
var f = function(v1, v2){
//do something
};
很明顯,這其實只是一條賦值語句,只不過等號右邊的值是一個函數而已。這個變量f
保存了對函數的引用,在使用時,和通過函數聲明形式定義的函數沒有任何差別,一樣是傳入參數即可:f(3, 7)
。
這兩種定義函數的形式雖然在使用時沒什么差別,但是需要注意的是,因為后者(函數表達式)說白了就是一條賦值語句,因此在尚未執行這句語句時,你是無法調用它的,因為未定義;而前者(函數聲明)則可以在這個函數的前面正常調用它,因為解析器會率先讀取函數聲明,存在一個“函數聲明提升”的過程。
- 參數與重載
ECMAScript 函數的參數與大多數其他語言中函數的參數有所不同。ECMAScript 函數不介意傳遞進來多少個參數,也不在乎傳進來的參數是什么數據類型。——《J3》
參數的數據類型沒有限制這點就不必說了,對于參數個數而言,如果一個函數的參數列表有2個參數,你可以按照期望傳2個參數,你也可以只傳1個參數,你甚至可以傳0個或者2個以上的參數,而這些都不會導致解析器報錯。為什么JS中的函數對參數可以如此縱容呢?原因在于:
之所以會這樣,原因是 ECMAScript中的參數在內部是用一個數組來表示的。函數接收到的始終都是這個數組,而不關心數組中包含哪些參數(如果有參數的話)。如果這個數組中不包含任何元素,無所謂;如果包含多個元素,也沒有問題。——《J3》
所以你明白了,我們定義的參數列表其實是形式上的,只是為了方便在函數內部操作而定義的一些命名而已。真正保存著你傳入的參數的東西,就是上面所說的那個“數組”——arguments
。不過,arguments
其實并不是數組,而是函數內部一個特殊的對象,但是它使用起來很像數組,因為你可以使用方括號來取每一項的值——按序對應你傳入的參數,例如arguments[0]
就表示你傳入的第一個參數。還有,你可以通過arguments.length
來確定實際傳了多少個參數。請看下面兩段代碼,其作用和效果是完全一樣的:
var sum(num1, num2){
return num1 + num2; //通過命名參數(參數列表)執行內部操作
}
console.log(sum(1, 2)); //3
var sum(){
return arguments[0] + arguments[1]; //通過arguments對象執行內部操作
}
console.log(sum(1, 2)); //3
所以:
這個事實說明了 ECMAScript 函數的一個重要特點:命名的參數只提供便利,但不是必需的。——《J3》
說到這里,其實有一點就可以明確了,那就是:JavaScript函數沒有重載!為什么這么說?我們知道,要實現兩個函數重載,除了要求這兩個函數的函數名一致外,要么使得這兩個函數的參數個數不同,要么使得參數類型不完全一致。而上面已經說了,JS函數的參數實際上是由一個包含零或多個值的arguments
對象來表示的,并不存在什么參數個數、參數類型的差別。因此,JavaScript函數沒有重載!
從JS函數參數的特點中我們可以直接否決掉函數重載,現在我們再從另一個角度來否決一次。請看下面代碼:
function add(num1, num2){
return num1 + num2;
}
function add(value){
return value + 100;
}
console.log(add(1, 2)); //101
結果不是3而是執行了第二個函數返回了101,很明顯,并沒有進行重載。事實上,上面那個add()
函數的代碼無論如何都不會被執行,原因在于函數名其實只是對函數的一個引用,是一個指針,因此對于同一命名,后面的始終都會覆蓋掉前面的。
由于函數名僅僅是指向函數的指針,因此函數名與包含對象指針的其他變量沒有什么不同。——《J3》
也就是說,上面這段代碼中的add
先是指向第一個函數,后又指向第二個函數,相當于第一個被覆蓋掉了。這種情況下,第一個add()
函數可以直接整個抹除掉了,寫了和沒寫一樣,永遠也訪問不到。
以上分別從函數參數和引用類型兩個角度判了JavaScript函數重載死刑。那就真的一點辦法都沒有了嗎?如果我一定要實現類似重載的效果呢?辦法總是有的。前面說過,arguments.length
可以確定實際傳入的參數個數,那我們就可以利用它來做文章了。請看下面代碼:
function add(){
if(arguments.length === 1){
return arguments[0] + 100;
} else if(arguments.length === 2){
return arguments[0] + arguments[1];
}
}
console.log(add(1, 2)); //3
console.log(add(1)); //101
這樣不就挺好地模擬了重載了嗎?的確,通過arguments.length
來做判斷,我們確實可以做出類似重載的效果。不過需要明確的一點是:這只是模擬重載,JavaScript函數沒有重載!
- 作為值的函數
因為 ECMAScript 中的函數名本身就是變量,所以函數也可以作為值來使用。也就是說,不僅可以像傳遞參數一樣把一個函數傳遞給另一個函數,而且可以將一個函數作為另一個函數的結果返回。——《J3》
請看代碼:
function callSomeFunction(someFunction, someArgument){
return someFunction(someArgument);
}
function sayHello(name){
alert('Hello ' + name);
}
callSomeFunction(sayHello, 'leo'); //'Hello leo'(注意此處的參數為函數指針,即函數名sayHello,而不是sayHello())
- arguments與this
-
arguments
arguments
在上面探討函數的參數與重載的時候已經大致了解過了。我們知道: -
arguments
是函數內部一個特殊的對象,存儲著所有參數的值; - 任何一個函數都有其唯一對應的一個
arguments
; -
arguments
本身作為一個對象,其表現卻類似數組,可以像數組一樣通過索引取值; -
arguments
對象的存在是JS函數沒有重載的一個重要原因,但我們卻可以通過arguments.length
模擬函數重載;
除了這些,這里再補充一點:arguments
對象還擁有一個屬性callee
,它是一個指針,指向擁有這個arguments
對象的那個函數。有時候,我們可以巧妙地使用arguments.callee
來優化代碼。
-
this
對于this
,有趣的則更多了。當你在一個函數中使用this
時,應該先明確幾點: -
this
是JS中的一個關鍵字; -
this
是在函數運行時生成的一個特殊的內部對象; -
this
實際上是一個指針,指向調用該函數的那個對象;
什么叫做“調用該函數的那個對象”?請看以下代碼及相關注釋:
<script>
//在Global環境下調用該函數,this指向全局環境
var x = 0;
function test(){
this.x = 1;
}
test();
console.log(x); //1(說明this指向全局對象Global)
</script>
<script>
//函數作為某個對象的方法進行調用,this指向該對象
var x = 1;
function test(){
console.log(this.x);
}
var obj = {};
obj.x = 0;
obj.f = test;
obj.f(); //0(說明this指向obj)
</script>
<script>
//函數作為構造函數進行調用,this指向new出的那個對象
var x = 0;
function test(){
this.x = 1;
}
var obj = new test();
console.log(obj.x); //1(說明this指向obj)
</script>
以上3段代碼分別是this
的常見使用場景。其實我們知道,前兩種使用場景的本質是一致的,第一種看起來是在直接調用test()
函數,實際上和第二種一樣都是在調用對象方法,只不過這個對象是看不見的全局變量罷了。
結合代碼稍加分析我們便能理解上面講的“this
實際上是一個指針,指向調用該函數的那個對象”這句話的含義了:
-
this
的值(即它的指向)在函數調用前是無法確定的; -
this
的值可以總結為:誰調用我,我就指向誰;
這個理解只能說大體上是這么回事,但是JavaScript中的這個this
說簡單就這么簡單,可深究起來你會發現遠不止如此,這也是為什么很多JavaScript程序員對this
有所困惑的原因。這里推薦一篇總結的不錯的博客《徹底理解js中this的指向,不必硬背》。
首先必須要說的是,this的指向在函數定義的時候是確定不了的,只有函數執行的時候才能確定this到底指向誰,實際上this的最終指向是那個調用它的對象(這句話有些問題,后面會解釋為什么會有問題,雖然網上大部分的文章都是這樣說的,雖然在很多情況下那樣去理解不會出什么問題,但是實際上那樣理解是不準確的,所以在你理解this的時候會有種琢磨不透的感覺)——《徹底理解js中this的指向,不必硬背》
- 身為對象
- 屬性
-
caller
caller
是函數對象的一個屬性,它是一個指針,指向調用該函數的那個函數。不要把它和callee
混淆了,后者是函數內部的arguments
對象的一個屬性。請理解一下下面的代碼:
-
function fn(){
console.log(fn === arguments.callee); //true
console.log(fn.caller === arguments.callee.caller); //true
}
-
length
函數對象的length
屬性表示的是這個函數希望接收的參數的個數。注意是“希望接收”,而不是“實際接收”,也就是說length
的值取決于函數定義時括號中的參數個數,而不是實際傳入了幾個參數。請看下面測試:
function fn(a, b, c){
console.log(arguments.length); //2(arguments.length表示實際接收的參數個數)
console.log(fn.length); //3(始終都會返回3,取決于定義時參數列表中參數的個數)
}
fn(1, 2);
-
prototype
原型,是一個指針,指向該函數的原型對象。函數的prototype
屬性是一個需要大書特書的東西,它和整個JavaScript語言的結構、實現和特性都息息相關。這里只是明確其作為函數對象的一個屬性先提一下,之后會在相關文章中作詳細的探討。 - 方法
apply()
call()
bind()
這三個方法都是函數對象所特有的。
先介紹前兩個,其作用都是實現在特定的作用域中調用函數。
apply()
和call()
的異同:
先看apply()
。apply()
方法接收兩個參數,一個是在其中運行函數的作用域對象,另一個是參數數組(可以是arguments
對象,也可以是一個Array
數組)。請看示例:
function sum(num1, num2){
return num1 + num2;
}
function callSum1(num1, num2){
return sum.apply(this, num1, num2);
}
function callSum2(num1, num2){
return sum.apply(this, [num1, num2]);
}
console.log(callSum1(1, 2)); //3
console.log(callSum2(1, 2)); //3
再來看看call()
。
call()方法與 apply()方法的作用相同,它們的區別僅在于接收參數的方式不同。對于 call() 方法而言,第一個參數是 this 值沒有變化,變化的是其余參數都直接傳遞給函數。換句話說,在使用 call() 方法時,傳遞給函數的參數必須逐個列舉出來。——《J3》
請看示例:
function sum(sum1, sum2){
return sum1 + sum2;
}
function callSum(num1, num2){
return sum.call(this, sum1, sum2);
}
console.log(callSum(1, 2)); //3
apply()
和call()
這兩個方法的作用是完全一樣的,區別只在于兩者接收參數的方式不同。
上面理清了apply()
和call()
這兩個方法的異同,可從示例代碼中看不出它們的實際作用,下面就來看看它們真正的用武之地。
apply()
和call()
的作用:
window.color = 'red';
var o = { color: 'blue' };
function sayColor(){
console.log(this.color);
}
sayColor(); //'red'
sayColor.call(this); //'red'
sayColor.call(o); //'blue'
當運行sayColor.call(o)
時,sayColor()
的作用域被設定為o
,因此返回結果為blue
。你應該看出來了,apply()
和call()
的真正作用,在于擴充函數的作用域,而
使用 call()(或 apply())來擴充作用域的大好處,就是對象不需要與方法有任何耦合關系。——《J3》
這一點是很有意義的,因為它可以讓你寫出更加靈活、漂亮的代碼。
最后,別忘了還有一個bind()
。和apply()
與call()
在某個環境中直接調用并執行函數不同,bind()
方法會返回一個函數實例,并將傳入的對象綁定到該函數實例內部的this
。請看測試:
window.color = 'red';
var o = { color: 'blue' };
function sayColor(){
console.log(this.color);
}
var newSayColor = sayColor.bind(o); //創建一個sayColor的實例并將其this值綁定到o
newSayColor(); //'blue'
函數作為對象,除了上面介紹的這三個特有的方法之外,當然還有從Object
繼承而來的toString()
、valueOf()
等方法,不過這幾個繼承而來的方法對函數對象而言意義不大,因為它們都只是單純地返回函數的代碼。