[JS] 從 Event Loop 到 Callback function,一堆名詞講的是什麼?

圖片來源:MDN - 並行模型和事件循環


事件循環(event loop)

JavaScript 是單執行緒的語言,一次只能做一件事,遇到需要花費大量時間的程式時,後面的程式就會全部卡住。

舉例來說,去超商提款機領錢,前面的人換了無數張卡片,一陣操作十幾分鐘過去,但是超商就只有一台提款機,只能在後面慢慢等他用完。

為了減少這種排隊窘境,瀏覽器執行時會讓順順跑的排一列、費時的移到另一列等待,順跑的一列出現空檔時,再把另一列排隊的項目抓回來執行。

我非常推薦先觀看下方影片來了解事件循環的運作,會比文字和圖片解釋更加容易理解:

影片講者的事件循環模擬器:連結


名詞解釋

看完上面的影片,我們可以快速了解事件循環的流程,以下歸納幾個名詞代表的意思:

  • 同步的 JavaScript(Synchronous):瀏覽器執行多數的程式碼都是同步的,收到就立刻執行。
  • 非同步的 JavaScript(Asynchronous):許多無法立即完成的程式會交由瀏覽器的 Web APIs 來處理,最常見的情況就是取得外部資料。
  • Web APIs:瀏覽器中提供的應用(例如:抓 api 資料、等待幾秒…等)。
  • Stack (Call Stack):同步執行的程式片段,會以堆疊方式執行(先進後出)。
  • Queue(Callback Queue / Task Queue):非同步執行的程式片段會交給 Web APIs 處理,完成後進入佇列中等待執行(先進先出)。

Callback Function

經過 Web APIs 處理完的非同步程式會進入 Queue 等待,當 Stack 沒有執行中的程式時,就會抓取 Queue 中等待執行的程式回來執行,這個回頭做的動作就是 Callback (回呼),所以非同步執行後回頭做的函式就是 Callback Function ? 意思大概到了,但不完全是。

在 MDN 中說明回呼函式(Callback Function)指的是把函式作為參數,提供另一個函式使用,用這種方式設計出來的函式可以是同步和非同步的,但通常都是用來處理非同步的程式。

非同步的完成時間通常是難以預測的,舉例來說,要取得一個外部資源,會因為網路速度、檔案大小讓完成時間都不相同,當有多個非同步程式執行,我們無法知道哪一個會先完成,回呼函式的設計就是要確保函式執行的先後順序


setTimeout

學習 JavaScript 時進入非同步的章節通常第一個學的就是 setTimeout,可以指定一段時間後再執行別的程式碼,在 W3schools 可以看到 setTimeout 傳入參數的格式:

setTimeout(function, milliseconds, param1, param2, ...)

第一個參數是函式、第二個是間隔的時間、第三個以後是可以選擇要額外傳入的參數。

第一個參數正好符合了「把函式作為參數,提供另一個函式使用」,也就是說這是一個 Callback Function,而 setTimeout 的功能正式讓傳入的回呼函式在指定的時間後再執行。

接下來的段落,會透過設計情境來說明如何設計和應用 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
27
28
29
30
// 吃飯
function eat() {
console.log('吃飽了');
}

// 睡覺
function sleep() {
console.log('睡飽了');
}

// 打東東
function hitDongDong() {
console.log('打爽了');
}

// 每日工作:裡面執行每個活動的函式
function dailyWorks() {
console.log('今日完成事項:');
eat();
sleep();
hitDongDong();
}
dailyWorks();

/*---輸出結果---*/
/*------------*/
// 今日完成事項:
// 吃飽了
// 睡飽了
// 打爽了

同步回呼函式

上面的函式設計上沒有彈性,不同的企鵝除了打東東之外也會做其他的事情,我們增加一個參數來接收要做的事情(函式),改寫後如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 除了「吃飯、睡覺、打東東」外可以自由設計各種活動函式
function moreActivity() {
//...
}

function dailyWorks(...activities) {
console.log('今日完成事項:');
activities.forEach((fn) => {
// 執行前先檢查是否為函式
if (typeof fn === 'function') fn();
});
}
dailyWorks(eat, sleep, hitDongDong, moreActivity);

/*---輸出結果---*/
/*------------*/
// 今日完成事項:
// 吃飽了
// 睡飽了
// 打爽了
// ( 自行加入的各種活動....)
  • 上面的程式碼中,在 dailyWorks 這個主要函式中增加 activities 參數(參數名稱可以自定),用來接收要執行的函式。

  • 運用其餘運算符(…)來接收更多的函式,就可以在執行 dailyWorks 函式時自由的替換或傳入更多的函式。

  • 因為傳入的函式都是同步執行,是「同步回呼函式」,哪個先傳入就先執行,沒有非同步的順序問題。


非同步回呼函式

問題又來了,企鵝哪有那麼神,可以一瞬間做那麼多事情?接著就讓每件工作加入一點執行時間。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function eat() {
setTimeout(() => console.log('吃飽了'), 2000);
}

function sleep() {
setTimeout(() => console.log('睡飽了'), 1000);
}

function hitDongDong() {
setTimeout(() => console.log('打爽了'), 1000);
}

function dailyWorks(...activities) {
console.log('今日完成事項:');
activities.forEach((fn) => {
if (typeof fn === 'function') fn();
});
}
dailyWorks(eat, sleep, hitDongDong);

完成修改後,請問上面這段程式碼的執行結果?

  • (A) 印出「今日完成事項:」、2 秒後印出「吃飽了」、第 3 秒印出「睡飽了」、第 4 秒印出「打爽了」
  • (B) 印出「今日完成事項:」、1 秒後同時印出「睡飽了」和「打爽了」、 第 2 秒印出「吃飽了」

如果你的答案是 (A),請試著把這段程式碼的執行流程用 stack、WebAPIs、queue 的方式畫出來。

雖然我們把傳入的函式修改成「非同步回呼函式」,但是傳入的函式都屬於主要函式(dailyWorks)的回呼函式,彼此間沒有 callback 的關係,整個程式的執行順序是:

  1. 主要函式的 console.log('今日完成事項:')
  2. 傳入的三個函式都進入 WebAPIs 執行,「睡飽了」和「打爽了」都在一秒後執行完,回到 queue,再被取回 stack 執行,而「吃飽了」在兩秒後才完成進入 queue,再被取回 stack 執行。

如果要讓這些非同步函式能夠一個完成再執行下一個(one by one),可以再做以下修改:

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
function eat(activity) {
setTimeout(() => {
console.log('吃飽了');
if (typeof activity === 'function') activity();
}, 2000);
}

function sleep(activity) {
setTimeout(() => {
console.log('睡飽了');
if (typeof activity === 'function') activity();
}, 1000);
}

function hitDongDong(activity) {
setTimeout(() => {
console.log('打爽了');
if (typeof activity === 'function') activity();
}, 1000);
}

function dailyWorks(activity) {
console.log('今日完成事項:');
if (typeof activity === 'function') activity();
}

dailyWorks(() => {
eat(() => {
sleep(() => {
hitDongDong(() => {
// 繼續延伸下去............
});
});
});
});

修改後的程式碼,讓每個活動函式都可以傳入 callback function,這樣就會依照順序,一個執行完再接下一個。

為了確保執行的順序,必須以巢狀的方式撰寫,當程式碼變得複雜時,傳說中的 Callback Hell(回呼地獄)就會誕生,雖然這不是電腦執行程式的問題,但是對於人類閱讀會有很大的障礙。

圖片來源:Async Flow: From callback hell to promise to Async-Await


結語

Callback function 歷史悠久,這也表示 Callback hell 荼毒了很長一段時間,直到 ES6(2015)的 Promise 和 ES8(2017)的 async/await 才有較簡潔的撰寫方法,儘管學習新方法可以縮短許多時間,但是 Event loop 的流程和 Callback function 的撰寫方式都是重要的基本功,花點時間弄清楚對於撰寫非同步程式會非常有幫助。

參考資料:
MDN - 回呼函式Kuro - 重新認識 JavaScript: Day 18 Callback Function 與 IIFE