iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 19
15
Modern Web

重新認識 JavaScript系列 第 19

重新認識 JavaScript: Day 19 閉包 Closure

本系列文章已重新編修,並在加入部分 ES6 新篇章後集結成書,有興趣的朋友可至天瓏書局選購,感謝大家支持。

購書連結 https://www.tenlong.com.tw/products/9789864344130

讓我們再次重新認識 JavaScript!


相信很多初學者在學習 JavaScript 的時候,一直對「閉包」(closure) 有所疑惑,當年我也是。因為從字面上來看,完全看不出它所代表的東西。 那麼今天,我想透過這篇文章來與各位介紹「閉包」到底是什麼。


範圍鏈 Scope Chain

在開始談閉包之前,我們現來談談「範圍鏈」(Scope Chain) 的觀念。

過去幾回我們不斷地強調一個重點「切分變數有效範圍的最小單位是 "function" 」

也就是說,像下面這段程式碼:

function outer() {

  // 在 outer 這層拿不到變數 c
  // 但可以向外找到變數 a
  var b = a * 2;

  function inner(c) {
    // 由於範圍鍊的關係,雖然只有對 c 定義,
    // 但可以像上一層一層取得 a, b, c
    console.log(a, b, c);
  }

  inner(b * 3);
}

// 在 global 這層只有 a, 不認得 b 與 c
var a = 1;
outer(a);

內層的 function inner 可以讀取外層宣告的變數,但外層的 outer function 存取不到內層宣告的變數。 若是在自己層級找不到就會一層一層往外找,直到 Global 為止。

這種行為,我們就稱之為「範圍鏈」(Scope Chain)。

另外,有個地方要來複習一下:

修改一下上面範例,若是在函式 outer 中的 b,沒有透過 var 宣告時,會發生什麼事?

function outer() {
  // 把 var 拿掉
  b = a * 2;

  function inner(c) {
    console.log(a, b, c);
  }

  inner(b * 3);
}

var a = 1;
outer(a);

.
.
.

想起來了嗎? 沒錯,變數 b 會變成「全域變數」。

由於範圍鏈的關係,若是沒有宣告變數 b 則會沿著範圍鏈一路往外尋找這個變數的定義,於是最後就在 global 層生成一個變數。

(忘記的同學趕快再回去看: 重新認識 JavaScript: Day 10 函式 Functions 的基本概念 )

現在已經有了範圍鏈的觀念之後,那麼來個簡單的測驗:

var msg = "global."

function outer() {
  var msg = "local."

  function inner() {
    return msg;
  }

  return inner;
}

var innerFunc = outer();
var result = innerFunc();

console.log( result );    //  ?

猜猜看,最後的 console.log( result ); 會出現什麼?

.
.
.

相信聰明的你應該可以回答出 "local." 吧!

https://ithelp.ithome.com.tw/upload/images/20171222/200655041O5QcUlpt2.jpg

如果還不懂的話也沒關係,簡單做個說明。

在這個範例裡面,我特別加了一個「陷阱」 var innerFunc = outer();

當我透過 outer() (有小括號,代表是得到的是呼叫 outer 後 return 的結果) 把原本在外層存取不到的 inner 取得。 這時有些人可能會想說,既然 innerFunc 裡面的內容是 return msg 那這個 msg 肯定是外層的 'global' 吧?

可惜答案剛好相反。

有個觀念很重要,範圍鏈是在函式被定義的當下決定的,不是在被呼叫的時候決定。 所以即使我們在 Global 層透過 innerFunc() 去呼叫內部的 inner(),實際上取得的 msg 仍然是內層的 "local"。


閉包 Closure

其實已經講完了。 (無誤)

當內部 (inner) 函式被回傳後,除了自己本身的程式碼外,也可以 穿越 取得了內部函式「當時環境」的變數值,記住了執行當時的環境,這就是「閉包」。

還記得我們在前一天介紹的 IIFE (立即被呼叫的函式) 嗎?

for( var i = 0; i < 5; i++ ) {
  (function(i){
    window.setTimeout(function() {
      console.log(i);
    }, 1000 * i);
  })(i);
}

像這種方式,廣義來說也是儲存閉包的環境狀態的作法,在執行 setTimeout 的同時,會將當下的 i 鎖起來,延長它的生命。


回到剛剛範例。

function outer() {
  var msg = "local."

  function inner() {
    return msg;
  }

  return inner;
}

當你在呼叫函式以前,範圍鏈就已經被建立了。 因此我們可以在函式 (outer) 裡面「回傳」另一個內部的函式 (inner) 給外層的範圍,使得外層也可以透過「範圍鏈」取得內部的變數 (msg) 。

所以,之後不管你在哪裡呼叫 outer(),回傳的 inner() 中的 msg 永遠只會是 "local." 的結果,而不是外面的 msg


再來一個範例,我們來比較一下「使用閉包」與「沒有使用閉包」的差異。

我們寫一個「計數器」,每呼叫一次 counter() 時,數值都要再加一:

var count = 0;

function counter(){
  return ++count;
}

console.log( counter() );   // 1
console.log( counter() );   // 2
console.log( counter() );   // 3

大多數人會利用一個全域變數來儲存 count 的資訊,這很合理,不然當你每呼叫一次 counter() 就重新宣告 count ,那麼就永遠不會往上加了。 但是,要是當我們的程式碼開始變多了,過多的全域變數會造成不可預期的錯誤,像是你與同事間的變數名稱衝突、沒用到的變數無法回收等等的。

這時候改用閉包的做法就可以避免這些問題:

function counter(){
  var count = 0;

  function innerCounter(){
    return ++count;
  }

  return innerCounter;
}

var countFunc = counter();

console.log( countFunc() );   // 1
console.log( countFunc() );   // 2
console.log( countFunc() );   // 3

像這樣,我們把 count 封裝在 counter() 當中,不但可以讓裡面的 count 不會暴露在 global 環境造成變數衝突,也可以確保內部 count 被修改。

進一步簡化之後,還可以寫成這樣:

function counter(){
  var count = 0;

  return function(){
    return ++count;
  }
}

甚至搭配 ES6 的箭頭函數 (Arrow Function) 可以寫得更簡短:

var counter = () => {
  var count = 0;
  return () => ++count;
}

另外值得一提的是,過去我們需要新增另一個計數器時,可能會再新增另一個全域變數去儲存另一個 count 的狀態。 而改用閉包的寫法之後,只需要像這樣:


function counter(){
  var count = 0;

  return function(){
    return ++count;
  }
}

var countFunc = counter();
var countFunc2 = counter();

console.log( countFunc() );   // 1
console.log( countFunc() );   // 2
console.log( countFunc() );   // 3

console.log( countFunc2() );   // 1
console.log( countFunc2() );   // 2

此時你就會發現 countFunccountFunc2 分別是「獨立」的計數器實體,彼此不會互相干擾!


那麼以上就是今天介紹的「閉包」用法,用一句話簡單形容就叫「內神通外鬼」
感謝各位收看。


上一篇
重新認識 JavaScript: Day 18 Callback Function 與 IIFE
下一篇
重新認識 JavaScript: Day 20 What's "THIS" in JavaScript (鐵人精華版)
系列文
重新認識 JavaScript37
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中
2
Luke
iT邦研究生 5 級 ‧ 2017-12-28 10:56:11

https://ithelp.ithome.com.tw/upload/images/20171228/20096781rb8acEiWRT.png

沒有出現 1 2 3/images/emoticon/emoticon04.gif

Kuro Hsu iT邦新手 1 級 ‧ 2017-12-28 11:01:15 檢舉

因為你少了一個步驟 XD

function counter(){
  var count = 0;

  function innerCounter(){
    return ++count;
  }

  return innerCounter;
}

直接呼叫 counter() 得到的結果會是回傳出來的 innerCounter,也就是那個內部的 function。

所以正確的做法是要先透過某個變數將 counter() 回傳的 function 存起來:

var countFunc = counter();

然後再去呼叫 countFunc()

完整版:

function counter(){
  var count = 0;

  function innerCounter(){
    return ++count;
  }

  return innerCounter;
}

var countFunc = counter();

console.log( countFunc() );   // 1
console.log( countFunc() );   // 2
console.log( countFunc() );   // 3
Luke iT邦研究生 5 級 ‧ 2017-12-28 14:38:30 檢舉
function counter(){
  var count = 0;

  function innerCounter(){
    return ++count;
  }
  
  var tmpV = innerCounter();
  console.log( '>>>' + tmpV ); 
  return tmpV;
}
console.log( counter() );   // 1
console.log( counter() );   // 2
console.log( counter() );   // 3

先透過某個變數將 counter() 回傳的 function 存起來
那麼我在 counter 內 ,先用var tmpV = innerCounter();
存起來
這裡為什麼也沒辦法印?/images/emoticon/emoticon06.gif

Kuro Hsu iT邦新手 1 級 ‧ 2017-12-28 14:48:11 檢舉

有啊,我想你執行之後應該會印出三次 1 對吧 XD

這是因為你每次在呼叫 counter 的時候,在 function 裡面都會重新宣告一次

var count = 0;

所以說,即使你透過 tmpV 來儲存 innerCounter() 的結果,但每次都會是「新的」++count,也就是 1

像我的範例,是將

var countFunc = counter();

拉到 function counter(){ ... }) 的外面,由變數 countFunc 來保存 counter() 回傳的 innerCounter,同時也將裡面的 count 狀態給保存下來,這個時候 count 就不會每次都從 0 開始計算,這也是「閉包」Closure 最大的功能所在。

0
mingyangshih
iT邦新手 5 級 ‧ 2019-06-14 13:43:49

您好:
我想請教
var contFunc = counter();
console.log去看contFunc()執行後的結果,為何會自動回傳數值,而不會是innerCounter()這個function呢?
因為您有提到var contFunc = counter();會同時回傳程式碼及相關變數,所以有點不太懂。
麻煩了感謝
mingyang

Kuro Hsu iT邦新手 1 級 ‧ 2019-06-14 13:53:05 檢舉
function counter() {
  var count = 0;

  function innerCounter() {
    return ++count;
  }

  return innerCounter;
}

var countFunc = counter();

console.log( countFunc() );   // 1

我先重複一下你的問題,你想問的是為什麼 console.log( countFunc() ); 印出來的是 1 而不是 innerCounter () { ... } 對吧?

如果是這樣的話,我們先看到這行 var countFunc = counter();

countFunc 這個變數存進來的東西其實是 counter 這個 function 回傳的結果,也就是 innerCounter 對吧?

那麼再往下走,當我們執行 countFunc() 的時候,實際上也就是對 innerCounter 進行呼叫,那麼 innerCounter 回傳的是什麼?

就是經過累加的 count,也就是最後你所看到的數字了。

所以closure就是在Global執行function內的function,並且可以保留function內的變數,並且不會影響到Global的變數,這樣理解對嗎?

感謝

Kuro Hsu iT邦新手 1 級 ‧ 2019-06-14 14:19:26 檢舉

倒也不是只能在 Global 執行,你要在某個 function 包裝 closure 也是可以的。

主要作用就如你所說,用來延長變數的生命週期,以及封裝這個變數的狀態不受外面污染。

0
wuyuxin321000
iT邦新手 5 級 ‧ 2020-07-31 16:42:17

太赞了!羡慕Kuro大大的清晰的逻辑

0
啾啾丸
iT邦新手 3 級 ‧ 2020-10-03 05:55:49

內神通外鬼嘴角失守啊!
Kuro大大,精闢啊!

我要留言

立即登入留言