【克服 JS 】第四章(下)物件與函式
JavaScript 全攻略:克服 JS 的奇怪部分,第四章(下)物件與函式 Object and Function,學習紀錄。
Udemy 課程連結:
JavaScript 全攻略:克服 JS 的奇怪部分
4-16 瞭解閉包
如果要深入瞭解 JavaScript,閉包 Closure 是很重要的觀念,但很難懂所以很多人不喜歡。
首先來一段程式碼:
function greet(whattosay) {
return function(name) {
console.log(whattosay + ' ' + name)
}
}
設定一個函式陳述句,在裏頭用函式表示式回傳一個函式,並利用範圍練 scope chain 的特性放入 whattosay,裏頭這個回傳函式沒有宣告 whattosay,於是它會外部(參照)查找,去找設定這個函式的 say 函式參數 whattosay。
當我們呼叫函式 say,會得到一個值,這個值是從函式 say 裡面return 返回的另一個函式。我們可以帶入參數,這樣呼叫函式裡的函式:
say('Hello')('Tony')
// "Hi Tony"
輸出為 Hi Tony。
修改程式碼,設定一個變數去接(指向)函式 say 的回傳值:
var sayHi = greet('Hi')
sayHi('Tony')
// "Hi Tony"
一樣可以執行,乍看很合理,但奇怪的事情是,whattosay 的值被設為 Hi,是在執行 greet('Hi')
時,但 greet()
執行完後,whattosay 的值應該就不存在了,為何在執行 sayHi()
時,還是能取得 whattosay 的值為 Hi。
因為它是閉包所以是可能的,這就是閉包的特性。
說明
當函式被呼叫,會在全域執行環境創造這個函式的執行環境,在函式執行環境裡執行它的程式,當函式結束,其函式執行環境也結束。
function greet(whattosay) {
return function(name) {
console.log(whattosay + ' ' + name)
}
}
var sayHi = greet('Hi')
sayHi('Tony')
- 一開始會產生 Global 的 execution context,並且做 hoisting。
- 執行到 greet() 的時候,產生了一個 execution context,裡面有變數 whattosay。
- greet() 執行完畢,此 execution context 會被garbage collection,但,whattosay 實際上不會被收走,而是存在記憶體裡面。
- 接著 sayHi 這個匿名函式執行,也創造了一個 execution context。
- 當此匿名函式要執行的時候,在找 whattosay 這個變數的時候,因為本身的 execution context 找不到,就透過 scope chain 找到了之前在記憶體中的 whattosay。
這種現象,我們就稱為閉包(closure),就算 execution context 沒有某些函式了,但 JavaScript 就是可以找到對應的變數。
合理嗎?不用管合不合理,這是 JavaScript 的特色,我們何時呼叫函式都沒差,不需要擔心它的外部環境是否還在執行,JavaScript 引擎永遠會確保無論我在執行哪個函式,它都能取用到應該要取用到的變數。
接下來看看,一個網路上一個經典的閉包例子:
function buildFunctions() {
var arr = []
for(var i = 0; i < 3; i++) {
arr.push(
function() {
console.log(i)
}
)
}
return arr
}
var fs = buildFunctions()
fs[0]()
fs[1]()
fs[2]()
建立一個函式 buildFunctions,裏頭宣告陣列 arr,並有個 for 迴圈陳述句,每次迴圈都會 把:
function() {
console.log(i);
}
塞入 arr 陣列裡,最後把 arr 回傳出來。 宣告變數 fs 並指向呼叫呼叫 buildFunctions() 後回傳的值,接著個別呼叫 fs 指向的陣列 arr,序號 0、1、2 的函式。
var fs = buildFunctions()
fs[0]()
fs[1]()
fs[2]()
// 3
// 3
// 3
執行結果都是 3。
探究一下程式執行的過程:
- 創造一個 Global Execution context,並且抬升 buildFunctions 與 fs。
- 接著執行 buildFunctions,創造了execution context,並抬升變數 i 與 arr,之後執行 for 迴圈,每次都會 push 一個function 進入 arr,反覆執行三次之後,i 變數是 3,這邊要注意一點,我跑迴圈的時候,是不會執行裡面的
function(){console.log(i)}
的,因為並沒有呼叫,也就是說,i 不會帶入值,arr 陣列裡面有三個長的一模一樣的 function。 - 當 buildFunctions 結束執行之後,會抽離 execution conrext,但會留下變數
i=3
與arr[3]
在記憶體之中。 - 接著執行
fs[0]()
會透過 scope chain 去找,最後找到i=3
,所以console.log(3)
印出 3。 - 之後
fs[0]()
執行完畢,抽離 execution context,同理fs[1]()
與fs[2]()
也是一樣的結果,這裡就不多贅述。
迴圈在將三個匿名函式放進陣列時,這三個匿名函數只是創造了,但沒有執行,所以 console.log(i)
並沒有將 i 的值傳進去。
直到最後使用 fs[0]()
fs[1]()
fs[2]()
,才是執行了這三個匿名函式,需要到外部環境取得 i 的值來顯示,雖然外部環境,也是就 buildFunctions() 已執行結束了,但因為閉包的關係,i 的值還保留在記憶體中讓三個匿名函式存取,此時 i 的值因為迴圈的關係已經從 0 跑到 3 了。
所以三個匿名函式的執行結果都是顯示 3。
結論,函式的執行與函式的宣告,其實是兩件事,函式執行的時候,就會透過閉包這個現象,配合 scope chain 去找出對應的變數。所以這就是為什麼印出來是 3 3 3 而不是 0 1 2。
如果要讓輸出結果為 0 1 2,有兩種方法可以處理:
- 利用 IIFE:
function buildFunctions2() {
var arr = []
for(var i = 0; i < 3; i++) {
arr.push(
(function(j) {
return function() {
console.log(j)
}
})(i)
)
}
return arr
}
var fs2 = buildFunctions2()
fs2[0]()
fs2[1]()
fs2[2]()
在 buildFunctionsc 函式內的迴圈,裏頭的 push 函式改成立即執行函式,每次立即函式被創造出來就立刻執行,而且每次立即函式的執行環境都不一樣,利用此方法可以保留 i 的值在不同的記憶體空間,利用閉包取值時結果不一樣。
- 利用 ES6 的 let 宣告變數:
function buildFunctions3() {
var arr = []
for(var i = 0; i < 3; i++) {
let j = i
arr.push(
function() {
console.log(j)
}
)
}
return arr
}
var fs3 = buildFunctions3()
fs3[0]()
fs3[1]()
fs3[2]()
let 強調在 {}
的區塊執行環境,每次 let 都會保留 j 在不同的記憶體位置,利用閉包取值時結果不一樣。
4-17 框架小叮嚀:Function Factories
利用閉包的特性,可以建立一些看似不可能的模式,例如改寫之前用不同語言打招呼的程式:
之前的程式碼(4-11 框架小叮嚀:重載函式):
function greet(firstname, lastname, language){
language = language || 'en';
if(language === 'en'){
console.log('Hello ' + firstname + ' ' + lastname);
}else if(language === 'es'){
console.log('Hola ' + firstname + ' ' + lastname);
}
}
greet('John' , 'Doe', 'en')
greet('John' , 'Doe', 'es')
這種作法的缺點是,若函式裡的判斷變多、傳進去的參數變多(目前就 3 個了,如果更多的話?),那呼叫時的易讀性會降低不少。
利用閉包的特性,修改其內容:
function makeGreetiong(language) {
return function(firstname, lastname) {
if (language === 'en') {
console.log('Hello ' + firstname + ' ' + lastname)
}
if (language === 'es') {
console.log('Hola ' + firstname + ' ' + lastname)
}
}
}
var greetEnglish = makeGreetiong('en')
var greetSpanish = makeGreetiong('es')
greetEnglish('John', 'Doe')
greetSpanish('John', 'Doe')
過程解析:
- 一開始會產生 Global execution context,並且 hoisting。
- 執行 makeGreeting 並傳入參數 en,創造 makeGreeting 的 execution context,並將回傳的 function 指派給 greetEnglish。
- makeGreeting() 執行完畢,此 execution context 被回收,但變數 language 會存在記憶體當中。
- 執行 makeGreeting 並傳入參數 es,創造 makeGreeting 的 execution context,並將回傳的 function 指派給 greetSpanish。
- makeGreeting() 執行完畢,此 execution context 被回收,但變數 language 會存在記憶體當中。
- 執行 greetEnglish('John', 'Doe'),創造了一個 execution context,透過 closure 的特性找到了對應的 language 變數,印出 Hello John Doe。
- 執行完畢,回收此 execution context。
- 執行 greetSpanish('John', 'Doe'),透過 closure 的特性找到了對應的 language 變數,印出 Hola John Doe。
- 執行完畢,回收此 execution context。
藉由此方法與閉包的特性,我們可以創造新函式,用閉包設定函式內的預設參數,來設定、優化重載函式的效果。
Codepen
Codepen:4-17 Function Factories
4-18 閉包與回呼(callback)
setTimeout
既是非同步,也用到了函式表示式和閉包的特性。
function sayHiLater() {
var greeting = 'Hi!'
setTimeout(function() { // 設定三秒後執行
console.log(greeting)
}, 3000)
}
sayHiLater() // 三秒後顯示 Hi!
setTimeout()
這個方法,接受我們傳入兩個參數(匿名函式、3000),這個被傳入的函式就是函式表示式(因為一級函式的特性,可以把函式被當成值傳來傳去)。
執行 sayHiLater() 後,過了三秒匿名函數才執行,此時雖然 sayHiLater() 的執行環境已結束了,變數理應隨著執行環境結束而從記憶體清除,但因為閉包的關係 greeting 的值還是有保留著讓匿名函數可以讀取。
回乎函式(Callback Function):把函式 b 傳給函式a,讓函式 a 可以做完某些事後再執行函式 b。
function tellMeWhenDone(callback) {
console.log('Do something ...')
callback()
}
tellMeWhenDone(function() {
console.log('I am done!')
})
// "Do something ..."
// "I am done!"
設定一個函式陳述句 tellMeWhenDone,若執行時傳入函式當參數給它,在 console.log('Do something ...')
這段程式碼執行完畢,它就會呼叫執行這個被傳入的函式,回應 tellMeWhenDone 呼叫的函式就是回乎函式。
4-19 call()、apply()與bind()
JavaScript 中 function 原生的三個方法(method)與用法,bind()
、call()
、apply()
。
當執行一個函式建立一個新的執行環境時,會自動產生一個 this 變數,指向這個函式所在的物件。bind()
、call()
、apply()
可以指定函式的 this 要指向哪個物件。
建立一個物件 person,有個成員函數 getFullName 會輸出他的兩個屬性值。
var person = {
firstname: 'John',
lastname: 'Doe',
getFullName: function() {
var fullname = this.firstname + ' ' + this.lastname
return fullname;
}
}
建立一個函式 logName,會執行 this.getFullName()
取得 fullname
,將 fullname
與隨便設的兩個輸入值 s1, s2 顯示出來。
var logName = function(lang1, lang2){
console.log('Logged: ' + this.getFullName())
console.log('Arguments: ' + lang1 + ' ' + lang2)
console.log('-----------')
}
logname() // Uncaught TypeError: this.getAllName is not a function
顯示錯誤,因為 logName() 裡的 this 是指向全域物件 window,那裡沒有 getFullName,而是 undefined 而我們試著呼叫 undefined,所以給我錯誤,所以合理。
如果我可以控制 this 指向誰就好了。
1. bind()
利用 bind 的方法來解決這個問題:
var logPersonName = logName.bind(person)
logPersonName()
使用 bind() 複製函式 logName,並指定新函式的 this 是指向物件 person,將新式數存在 logPersonName。
也可以直接在建立函式時立即指定 this 要指向 person:
var logPersonName2 = function(lang1, lang2) {
console.log(this.getFullName() + ' ' + s1 + ' ' + s2)
}.bind(person)
如果再次呼叫 logName 函式,還是會顯示錯誤,也就是原本的 logName 函式不受影響,因為用 bind() 綁定 this 的函式,也就是變數 logPersonName 指向的函式,並不是真正的 logName,而是 logName 函式的複製版本。
bind() 可以創造函式的拷貝版,並且可以透過傳入物件來綁定(指定)它的 this 是誰。
2. call()
call 與 bind 一樣,它們唯一的差別在於 call 是改變 this 之後,會順便執行呼叫 call 的 function。
logName.call(person, 'en' , 'es')
同樣修改 this 對象,bind() 會複製函數,但不會馬上執行,call() 則會直接執行。
3. apply()
同 call 也會直接執行,不同點在於定二個參數這個參數必須是陣列形式(array)。
logName.apply(person, ['en' , 'es'])
講完這三個方法,那什麼時候會需要用到這些功能呢?
- 函數借用 function borrowing:可以跟別的物件借方法來操作。
創建一個物件 person2,和物件 person 一樣,只是少了一個getFullName:
var person2 = {
firstname: 'John',
lastname: 'Doe',
}
//function borrowing
console.log(person.getFullName.apply(person2))
// "John Doe"
person2 不用再建立一個成員函式 getFullName ,可以利用 apply() 借 person 的 getFullName 給 person2 使用。
- function currying:複製一個函式並設定預設輸入值。
bind() 除了可以帶入欲綁定 this 的物件,也可以帶入函式的參數,但 bind 帶入的參數會成為這個函式參數的預設值,例如:
function multiply(a, b) { //輸出兩數相乘的值
return a*b;
}
var multiplyByTwo = multiply.bind(this, 2)
// this,就是欲綁定this的物件(在這裡範例沒什麼作用)
console.log(multiplyByTwo(4)) // 顯示 8
利用 bind() 複製函數 multiply 並設定原本第一個輸入值固定為 2,只要輸入原本的的第二個參數就好,這樣就可以快速產生一個乘以2的函數,不用再寫一次相乘的程式。
Codepen
Codepen:4-19 call()、apply()與bind()
4-20 函數式程式設計 Functional Programing
JavaScript 因為有一級函式,可以用來實作函數式程式設計,可以做一些在其他沒有一級函式的程式語言不能做的事,可用一些全新的方法來思考及設計程式。
來從一些範例看看函數式程式設計之美,先從簡單的陣列開始:
var arr1 = [1, 2, 3]
console.log(arr1) // [1, 2, 3]
var arr2 =[]
for (var i = 0; i < arr1.length; i++) {
arr2.push(arr1[i] * 2)
}
console.log(arr2) // [2, 4, 6]
沒有一級函式的程式語言中,放入函數中的東西是有限制的,一級函式可以做完全不同的事情,用一些全新的方法來思考及設計程式,看看如何用函數式程式設計來簡化程式:
var arr1 = [1, 2, 3]
console.log(arr1)
function mapForEach(arr, fn) {
var newArr = []
for (var i= 0; i < arr.length; i++) {
newArr.push(
fn(arr[i])
)
}
return newArr
}
var arr2 = mapForEach(arr1, function(item) {
return item * 2
})
console.log(arr2)
將建立 arr1 以外的新陣列程式碼,改在 mapForEach 函式裏頭處理,然後用變數 arr2 指向呼叫 mapForEach 函式後的的回傳值。
呼叫 mapForEach 函式時帶入兩個參數,一是參考對象陣列,二是匿名函式,告訴 mapForEach 函式要處理什麼,這裡告訴 mapForEach 要將陣列參數乘與二並回傳。
這種做法比原本的方式複雜,但做更多事,比如可以建立第三個陣列 arr3,回傳是否有值大於 2,可以快速新增內:
var arr3 = mapForEach(arr1, function(item) {
return item > 2
})
console.log(arr3)
但傳入的匿名函式輸入值只有固定一個,如果想加新參數,像是下面這個函式這樣,變成兩個輸入值。
var checkPastLimit = function(limiter, item) {
return item > limiter
}
必需想辦法將兩個參數變成一個才能傳入 mapForEach,可以用 bind() 做 function currying:
var arr4 = mapForEach(arr1, checkPastLimit.bind(this, 1))
console.log(arr4)
建立變數 arr4 並指向呼叫 mapForEach 後的回傳值,這裡一樣帶入 arr1 當參數,另一個參數帶入 checkPastLimit,這裡用bind 並不是要修改 this,而是為了用 bind 的特性預設參數,來達成 function curryin g的目的。
如果覺得呼叫 bind 很麻煩,有更簡潔的作法:
var checkPastLimitSimplified = function(limiter) {
return function(limiter, item){
return item > limiter
}.bind(this, limiter)
}
var arr5 = mapForEach(arr1, checkPastLimitSimplified(2))
console.log(arr5)
建立變數 checkPastLimitSimplified 指向一個匿名函式,這個函式可以帶入參數 limiter,若是執行它會回傳下方匿名函式:
return function(limiter, item){
return item > limiter
}.bind(this, limiter)
被回傳的匿名函式,可以再帶入另一個參數 limiter 和 item,然後回傳 item 參數是否大於 limiter 參數的布林值,用 bind 來預設一個參數 limiter。
最後建立變數 arr5,指向呼叫 mapForEach 函式的回傳值,帶入 arr1 陣列與呼叫 checkPastLimitSimplified(2) 的回傳值。
函式庫
JavaScript 資源庫,提供很多處理陣列和物件的函式。
首先載入 underscore.js,再來看看以下程式碼:
var arr1 = [1, 2, 3]
var arr6 = _.map(arr1, function(item) {
return item * 3
})
console.log(arr6)
var arr7 = _.filter([2, 3, 4, 5, 6, 7], function(item) {
return item % 2 === 0
})
console.log(arr7)
函數式程式設計的優點:
- 簡潔
- 速度快
- 維護性好
- 可重複利用