JavaScript是一門函式式的物件導向程式語言。瞭解函式將會是瞭解物件建立和操作、原型及原型方法、模組化程式設計等的重要基礎。函式包含一組語句,它的主要功能是程式碼複用、隱藏資訊和組合呼叫。我們程式設計就是把一個需求拆分成若干函式和資料結構的組合實現,其中演算法又是實現正確函式的方法論。我們先介紹基礎知識:① 在JavaScript中,函式物件背後到底有什麼;② 函式呼叫的模式有多少種;③ 作用域與閉包。至於遞迴、記憶、回撥、級聯、模組、柯里化等,我們放到進階知識中再涉獵。
一、 函式物件
前面我們提到,在JavaScript中,函式也是物件,物件的原型連線到Object.prototype,函式物件則連線到Function.prototype,再連線到Object.prototype。我們可以看看這兩個物件中具有什麼樣的屬性:
1 var sum = function(a, b) { 2 return a + b; 3 } 4 5 console.log(sum.prototype);
輸出發現,這個Function.prototype中有一個constructor構造器屬性,值就是剛才我們定義的這個函式的內容。而Function.prototype則連線到Object.prototype。也就是說當我們建立一個函式物件時,Function的構造器會自動執行類似這樣的一些程式碼: this.prototype = {constructor: this}; 。換句話說,其實constructor屬性的意義不大,因為它自己本身就是這個屬性。只是因為JavaScript為了模仿其他的面嚮物件語言,做出了這樣一個“偽類”,以constructor作為中間層而已。
二、函式呼叫
函式的特別之處在於:它可以被呼叫。呼叫函式時,作業系統會暫停當前函式的執行,把控制權和引數傳遞給呼叫的函式。函式除了收到給出的形式引數,還會接收兩個新引數:this 和 arguments。我們在物件導向的程式設計中最需要注意的就是需要用到的方法裡面this的值到底是什麼。實際上,this的值取決於函式呼叫的模式。在JavaScript中,函式呼叫的模式一共有4種,分別是:方法呼叫模式、函式呼叫模式、構造器呼叫模式和apply呼叫模式:
1. 方法呼叫模式
物件中的函式我們稱為方法。此時,該函式中this的值為直接所屬的物件。
2. 函式呼叫模式
當函式不是物件的屬性,也就是在一般情況下,我們像上面舉例的程式碼一樣直接宣告的一個函式。此時,該函式中this的值指向全域性物件。這種呼叫方法是最簡單直接的方法:
1 var sub = function(a, b) { 2 return a - b; 3 }
但需要注意的是,由於語言設計的失誤,一個函式的內部函式this的值,本應該為這個函式this的值,而真實情況是它卻指向了全域性物件,因此,我們需要更機智地提供一種解決方法:
1 var motherLyn = { 2 generation: 'mother', 3 name: 'Lyn', 4 getFullName: function() { 5 var that = this; 6 7 var getGeneration = function() { 8 return that.generation; 9 } 10 11 var getName = function() { 12 return that.name; 13 } 14 15 return getGeneration() + getName(); 16 } 17 }
如果我們缺少了第五行的程式碼,由於getGeneration和getName兩個函式處於getFullName函式的內部,它們的this會指向window物件(全域性物件),而window物件中沒有generation和name屬性,將會返回undefined。而在getFullName函式中宣告一個that變數並讓它指向this,避免了在內部函式中使用this,才能讓程式碼向我們期望的方向執行。
3. 構造器呼叫模式
使用這種方式時,我們務必要把函式名字的首字母大寫,以與函式呼叫方式區分開來,每當看到首字母大寫的函式就會本能地加上new關鍵字。這也是大家的一種約定,使我們不會因為疏忽而呼叫時忘記新增new關鍵字,增加測試工作:
1 var MotherLyn = function(generation, name) { 2 this.generation = generation; 3 this.name = name; 4 }; 5 6 var person = new MotherLyn("mother", "Lyn");
這時候this仍然指向全域性變數,只有使用new關鍵字時this才會指向函式物件本身,JavaScript也會提示此建構函式可能會轉換為類宣告。在可以不使用new的情況下,我們可以儘量不使用這種形式的構造器,因為當發生錯誤時,既沒有編譯時警告,也沒有執行時警告。
在以後關於物件建立的講解中我們將看到多種建立物件的方式,也包括完全不使用new的建立方法,我們需要結合不同情況使用。
在以後關於原型的講解中我們會看到一個物件的例項、它的構造器和它的原型三者之間的關係,這非常重要。
4. Apply呼叫模式
前面提到,函式本質上就是物件,因此函式是可以具有方法的。例如使用Function.apply方法,我們可以重定義某個方法內this的值,以陣列的形式傳遞期望傳入的引數。這樣哪怕一個物件沒有繼承另一個物件,也可以使用它裡面的方法:
1 var myArray = [5, 6]; 2 var addArray = sum.apply(null, myArray); //呼叫到文章首部的sum函式,結果值為11 3 4 var myObj = { 5 generation: 'my', 6 name: 'Obj' 7 }; 8 var getObjName = motherLyn.getFullName.apply(myObj); 9 //呼叫到上面的motherLyn物件中的getFullName方法,結果輸出myObj
我們發現,哪怕上述的myObj並沒有繼承自motherLyn物件,它仍然能通過apply方法,重定義this的值,重用其中的方法。
注:上面我們用到了apply方法以陣列的形式重定義了arguments引數,但實際上由於語言設計的失誤,arguments引數並不是一個陣列,而是一個array-like物件。也就是說,它除了有一個length屬性以外,沒有Array.prototype中的像concat這樣的其他方法。
三、 作用域與閉包
1. 作用域
在程式語言中,作用域控制變數的可見性、生命週期、名稱衝突和記憶體管理,對於程式設計師來說是一項重要的服務。儘管像其他類C語法的語言一樣,JavaScript也擁有函式作用域,可是直到ES5標準卻一直沒有塊級作用域。這一點也是設計上比較糟糕的地方:
1 for(var i = 0; i < 5; i++) { 2 console.log(i); 3 }; 4 5 console.log(i);
像以上的程式碼會輸出從0到5的六個i,原因是因為JavaScript缺少塊級作用域,i的確從for語句中被洩露出來了。為此在ES6標準中let和const兩種宣告變數的方式被提出了(當然這兩個關鍵字還會解決很多其他問題),這一點我們會放到後續的進階知識中講解到。像以上這個程式碼使用let取代var保證了i不會被洩露,而且i不會被宣告為window物件的屬性,有效避免了汙染全域性物件的問題。
在很多現代語言中,我們更加推薦延遲宣告變數。但在JavaScript中,由於缺少塊級作用域,儘管使用var宣告變數還會得到變數提升(先使用再宣告也是可以的),但延遲宣告變數可能會編寫出混亂的難以維護的程式碼。因此我們還是要在函式體的頂部將所有需要使用到的變數全部宣告出來。
2. 閉包
所謂閉包,就是可以訪問被它被建立時所處的上下文環境的函式。閉包支援了JavaScript實現更靈活更有邏輯性的表達方式,先前我們提到這麼多次“由於設計失誤”,現在我們終於可以誇獎一次“設計非常精彩”了。閉包最常見的用法就是返回一個函式:
1 var getMe = function() { 2 var name = 'MotherLyn'; 3 var displayName = function() { 4 console.log(name); 5 } 6 return displayName; 7 }
上述例子中的displayName函式就是典型的一個閉包,它可以獲得它被建立時上下文環境(也就是getMe函式)中的變數,這些變數將持續地保留直至內部函式不再需要使用(當然這一定程度上也會影響效能)。當我們需要呼叫這個displayName函式,我們這樣來寫:
1 var me = getMe(); 2 me();
第一句呼叫到了getMe函式,將它的返回值給到了內部函式,並賦值給了me,此時name值已經確定好了。最後呼叫到me函式來呼叫displayName函式。觀察以上的函式,或許我們可以考慮下閉包的作用:
① 在DOM操作中,我們的程式碼通常是作為使用者行為的回撥函式執行,也就是說為了響應使用者的某些行為而存在。因此在編寫可複用的web程式碼時,閉包具有重要意義:
1 <a href="#" id="red">Red</a> 2 <a href="#" id="green">Green</a> 3 <a href="#" id="blue">Blue</a>
1 function changeColor(color) { 2 return function() { 3 document.body.style.backgroundColor = color; 4 } 5 } 6 7 var change2Red = changeColor('red'); 8 var change2Green = changeColor('green'); 9 var change2Blue = changeColor('blue'); 10 11 document.getElementById('red').onclick = change2Red; 12 document.getElementById('green').onclick = change2Green; 13 document.getElementById('blue').onclick = chage2Blue ;
上面的程式碼跟物件導向的程式碼有點類似,它先建立了一個改換背景顏色的模板函式,通過賦不同的值建立不同的函式物件進行應用。
② 資料隱藏和封裝(這也是模組化程式設計的基礎):
1 var motherLyn = function(generation, name) { 2 return { 3 getFullName: function() { 4 return generation + name; 5 } 6 } 7 } 8 9 var me = motherLyn('mother', 'Lyn'); 10 11 console.log(me.getFullName())
在這個例子中,motherLyn作為一個建構函式,返回一個物件,我們不能使用new關鍵字建立例項,因此函式名我們採用了小寫開頭。創造了一個me例項以後,無法直接訪問generation和name兩個變數,只能通過返回物件中的函式訪問。由於JavaScript中內部函式的生命週期比它的外部函式要長,我們利用這一點模仿了物件導向的私有物件。
③ 設計失誤(for迴圈閉包詳解)
JavaScript中有一個非常常見的關於閉包的設計失誤,當我們在for迴圈中加入一層閉包,將會出現意外的結果:
1 <p>1</p> 2 <p>2</p> 3 <p>3</p> 4 <p>4</p>
1 var pList = document.querySelectorAll('p'); 2 3 for(var i = 0; i < pList.length; i++) { 4 pList[i].onclick = function() { 5 console.log(i); 6 } 7 }
觀察程式碼,我們獲取到HTML中的所有四個p結點,作為一個結點陣列。我們期望迴圈這個陣列,讓其每一個結點被點選時輸出它在陣列中的位置。但不幸的是,結果是每一次都輸出for迴圈結束以後i的值。也就是在以上的例子中會永遠輸出4。原因是內部函式實際上訪問外部函式的實際變數而非它的複製,對於這個問題,我們有許多種解決方案,最簡單的莫過於:
1 for(let i = 0; i < pList.length; i++) { 2 pList[i].onclick = function() { 3 console.log(i); 4 } 5 }
使用ES6標準中的let取代var,使i的作用域變為塊級作用域,這樣閉包中訪問到的i也被修正為迴圈過程中的i。我們也可以建立一個輔助函式,讓這個輔助函式返回繫結了當前i值的函式:
1 var helper = function(i) { 2 return function() { 3 console.log(i); 4 } 5 } 6 7 for(var i = 0; i < pList.length; i++) { 8 pList[i].onclick = helper(i); 9 }
當然還有其他的方法,我們比較不推薦的是將i繫結到迴圈當前的物件中作為一個屬性存在,這樣會汙染當前的物件。