JavaScript函數與方法的那些事

說起來 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()等方法,不過這幾個繼承而來的方法對函數對象而言意義不大,因為它們都只是單純地返回函數的代碼。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容