1. 程式人生 > >前端入門12-JavaScript語法之函式

前端入門12-JavaScript語法之函式

宣告

本系列文章內容全部梳理自以下幾個來源:

作為一個前端小白,入門跟著這幾個來源學習,感謝作者的分享,在其基礎上,通過自己的理解,梳理出的知識點,或許有遺漏,或許有些理解是錯誤的,如有發現,歡迎指點下。

PS:梳理的內容以《JavaScript權威指南》這本書中的內容為主,因此接下去跟 JavaScript 語法相關的系列文章基本只介紹 ES5 標準規範的內容、ES6 等這系列梳理完再單獨來講講。

正文-函式

在 JavaScript 裡用 function 宣告的就是函式,函式本質上也是一個物件,不同的函式呼叫方式有著不同的用途,下面就來講講函式。

函式有一些相關術語: function 關鍵字、函式名、函式體、形參、實參、建構函式;

其中,大部分的術語用 Java 的基礎來理解即可,就建構函式需要注意一下,跟 Java 裡不大一樣。在 JavaScript 中,所有的函式,只要它和 new 關鍵字一起使用的,此時,就可稱這個函式為建構函式。

因為,為了能夠在程式中辨別普通函式和建構函式,書中建議需要有一種良好的程式設計規範,比如建構函式首字母都用大寫,普通函式或方法的首字母小寫,以人為的手段來良好的區分它們。這是因為,通常用來當做建構函式就很少會再以普通函式形式使用它。

函式定義

函式的定義大體上包含以下幾部分:function 關鍵字、函式物件的變數識別符號、形參列表、函式體、返回語句。

如果函式沒有 return 語句,則函式返回的是 undefined。

函式定義有三種方式:

函式宣告式

add(1,2); //由於函式宣告被提前了,不會出錯
function add(x, y) {
    //函式體
}

add 是函式名,由於 JavaScript 有宣告提前的處理,以這種方式定義的函式,可以在它之前呼叫。

函式定義表示式

var add = function (x, y) {
    //函式體
}

這種方式其實是定義了匿名函式,然後將函式物件賦值給 add 變數,JavaScript 的宣告提前處理只將 add 變數的宣告提前,賦值操作仍在原位置,因此這種方式的宣告,函式的呼叫需要在宣告之後才不會報錯。

注意,即使 function 後跟隨了一個函式名,不使用匿名函式方式,但在外部仍舊只能使用 add 來呼叫函式,無法通過函式名,這是由於 JavaScript 中作用域機制原理導致,在後續講作用域時會來講講。

Function

var add = new Function("x", "y", "return x*y;");
//基本等價於
var add = function (x, y) {
    return x*y;
}

Function 建構函式接收不定數量的引數,最後一個引數表示函式體,前面的都作為函式引數處理。

注意:以這種方式宣告的函式作用域是全域性作用域,即使這句程式碼是放在某個函式內部,相當於全域性作用域下執行 eval(),而且對效能有所影響,不建議使用這種方式。

函式呼叫

跟 Java 不一樣的地方,在 JavaScript 中函式也是物件,既然是物件,那麼對於函式物件這個變數是可以隨意使用的,比如作為賦值語句的右值,作為引數等。

當被作為函式物件看待時,函式體的語句程式碼並不會被執行,只有明確是函式呼叫時,才會觸發函式體內的語句程式碼的執行。

例如:

var a = function () {
    return 2;
}
var b = a;    //將函式物件a的引用賦值給b
var c = a();  //呼叫a函式,並將返回值賦值給c

函式的呼叫可分為四種場景:

  • 作為普通函式被呼叫
  • 作為物件的方法被呼叫
  • 作為建構函式被呼叫
  • 通過 call() 或 apply() 間接的呼叫

不同場景的呼叫所造成的區別就是,函式呼叫時的上下文(this)區別、作用域鏈的區別;

作為普通函式被呼叫

通常來說,直接使用函式名+() 的形式呼叫,就可以認為這是作為函式被呼叫。如果有藉助 bind() 時會是個例外的場景,但一般都可以這麼理解。

如果只是單純作為函式被呼叫,那麼通常是不用去考慮它的上下文、它的this值,因為這個時候,函式的用途傾向於處理一些通用的工作,而不是特定物件的特定行為,所以需要使用 this 的場景不多。

普通函式被呼叫時的作用域鏈的影響因素取決於這個函式被定義的位置,作用域鏈是給變數的作用域使用的,變數的作用域分兩種:全域性變數、函式內變數,作用域鏈決定著函式內的變數取值來源於哪裡;

普通函式被呼叫時的上下文在非嚴格模式下,一直都是全域性物件,不管這個函式是在巢狀函式內被呼叫或定義還是在全域性內被定義或呼叫。但在嚴格模式下,上下文是 undefined。

作為物件的方法被呼叫

普通的函式如果掛載在某個物件內,作為物件的屬性存在時,此時可從物件角度稱這個函式為物件的方法,而通過物件的引用訪問這個函式型別的屬性並呼叫它時,此時稱為方法呼叫。

方法呼叫的上下文(this)會指向掛載的這個物件,作用域鏈仍舊是按照函式定義的位置生成。

var a = {
    b: 1,
    c: function () {
        return this.b;
    }
}
a.c();  //輸出1,a.c() 稱為物件的方法呼叫
a["c"](); //物件的屬性也可通過[]訪問,此種寫法也是呼叫物件a的c方法

只有明確通過物件的引用訪問函式型別的屬性並呼叫它的行為才稱為物件的方法呼叫,並不是函式掛載在物件上,它的呼叫就是方法呼叫,需要注意下這點,看個例子:

var d = a.c;
d();  //將物件的c函式引用賦值給d,呼叫d,此時d()是普調的函式呼叫,上下文在非嚴格模式下是全域性物件,不是物件a

下面通過一個例子來說明普通函式呼叫和物件的方法呼叫:

var a = 0;
var o = {
    a:1,
    m: function () {
        console.log(this.a); 
        f();  //f() 是函式呼叫
        function f() {
            console.log(this.a);
        }
    }
}
o.m(); //輸出 1 0,因為0.m()是方法呼叫,m中的this指向物件o,所以輸出

輸出1 0,因為 o.m() 是方法呼叫,m 中的 this 指向物件 o,所以輸出的 a 是物件 o 中 a 屬性的值 1;

而 m 中雖然內嵌了一個函式 f,它並不掛載在哪個物件像,f() 是對函式 f 的呼叫,那麼它的上下文 this 指向的是全域性物件。

所以,對於函式的不同場景的呼叫,重要的區別就是上下文。

作為建構函式被呼叫

普通函式掛載在物件中,通過物件來呼叫稱方法;而當普通函式結合 new 關鍵字一起使用時,被稱為建構函式。

建構函式的場景跟其他場景比較不同,區別也比較大一些,除了呼叫上下文的區別外,在實參處理、返回值方面都有不同。

如果不需要給建構函式傳入引數,是可以省略圓括號的,如:

var o = new Object();
var o = new Object;

對於方法呼叫或函式呼叫圓括號是不能省略的,一旦省略,就只會將它們當做物件處理,並不會呼叫函式。

建構函式呼叫時,是會建立一個新的空物件,繼承自建構函式的 prototype 屬性,並且這個新建立的空物件會作為建構函式的上下文,如:

var o = {
    a:1,
    f:function () {
        console.log(this.a);
    }
}
o.f();  //輸出1
new o.f();  //輸出undefined

如果是 o.f() 時,此時是方法呼叫,輸出 1;

而如果是 new o.f() 時,此時 f 被當做建構函式處理,this 指向的是新建立的空物件,空物件沒有 a 這個屬性,所以輸出 undefined。

建構函式通常不使用 return 語句,預設會建立繼承自建構函式 prototype 的新物件返回。但如果硬要使用 return 語句時,如果 return 的是個物件型別,那麼會覆蓋掉建構函式建立的新物件返回,如果 return 的是原始值時,return 語句無效。

var o = {
    f:function () {
        return [];
    }
}

var b = new o.f();  //b是[] 空陣列物件,而不是f

間接呼叫

call()apply() 是 Function.prototype 提供的函式,所有的函式物件都繼承自 Function.prototype,所有都可以使用這兩個函式。它們的作用是可以間接的呼叫此函式。

什麼意思,也就是說,任何函式可以作為任何物件的方法來呼叫,即使這個函式並不是那個物件的方法。

var o = {
    a:1,
    f:function () {
        console.log(this.a);
    }
}
o.f(); //輸出1
var o1 = {
    a:2
}
o.f.call(o1); //輸出2

函式 f 原本是物件 o 的方法,但可以通過 call 來間接讓函式 f 作為其他物件如 o1 的方法呼叫。

所以間接呼叫本質上也還是物件的方法呼叫。應用場景可以是子類用來呼叫父類的方法。

那麼函式的呼叫其實按場景來分可以分為三類:作為普通函式被呼叫,作為物件方法被呼叫,作為建構函式被呼叫。

普通函式和物件方法這兩種區別在於上下文不一樣,而建構函式與前兩者區別更多,在引數處理、上下文、返回值上都有所區別。

如果硬要類比於 Java 的函式方面,我覺得可以這麼類比:

  •      普通函式的呼叫 VS 公開許可權的靜態方法
  •      物件方法的呼叫 VS 物件的公開許可權的方法
  •      建構函式的呼叫 VS 建構函式的呼叫

左邊 JavaScript,右邊 Java,具體實現細節很多不一樣,但大體上可以這麼類比理解。

函式引數

引數分形參和實參兩個概念,形參是定義時指定的引數列表,期望呼叫時函式所需傳入的引數,實參是實際呼叫時傳入的引數列表。

在 JavaScript 中,不存在 Java 裡方法過載的場景,因為 JavaScript 不限制引數的個數,如果實參比形參多,多的省略,如果實參比形參少,少的引數值就是 undefined。

這種特性讓函式的用法變得很靈活,呼叫過程中,根據需要傳入所需的引數個數。但同樣的,也帶來一些問題,比如呼叫時沒有按照形參規定的引數列表來傳入,那麼函式體內部就要自己做相對應的處理,防止程式因引數問題而異常。

同樣需要處理的還有引數的型別,因為 JavaScript 是弱型別語言,函式定義時無需指定引數型別,但在函式體內部處理時,如果所期望的引數型別與傳入的不一致,比如希望陣列,傳入的是字串,這種型別不一致的場景JavaScript雖然會自動根據型別轉換規則進行轉換,但有時轉換結果也不是我們所期望的。

所以,有些時候,函式體內部除了要處理形參個數和實參個數不匹配的場景外,最好也需要處理引數的型別檢查,來避免因型別錯誤而導致的程式異常。

arguments

函式也是個物件,當定義了一個函式後,它繼承自 Function.prototype 原型,在這個原型中定義了所有函式共有的基礎方法和屬性,其中一個屬性就是 arguments。

這個屬性是一個類陣列物件,按陣列序號順序儲存著實參列表,所以在函式內使用引數時,除了可以使用形參定義的變數,也可以使用 arguments。

var a = function (x, y) {
    //x 和 arguments[0]等效
    console.log(x);
    console.log(arguments[0]);
    console.log(arguments[1]);
    console.log(arguments[2]);
}

a(5); //輸出 5 5 undefined undefined
a(5, 4, 3); //輸出 5 5 4 3

所以,雖然函式定義時聲明瞭三個引數,但使用的時候,並不一定需要傳入三個,當傳入的實參個數少於定義的形參個數時,相應形參變數對應的值為 undefined;

相反,當傳入實參個數超過形參個數時,可用 arguments 來取得這些引數使用。

引數處理

因為函式不對引數個數、型別做限制,使用時可以傳入任意數量的任意型別的實參,所以在函式內部通常需要做一些處理,大體上從三個方面進行考慮:

  • 形參個數與實參個數不符時處理
  • 引數預設值處理
  • 引數型別處理

下面分別來講講:

形參個數與實參個數不符時處理

通過 argument.length 可以獲取實參的個數,通過函式屬性 length 可以獲取到形參個數,知道形參個數和實參個數就可以做一些處理。如:

var a = function (x) {
    if (arguments.length !== arguments.callee.length) {
        throw Error("...");
    }
}

上述程式碼表示當傳入的實參個數不等於形參個數時,拋異常。

形參個數用:arguments.callee.length 獲取,callee 是一個指向函式本身物件的引用。這裡不能直接用 length 或 this.length,因為在函式呼叫一節說過,當以不同場景使用函式時,上下文 this 的值是不同的,不一定指向函式物件本身。

在函式體內部要獲取一個指向函式本身物件的引用有三種方式:

  • 函式名
  • arguments.callee
  • 作用域下的一個指向該函式的變數名
引數預設值處理

通常是因為實參個數少於形參的個數,導致某些引數並沒有被定義,函式內使用這些引數時,引數值將會是 undefined,為了避免會造成一些邏輯異常,可以做一些預設值處理。

var a = function (x) {
    //根據形參實參個數做處理
    if (arguments.length !== arguments.callee.length) {
        throw Error("...");
    }
    //處理引數預設值
    x = x || "default"; // 等效於 if(x === undefined) x = "default";

}
引數型別處理
var a = function (x) {
    //根據形參實參個數做處理
    if (arguments.length !== arguments.callee.length) {
        throw Error("...");
    }
    //處理引數預設值
    x = x || "default"; // 等效於 if(x === undefined) x = "default";
    //引數型別處理
    if (Array.isArray(x)) {
        //...   
    }
    if (x instanceof Function) {
        //...   
    } 
    //...
}

引數型別的處理可能比較常見,通過各種輔助手段,確認所需的引數型別究竟是不是期望的型別。

多個引數時將其封裝在物件內

當函式的形參個數比較多的時候,對於這個函式的呼叫是比較令人頭疼的,因為必須要記住這麼多引數,每個位置應該傳哪個。這個時候,就可以通過將這些引數都封裝到物件上,函式呼叫傳參時,就不必關心各個引數的順序,都新增到物件的屬性中即可。

//函式用於複製原始陣列指定起點位置開始的n個元素到目標陣列指定的開始位置
function arrayCopy(fromArray, fromStart, toArray, toStart, length) {
    //...
}

//外部呼叫時,傳入物件內只要有這5個屬性即可,不必考慮引數順序,同時這種方式也可以實現給引數設定預設值
function arrayCopyWrapper(args) {
    arrayCopy(args.fromArray,
                args.fromStart || 0, 
                args.toArray,
                args.toStart || 0,
                args.length);
}
arrayCopyWrapper({fromArray:[1,2,3], fromStart:0, toArray:a, length:3});

第二種方式相比第一種方式會更方便使用。

函式特性

函式既是函式,也是物件。它擁有類似其他語言中函式的角色功能,同時,它本身也屬於一個物件,同樣擁有物件的相關功能。

當作為函式來對待時,它的主要特性也就是函式的定義和呼叫:如何定義、如何呼叫、不同定義方式有和區別、不同調用方式適用哪些場景等等。

而當作為物件來看待時,物件上的特性此時也就適用於這個函式物件,如:動態為其新增或刪除屬性、方法,作為值被傳遞使用等。

所以,函式的引數型別也可以是函式,函式物件也可以擁有型別為函式的屬性,此時稱它為這個物件的方法。

如果某些場景下,函式的每次呼叫時,函式體內部都需要一個唯一變數,此時通過給函式新增屬性的方式,可以避免在全域性作用域內定義全域性變數,這是 Java 這類語言做不到的地方。

類似需要跟蹤函式每次的呼叫這種場景,就都可以通過對函式新增一些屬性來實現。

function uniqueCounter() {
    return uniqueCounter.counter++;
}
uniqueCounter.counter = 0;

var a = uniqueCounter();  //a = 0;
var b = uniqueCounter();  //b = 1;
var c = uniqueCounter();  //c = 2;

雖然定義全域性變數的方式也可以實現,但容易汙染全域性空間的變數。

函式屬性

除了可動態對函式新增屬性外,由於函式都是繼承自 Function.prototype 原型,因此每個函式其實已經自帶了一些屬性,包括常用的方法和變數,比如上述介紹過的 arguments。

這裡就來學下,一個函式本身自帶了哪些屬性,不過函式比較特別,下面介紹的一些屬性並沒有被納入標準規範中,但各大瀏覽器卻都有實現,不過使用這類屬性還是要注意下:

arguments

上述介紹過,這個屬性是個類陣列物件,用於儲存函式呼叫時傳入的實參列表。

但有一點需要注意,在嚴格模式下,不允許使用這個屬性了,這個變數被作為一個保留字了。

length

上述也提過,這個屬性表示函式宣告時的形參個數,也可以說是函式期望的引數個數。

有一點也需要注意,在函式體內不能直接通過 length 或 this.length 來訪問這個屬性,因為函式會跟隨著不同的呼叫方式有不同的上下文 this,並不一定都指向函式物件本身。

而 arguments 物件中還有一個屬性 callee,它指向當前正在執行的函式,在函式體內部可以通過 arguments.callee 來獲取函式物件本身,然後訪問它的 length 屬性。

在函式外部,就可以直接通過訪問函式物件的屬性方式直接獲取 length。如:

var a = function (x, y) {
    console.log(arguments.length);
    console.log(arguments.callee.length);
}

a(1); // 輸出 1 2,實參個數1個,形參個數2個
a.length;  //2

但需要注意一點,在嚴格模式下,函式體內部就不能通過 arguments.callee.length 來使用了。

caller

caller 屬性表示指向當前正在執行的函式的函式,也就是當前在執行的函式是在哪個函式內執行的。這個是非標準的,但大多瀏覽器都有實現。

在嚴格模式下,不能使用。

還有一點需要注意的是,有的書裡是說這個 caller 屬性是函式的引數物件 arguments 裡的一個屬性,但某些瀏覽器中,caller 是直接作為函式物件的屬性。

總之,arguments,caller,callee 這三個屬性如果要使用的話,需要注意一下。

name

返回函式名,這個屬性是 ES6 新增的屬性,但某些瀏覽器在 ES6 出來前也實現了這個屬性。即使不通過這個屬性,也可以通過函式的 toSring() 來獲取函式名。

bind()

用於將當前函式繫結至指定物件,也就是作為指定物件的方法存在。同時,這個函式會返回一個函式型別的返回值,所以通過 bind() 方式,可以實現以函式呼叫的方式來呼叫物件的方法。

function f(y) {
    return this.x + y;
}
var o = {x:1}

var g = f.bind(o);
g(2);  //輸出 3

此時 g 雖然是個函式,但它表示的是物件 o 的方法 f,所以 g() 這種形式雖然是函式呼叫,但實際上卻是呼叫 o 物件的方法 f,所以方法 f 函式體中的 this 才會指向物件 o。

另外,如果呼叫 bind() 時傳入了多個引數,第一個引數表示需要到的物件,剩餘引數會被使用到當前函式的引數列表。

prototype

該屬性名直譯就是原型,當函式被當做建構函式使用時才有它的意義,用於當某個物件是從建構函式例項化出來的,那麼這個物件會繼承自這個建構函式的 prototype 所指向的物件。

雖然這個屬性的中文直譯就是原型,但我不喜歡這麼稱呼它,因為原型應該是指從子物件的角度來看,它們繼承的那個物件,稱作它們的原型,因為原型就是類似於 Java 裡父類的概念。

雖然,子物件的原型確實由建構函式的 prototype 決定,但如果將這個詞直接翻譯成原型的話,那先來看下這樣的一句表述:通過建構函式建立的新物件繼承自建構函式的原型。

沒覺得這句話會有一點兒歧義嗎?建構函式本質上也是一個物件,它也有繼承結構,它也有它繼承的原型,那麼上面那句表述究竟是指新物件繼承自建構函式的原型,還是建構函式的 prototype 屬性值所指向的那個物件?

所以,你可以看看,在我寫的這系列文章中,但凡出現需要描述新物件的原型來源,我都是說,新物件繼承自建構函式的 prototype 所指向的那個物件,我不對這個屬性名進行直譯,因為我覺得它會混淆我的理解。

另外,在 prototype 指向的原型物件中新增的屬性,會被所有從它關聯的建構函式創建出來的物件所繼承。所有,陣列內建提供的一些屬性方法、函式內建提供的相關屬性方法,實際上都是在 Array.prototype 或 Function.prototype 中定義的。

call() 和 apply()

這兩個方法在函式呼叫一小節中介紹過了,因為在 JavaScript 中的函式的動態的,任意函式都可以作為任意物件的方法被呼叫,即使這個函式宣告在其他物件中。此時,就需要通過間接呼叫實現,也就是通過 call()apply()

一種很常見的應用場景,就是用於呼叫原型中的方法,類似於 Java 中的 super 呼叫父類的方法。因為子類可能重寫了父類的方法,但有時又需要呼叫父類的方法,那麼可通過這個實現。

toString()

Function.prototype 重寫了 Object.prototype 中提供的 toString 方法,自定義的函式會通常會返回函式的完整原始碼,而內建的函式通常返回 [native code] 字串。

藉助這個可以獲取到自定義的函式名。

巢狀函式

巢狀函式就是在函式體中繼續定義函式,需要跟函式的方法定義區別開來。

函式的方法定義,是將函式看成物件,定義它的屬性,型別為函式,這個函式只是該函式物件的方法,並不是它的巢狀函式。

而巢狀函式需要在函式體部分再用 function 定義的函式,這些函式稱為巢狀函式。

var x = 0;
var a = function () {
    var x = 1;
    function b() {
        console.log(x);
    }

    var c = function () {
        console.log(x);
    }

    b();  //輸出:1
    c();  //輸出:1
    a.d();//輸出:0
}

a.d = function () {
    console.log(x);
}

函式 b 和 c 是巢狀在函式 a 中的函式,稱它們為巢狀函式。其實本質就是函式體內部的區域性變數。

函式 d 是函式 a 的方法。

巢狀函式有些類似於 Java 中的非靜態內部類,它們都可以訪問外部的變數,Java 的內部類本質上是隱式的持有外部類的引用,而 JavaScript 的巢狀函式,其實是由於作用域鏈的生成規則形成了一個閉包,以此才能巢狀函式內部可以直接訪問外部函式的變數。

閉包涉及到了作用域鏈,而繼承涉及到了原型鏈,這些概念後面會專門來講述。

這裡稍微提下,閉包通俗點理解也就是函式將其外部的詞法作用域包起來,以便函式內部能夠訪問外部的相關變數。

通常有大括號出現都會有閉包,所以函式都會對應著一個閉包。

高階應用場景

利用函式的特性、閉包特性、繼承等,能夠將函式應用到各種場景。

使用函式作為臨時名稱空間

JavaScript 中的變數作用域大概就兩種:全域性作用域和函式內作用域,函式內定義的變數只能內部訪問,外部無法訪問。函式外定義的變數,任何地方均能訪問。

基於這點,為了保護全域性名稱空間不被汙染,常常利用函式來實現一個臨時的名稱空間,兩種寫法:

var a;
(function () {
    var a = 1;
    console.log(a);  //輸出1
})();
console.log(a);  //輸出undefined

簡單說就是定義一個函式,定義的同時末尾加上 () 順便呼叫執行函式體內容,那麼這個函式的作用其實也就是建立一個臨時的名稱空間,在函式體內部定義的變數不用擔心與其他人起衝突。

(function () {
   //...
}());

外層括號不能漏掉,末尾函式呼叫的括號也不能漏掉,這樣就可以了,至於末尾的括號是放在外層括號內,還是外都可以。

使用函式封裝內部資訊

閉包的特性,讓 JavaScript 雖然沒有類似 Java 的許可權控制機制,但也能近似的模擬實現。

因為函式內的變數外部訪問不到,而函式又有閉包的特性,巢狀函式可以包裹外部函式的區域性變數,那麼外部函式的這些區域性變數,只有在巢狀函式內可以訪問,這樣就可以實現對外隱藏內部一些實現細節。

var a = function () {
    var b = 1;
    return {
        getB: function () {
            return b;
        }
    }
}
console.log(c.b); //輸出 undefined
var c = a();   //輸出 1

大家好,我是 dasu,歡迎關注我的公眾號(dasuAndroidTv),公眾號中有我的聯絡方式,歡迎有事沒事來嘮嗑一下,如果你覺得本篇內容有幫助到你,可以轉載但記得要關注,要標明原文哦,謝謝支援~
dasuAndroidTv2.png