iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 18
12
Modern Web

重新認識 JavaScript系列 第 18

重新認識 JavaScript: Day 18 Callback Function 與 IIFE

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

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

讓我們再次重新認識 JavaScript!


在上一篇文章當中,我們介紹了函式的參數與 arguments 物件,那麼今天的分享中,我們繼續來看看 Function 在 JavaScript 的各種不同面貌。


Callback Function

你可能常聽到人家在講 Callback function,但你真的知道 Callback function 是什麼嗎? 其實 Callback function 跟一般的函式沒什麼不同,差別在於被呼叫執行的時機。

先前介紹事件的時候有說過,「JavaScript 是一個事件驅動 (Event-driven) 的程式語言」,而事件的概念就如同:

辦公室電話響了 (事件被觸發 Event fired) -> 接電話 (處理事件 Event Handler)

而寫成程式碼就類似:

// 註:這裡只是比喻,並沒有電話響這個事件 XD
Office.addEventListener( '電話響', function(){ /* 接電話 */ }, false);

可以看到,Office 透過 addEventListener 方法註冊了一個事件,當這個事件被觸發時,它會去執行我們所指定的第二個參數,也就是某個「函式」(接電話)。

換句話說,這個函式只會在滿足了某個條件才會被動地去執行,我們就可以說這是一個 Callback function。


把函式當作另一個函式的參數?

經歷過各種「事件」的你,相信也發現了一件事,所謂的「Callback function」其實就是「把函式當作另一個函式的參數,透過另一個函式來呼叫它」。

什麼意思呢? 除了剛剛介紹的「事件」以外,還有另一個經典的案例:

window.setTimeout( function(){ ... }, 1000);

如果我們希望隔某段時間之後,執行某件事,就可以透過 window.setTimeout 來幫助我們達成。

像是上面的範例中, window.setTimeout 帶有兩個參數,第一個是要做的事情,用一個函式來代表,第二個則是時間 (1/1000 秒, milliseconds)。 而第一個參數的函式也是 Callback function 的一種:「在經過了某段時間之後,才執行的函式」。

當然,上面兩個範例中的 Callback function 我們也可以把它單獨抽出來定義:

var handler = function() {
  /* 接電話 */
};

Office.addEventListener( '電話響', handler, false);

這樣會使你的程式看起來更清楚。

除了事件以外,還有另一個會需要用到 Callback function 的場景,就是「控制多個函式間執行的順序」。

什麼意思呢? 來看個簡單的例子。

這裡定義了兩個 function

var funcA = function(){
  console.log('function A');
};

var funcB = function(){
  console.log('function B');
};

funcA();
funcB();

因為 funcAfuncB 都會立即執行,所以執行結果必定為:

"function A"
"function B"

但是,假設我們改成這樣,加上一個隨機生成的等待時間:

var funcA = function(){
  var i = Math.random() + 1;

  window.setTimeout(function(){
    console.log('function A');
  }, i * 1000);
};


var funcB = function(){
  var i = Math.random() + 1;

  window.setTimeout(function(){
    console.log('function B');
  }, i * 1000);
};

funcA();
funcB();

這時候就沒辦法確定是 "function A" 會先出現還是 "function B" 會先出現了對吧?

像這種時候,為了確保執行的順序,就會透過 Callback function 的形式來處理:

// 為了確保先執行 funcA 再執行 funcB
// 我們在 funcA 加上 callback 參數
var funcA = function(callback){
  var i = Math.random() + 1;

  window.setTimeout(function(){
    console.log('function A');

    // 如果 callback 是個函式就呼叫它
    if( typeof callback === 'function' ){
      callback();
    }

  }, i * 1000);
};

var funcB = function(){
  var i = Math.random() + 1;

  window.setTimeout(function(){
    console.log('function B');
  }, i * 1000);
};

// 將 funcB 作為參數帶入 funcA()
funcA( funcB );

像這樣,無論 funcA 在執行的時候要等多久, funcB 都會等到 console.log('function A'); 之後才執行。

不過需要注意的是,當函式之間的相依過深,callback 多層之後產生的「波動拳」維護起來就會很可怕!

getData(function (a) {
  getMoreData(a, function (b) {
    getMoreData(b, function (c) {
      getMoreData(c, function (d) {
        getMoreData(d, function (e) {
          ...
        });
      });
    });
  });
});

https://ithelp.ithome.com.tw/upload/images/20171221/20065504RIRldpL7Fs.jpg

如果真的不幸需要寫到這麼多層,這點後續我們介紹到 Promise 時會再說明如何擺脫「波動拳」(a.k.a. "Callback Hell")。


立即被呼叫的函式 (Immediately Invoked Function Expression, IIFE)

在系列文的 重新認識 JavaScript: Day 10 函式 Functions 的基本概念 我們曾經介紹過,在 ES6 以前,JavaScript 變數有效範圍的最小單位是以 function 做分界的。

那麼,現在我就透過一個最經典的範例來為各位解說。

當迴圈遇到 function

題目是這樣的:假設想透過迴圈 + setTimeout 來做到,在五秒鐘之內,每秒鐘依序透過 console.log 印出: 0 1 2 3 4

那麼你會怎麼做? 很多 JavaScript 的初學者可能會很直覺地寫下這樣的程式碼:

// 假設想透過迴圈 + setTimeout 來做到
// 每秒鐘將 i 的值 console 出來

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

真的是這樣嗎? 我們來看看執行的結果。

執行的結果是, console.log() 會在「一秒鐘之後」同時印出「五次 5」。

5
5
5
5
5

https://i.giphy.com/jquDWJfPUMCiI.gif

為什麼會這樣呢,先前曾經說過:

「切分變數有效範圍的最小單位是 "function" 」
「切分變數有效範圍的最小單位是 "function" 」
「切分變數有效範圍的最小單位是 "function" 」

很重要,所以要再講三次。

假設我們的時間軸是這樣的:

https://ithelp.ithome.com.tw/upload/images/20171221/20065504hFhXV6E4Mi.png

但我們知道, JavaScript 是一個「非同步」的語言,所以當我們執行這段程式時, for 迴圈並不會等待 window.setTimeout 結束後才繼續,而是在執行階段就一口氣跑完

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

https://ithelp.ithome.com.tw/upload/images/20171221/200655042b6lzRwhqP.png

https://ithelp.ithome.com.tw/upload/images/20171221/20065504QPKpcjZal3.png

很明顯地,這段程式有兩個問題需要被解決:

1. 提出問題的人 (不是)

  1. console.log 印出來的數字
  2. console.log 執行的時間

首先先看「印出來的數字」。

由於切分變數有效範圍的最小單位是 function,這代表著當我每一次執行 setTimeout 的時候,

window.setTimeout(function() {
  console.log(i);
}, 1000);

裡面 console.log(i);i 變數是去函式「外層」拿的對吧?

(不知道為什麼的朋友趕快回去看 重新認識 JavaScript: Day 10 函式 Functions 的基本概念 )

也就是說,因為 for 迴圈並不會等待 window.setTimeout 結束後才繼續,所以當 window.setTimeout 內的 Callback Function 執行時,拿到的 i 已經是跑完 for() 迴圈的 5

https://ithelp.ithome.com.tw/upload/images/20171221/20065504d4spmnUDm2.png

那麼要怎麼解決這個問題呢?

利用 「切分變數有效範圍的最小單位是 "function" 」這個特性,我們可以把 window.setTimeout 透過一個「立即被呼叫的特殊函式」來包裝。

像這樣的寫法:

(function(){
  // 做某事...

})();

很多從其他語言轉來寫 JavaScript 的朋友可能不習慣。
沒關係,我們來拆解一下結構,你就會發現這樣的寫法其實沒有那麼難懂。


首先從一般單純的函式寫起:

function doSomething ( i ){
  // 做某事...

}

有個叫 doSomething 的函式,裡面有個參數 i ,當我們要呼叫的時候,會用小括號 () 來呼叫它: doSomething( xxx ) ,大家應該都很熟悉了。

那麼,假設我們希望在定義函式的當下就呼叫它:

  1. 先加個小括號 () 把這個函式包起來:
(function doSomething ( i ){
  // 做某事...

})
  1. 因為要呼叫它,所以在後面再加個小括號 ()
(function doSomething ( i ){
  // 做某事...

})(123)

看出來了嗎?

一般的呼叫方式:

doSomething(123);

函式宣告當下即呼叫:

(function doSomething ( i ){
  // 做某事...

})(123);

假設這個函式我們只需要在宣告的當下執行,之後不會再呼叫,那麼甚至連名字都不用了:

(function ( i ){
  // 做某事...

})(123);

喔喔喔! 這不就是我們一開始看到的那個「立即被呼叫的特殊函式」(Immediately Invoked Function Expression, IIFE) 嗎!

(function(){
  // 做某事...

})();

回到 for 迴圈與 window.setTimeout 的問題。

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

我們知道像上面這樣的寫法,在執行 window.setTimeout 的時候, i 早已變成了 5,那麼為了保留每一次執行迴圈時那個「當下的」 i,我們可以用一個立即被呼叫的特殊函式將它包覆起來,然後將 i 作為參數傳入:

for( var i = 0; i < 5; i++ ) {

  // 為了凸顯差異,我們將傳入後的參數改名為 x
  // 當然由於 scope 的不同,要繼續在內部沿用 i 也是可以的。
  (function(x){
    window.setTimeout(function() {
      console.log(x);
    }, 1000);
  })(i);

}

這時候你會發現,執行的結果就會是我們預期的 0 1 2 3 4 了。

但還是在一秒鐘後同時出現啊?

嘿嘿,相信聰明的你已經發現,由於 for 迴圈在一瞬間就跑完,等於那一瞬間它向 window 依序註冊了五次 timer,每個 timer 都只等待一秒鐘,當然同時出現囉。
所以我們稍微修改一下:

for( var i = 0; i < 5; i++ ) {

  (function(x){
    // 將原本的 1000 改成 1000 * x
    window.setTimeout(function() {
      console.log(x);
    }, 1000 * x);
  })(i);

}

像這樣,就可以依序印出我們要的結果囉!


上面我們用了一個經典的案例來跟各位介紹 IIFE 的用法,除了在迴圈內呼叫 function 我們會需要用 IIFE 來把參數的值保留起來之外 [註1],IIFE 還有另外幾個好處,就是可以減少「全域變數」的產生,同時也避免了變數名稱衝突的機會

如果你有去看過 jQuery 的原始碼,就會發現 jQuery 也用了相同的手法將 windowundefined [註2] 保留起來:

(function( window, undefined ) {

  // 略...

})( window );

  • [註1] ES6 以後新增了 letconst,且改以 { } 作為它的 Scope。
    換句話說,將範例中的 for 改為 let 就可以做到保留 i 在執行迴圈當下的「值」的效果:

    for( let i = 0; i < 5; i++ ) {
      window.setTimeout(function() {
        console.log(i);
      }, 1000);
    }
    
  • [註2] undefined 是可以被修改的 (詳見 重新認識 JavaScript: Day 03 變數與資料型別 ) ,所以 jQuery 雖然在 IIFE 定義了兩個參數,但只傳了一個 winodw,就是為了保持 undefined 原本的樣子。


上一篇
重新認識 JavaScript: Day 17 函式裡的「參數」
下一篇
重新認識 JavaScript: Day 19 閉包 Closure
系列文
重新認識 JavaScript37

2 則留言

0
RocMark
iT邦新手 5 級 ‧ 2018-03-09 13:50:59

感謝大佬,終於比較全面性的了解CallBack了

我對於IIFE的理解是可以防止變量及外部訪問

想問一下 let 解決了 var 汙染全域問題,是否也同時有 IIFE寫法帶來的優點

IIFE 還有需要被使用嗎?(在可以用ES6的環境下)
有需要能用在哪裡呢?

Kuro Hsu iT邦新手 4 級 ‧ 2018-03-09 15:22:29 檢舉

過去使用 IIFE 最主要的原因就是為了避免變數污染造成的問題。

若是 ES6+ 環境以 let 取代 var,再加上 { ... } 將變數鎖定在 block-scope 之後,就可以完全取代 IIFE。

RocMark iT邦新手 5 級 ‧ 2018-03-09 19:28:35 檢舉

了解了 感謝!

0
jack1234552000
iT邦新手 5 級 ‧ 2019-06-02 22:31:18

請問為什麼 印出aaa
是迴圈跑完的i
https://ithelp.ithome.com.tw/upload/images/20190602/20108315DyPY55gaHb.jpg

Kuro Hsu iT邦新手 4 級 ‧ 2019-06-02 22:41:25 檢舉

我要留言

立即登入留言