前端入門12-JavaScript語法之函數
聲明
本系列文章內容全部梳理自以下幾個來源:
- 《JavaScript權威指南》
- MDN web docs
- Github:smyhvae/web
- Github:goddyZhao/Translation/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),公眾號中有我的聯系方式,歡迎有事沒事來嘮嗑一下,如果你覺得本篇內容有幫助到你,可以轉載但記得要關註,要標明原文哦,謝謝支持~
前端入門12-JavaScript語法之函數