【克服 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')
  1. 一開始會產生 Global 的 execution context,並且做 hoisting。
  2. 執行到 greet() 的時候,產生了一個 execution context,裡面有變數 whattosay。
  3. greet() 執行完畢,此 execution context 會被garbage collection,但,whattosay 實際上不會被收走,而是存在記憶體裡面。
  4. 接著 sayHi 這個匿名函式執行,也創造了一個 execution context。
  5. 當此匿名函式要執行的時候,在找 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。

探究一下程式執行的過程:

  1. 創造一個 Global Execution context,並且抬升 buildFunctions 與 fs。
  2. 接著執行 buildFunctions,創造了execution context,並抬升變數 i 與 arr,之後執行 for 迴圈,每次都會 push 一個function 進入 arr,反覆執行三次之後,i 變數是 3,這邊要注意一點,我跑迴圈的時候,是不會執行裡面的 function(){console.log(i)} 的,因為並沒有呼叫,也就是說,i 不會帶入值,arr 陣列裡面有三個長的一模一樣的 function。
  3. 當 buildFunctions 結束執行之後,會抽離 execution conrext,但會留下變數 i=3arr[3] 在記憶體之中。
  4. 接著執行 fs[0]() 會透過 scope chain 去找,最後找到 i=3,所以 console.log(3) 印出 3。
  5. 之後 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,有兩種方法可以處理:

  1. 利用 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 的值在不同的記憶體空間,利用閉包取值時結果不一樣。

  1. 利用 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')

過程解析:

  1. 一開始會產生 Global execution context,並且 hoisting。
  2. 執行 makeGreeting 並傳入參數 en,創造 makeGreeting 的 execution context,並將回傳的 function 指派給 greetEnglish。
  3. makeGreeting() 執行完畢,此 execution context 被回收,但變數 language 會存在記憶體當中。
  4. 執行 makeGreeting 並傳入參數 es,創造 makeGreeting 的 execution context,並將回傳的 function 指派給 greetSpanish。
  5. makeGreeting() 執行完畢,此 execution context 被回收,但變數 language 會存在記憶體當中。
  6. 執行 greetEnglish('John', 'Doe'),創造了一個 execution context,透過 closure 的特性找到了對應的 language 變數,印出 Hello John Doe。
  7. 執行完畢,回收此 execution context。
  8. 執行 greetSpanish('John', 'Doe'),透過 closure 的特性找到了對應的 language 變數,印出 Hola John Doe。
  9. 執行完畢,回收此 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'])

講完這三個方法,那什麼時候會需要用到這些功能呢?

  1. 函數借用 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 使用。

  1. 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) 

函數式程式設計的優點:

  • 簡潔
  • 速度快
  • 維護性好
  • 可重複利用

Codepen

參考資料:

JavaScript 基礎二三事|2018 iT 邦幫忙鐵人賽

那克斯的學習筆記

史考特の工程師之旅

TOP