JS-Callback Function

Callback Function & Promise

前言

在 2018 iT 邦幫忙鐵人賽,看到Kuro大的重新認識 JavaScript 系列文,仔細的閱讀後,紀錄自己觀念不足的部份,也非常推薦給大家觀看此系列文。


Callback Function

概念就如同:

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

而寫成程式碼就類似:

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

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

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


波動拳 (a.k.a. “Callback Hell”)

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

下面舉例從簡單的事情慢慢演變成複雜時,會發生什麼情形

這裡定義了兩個 function

1
2
3
4
5
6
7
8
9
10
var funcA = function(){
console.log('function A');
};

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

funcA();
funcB();

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

1
2
"function A"
"function B"

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 的形式來處理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 為了確保先執行 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 多層之後產生的「波動拳」維護起來就會很可怕!

1
2
3
4
5
6
7
8
9
10
11
getData(function (a) {
getMoreData(a, function (b) {
getMoreData(b, function (c) {
getMoreData(c, function (d) {
getMoreData(d, function (e) {
...
});
});
});
});
});

再見 Callback Hell

執行順序的問題是一個,還有另一個常見的狀況是這樣,再回到 「Overcooked」 的場景。

用 Overcooked (遊戲名稱:煮過頭) 比喻 同步(Synchronous)的概念
假設邊緣人如我,只能自己一人玩 Overcooked,在領完食材原料之後,一樣會有青菜、番茄需要處理。

因為只有一個廚師,所以要嘛先處理青菜、要嘛先處理番茄,必須先弄完一項之後再去處理另一項,整個流程會被前一個步驟卡住。

像這樣「先完成 A 才能做 B、C、D …」的運作方式我們就會把它稱作「同步」(Synchronous) 。

當我要確保「切青菜、切番茄、擺盤」三個動作都完成之後,我才能繼續「上菜」這個動作。

在面臨這種問題的時候,我要怎麼確保三個動作都完成之後,才繼續執行後面的程式呢?

最直覺的方式是新增一個變數來管理狀態:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
var result = []; //紀錄已完成的事件
var step = 3; //全部完成的總數量

// 假設 funcA、funcB、funcC 分別代表「切青菜、切番茄、擺盤」
function funcA(){
window.setTimeout(function(){
result.push('A'); //紀錄 A 完成
console.log('A');

if( result.length === step ){
funcD();
}

}, (Math.random() + 1) * 1000);
}

function funcB(){
window.setTimeout(function(){
result.push('B'); //紀錄 B 完成
console.log('B');

if( result.length === step ){
funcD();
}
}, (Math.random() + 1) * 1000);
}

function funcC(){
window.setTimeout(function(){
result.push('C'); //紀錄 C 完成
console.log('C');

if( result.length === step ){
funcD();
}
}, (Math.random() + 1) * 1000);
}

function funcD(){
console.log('上菜!');
result = [];
}

funcA();
funcB();
funcC();

像上面這樣,當我們依序執行了 funcA()funcB()funcC(),由於內部 setTimeout 會等待亂數時間的關係,我們無法得知誰先誰後。 但可以確定的是,當這三個函式執行的時候就會去檢查 result.length === step ,如果成立,就表示三個任務都已經完成,那麼就可以再去呼叫 funcD 執行後續的事情。

如果不希望使用全域變數來污染執行環境的話,甚至可以包裝成一個通用的函式:

閉包的概念
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function serials(tasks, callback) {
var step = tasks.length;
var result = [];

// 檢查的邏輯寫在這裡
function check(r) {
result.push(r);
if( result.length === step ){
callback();
}
}

tasks.forEach(function(f) {
f(check);
});
}

那麼改寫一下 funcA()funcB()funcC():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function funcA(check){
window.setTimeout(function(){
console.log('A');
check('A');
}, (Math.random() + 1) * 1000);
}

function funcB(check){
window.setTimeout(function(){
console.log('B');
check('B');
}, (Math.random() + 1) * 1000);
}

function funcC(check){
window.setTimeout(function(){
console.log('C');
check('C');
}, (Math.random() + 1) * 1000);
}

function funcD(){
console.log('上菜!');
}

最後呼叫的時候,我們就可以透過這樣呼叫 serials()

1
serials([funcA, funcB, funcC], funcD);

把想要提前執行的函式以陣列的方式傳進 serials() 作為第一個參數,當陣列中的函式都執行完畢後,才會呼叫第二個參數的 funcD()


Promise 物件

為了解決同步/非同步的問題,自從 ES6 開始新增了一個叫做 Promise 的特殊物件。

簡單來說,Promise 按字面上的翻譯就是「承諾、約定」之意,回傳的結果要嘛是「完成」,要嘛是「拒絕」。

實際寫成 Promise 的程式碼大概像這樣:

1
2
3
4
const myFirstPromise = new Promise((resolve, reject) => {
resolve(someValue); // 完成
reject("failure reason"); // 拒絕
});

要提供一個函式 promise 功能,讓它回傳一個 promise 物件即可:

1
2
3
4
5
function myAsyncFunction(url) {
return new Promise((resolve, reject) => {
// resolve() or reject()
});
};

當 Promise 被完成的時候,我們就可以呼叫 resolve(),然後將取得的資料傳遞出去。 或是說想要拒絕 Promise , 那麼就呼叫 reject() 來拒絕。


一般來說, Promise 物件會有這幾種狀態:

  • pending: 初始狀態,不是 fulfilled 或 rejected。
  • fulfilled: 表示操作成功地完成。
  • rejected: 表示操作失敗。

整個 Promise 流程可以用這張圖表示:


如果我們需要依序串連執行多個 promise 功能的話,可以透過 .then() 來做到。

以剛剛的 funcA()funcB()funcC() 來當範例,我們將這三個函式分別透過 Promise 包裝:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function funcA(){
return new Promise(function(resolve, reject){
window.setTimeout(function(){
console.log('A');
resolve('A'); // 完成
}, (Math.random() + 1) * 1000);
});
}

function funcB(){
return new Promise(function(resolve, reject){
window.setTimeout(function(){
console.log('B');
resolve('B'); // 完成
}, (Math.random() + 1) * 1000);
});
}

function funcC(){
return new Promise(function(resolve, reject){
window.setTimeout(function(){
console.log('C');
resolve('C'); // 完成
}, (Math.random() + 1) * 1000);
});
}

最後透過呼叫

1
funcA().then(funcB).then(funcC);

就可以做到等 funcA() 被 「resolve」之後再執行 funcB(),然後 resolve 再執行 funcC() 的順序了。


如果我們不在乎 funcA()funcB()funcC() 誰先誰後,只關心這三個是否已經完成呢?

那就可以透過 Promise.all() 來做到:

Promise.all()
1
2
3
4
5
// funcA, funcB, funcC 的先後順序不重要
// 直到這三個函式都回覆 resolve 或是「其中一個」 reject 才會繼續後續的行為

Promise.all([funcA(), funcB(), funcC()])
.then(function(){ console.log('上菜'); });