JavaScript 的 Callback, promise, async 及 await

在做 Web App 時不可避免的就是要執行「非同步」的動作,英文稱之為 Asyncrhonous action。

所謂的「非同步」指的是一個動作被分成兩個時期,初始化和執行。例如設定鬧鐘 10 秒後響鈴,初始化就是設定鬧鐘,執行就是當時間到之後響鈴。

我們來看看 JavaScript 中的非同步是怎麼被實做的。

Callback Function

JavaScript 中有所謂的一級函式(First-class Function),也就是可以把 Function 當變數傳來傳去,當作 Return Value 等等。

Callback Function 就是把一個 Function 當作變數傳入另一個 Function 中,在需要的時刻呼叫。

例如,設定鬧鐘 1 秒鐘後響鈴,我們可以先寫一個這樣的 Function 設定鬧鐘 1 秒後做某些事

1
2
3
4
5
function setAlarm() {
setTimeout(function () {
// Do something
}, 1000);
}

要做的「某些事」便可以寫成一個 Function 傳進去,例如印出 Ring!,呼叫後這個 Callback Function 的內容便會在 1 秒鐘後被執行

1
2
3
4
5
6
7
8
function callback() {
console.log('Ring!');
}
function setAlarm(cb) {
setTimeout(cb, 1000);
}

setAlarm(callback); // Execute

成功與錯誤的 Callback Function

現實的場景常常要在 Callback Function 中 Handle Errors,因此要分成成功及失敗兩種情況。我們可以寫個 Function 模擬成功或失敗的情況,然後傳兩個 Callback Function 進去,一個處理成功,一個處理失敗

1
2
3
4
5
6
7
8
function findEvenNumber(cbSuccess, cbFailed) {
const randomNumber = Math.floor(Math.random() * 2); // Random number 0 or 1
if (randomNumber === 0) {
cbSuccess();
} else {
cbFailed();
}
}

假設隨機產生 0 或 1 兩個數字,產生 0 就成功、1 就失敗,我們在 Callback Functions 裡印出成功或失敗

1
2
3
4
findEvenNumber(
() => console.log('Successful'),
() => console.log('Failed')
);

執行後就能看到成功或失敗了。

Pyramid of Doom

我們接著可以做一件事,看看能不能連續找到 3 次偶數,可以的話就歡呼一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
findEvenNumber(
() => {
console.log('First time');
findEvenNumber(
() => {
console.log('Second time');
findEvenNumber(
() => {
console.log('Hooray, 3 even number in a row');
},
() => console.log('Failed at third time')
);
},
() => console.log('Failed at second time')
);
},
() => console.log('Failed at first time')
);

由於要第一次成功,我們才能在成功的 Callback 中再呼叫一個 Callback,第二次成功再呼叫裡面一層的 Callback,以此類推。

顯然會讓整段程式碼非常的「巢」,可讀性不佳。這樣巢狀的寫法寫到後面,中間變的很深的樣子也被稱為 Pyramid of Doom 金字塔毀滅。

我們可以做的是把這些 Callbacks 拆開寫

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
findEvenNumber(findEvenNumberSuccess1, findEvenNumberFailed1);

function findEvenNumberSuccess1() {
console.log('First time');
findEvenNumber(findEvenNumberSuccess2, findEvenNumberFailed2);
}
function findEvenNumberSuccess2() {
console.log('Second time');
findEvenNumber(findEvenNumberSuccess3, findEvenNumberFailed3);
}
function findEvenNumberSuccess3() {
console.log('Hooray, 3 even number in a row');
}
function findEvenNumberFailed1() {
console.log('Failed at first time');
}
function findEvenNumberFailed2() {
console.log('Failed at second time');
}
function findEvenNumberFailed3() {
console.log('Failed at third time');
}

看起來可讀性就比較高一些了,但這樣子的做法就會將 Function 的連慣性拆開成一塊一塊的,那有沒有既可以保有連續性,又比較好的做法呢?

接下來要談的 Promise 就可以解決這個問題。

Promise

Promise Object 是一個 JavaScript 內建的 Object,用來處理非同步動作的成功與失敗。

使用方式很簡單,新增一個物件,傳入一個 Callback Function,裡面兩個參數分別是成功和失敗的 Callback Function,就和我們上面寫的類似,只不過 JavaScript 已經有內建的 Class 幫你做而已

1
2
3
4
const promise = new Promise(function (resolve, reject) {
// When succeeded, invoke resolve
// When failed, invoke reject
});

我們用 Promise 改寫找到偶數的 Function

1
2
3
4
5
6
7
8
new Promise(function (resolve, reject) {
const randomNumber = Math.floor(Math.random() * 2); // Random number 0 or 1
if (randomNumber === 0) {
resolve();
} else {
reject();
}
});

再來要如何使用 Promise 呢?如果成功的話,意味我們在 Promise 裡面呼叫了 resolve(),此時我們可以呼叫這個 Promise 的 then() Function

1
2
3
4
5
6
7
8
9
10
new Promise(function (resolve, reject) {
const randomNumber = Math.floor(Math.random() * 2); // Random number 0 or 1
if (randomNumber === 0) {
resolve();
} else {
reject();
}
}).then(() => {
console.log('Successful');
});

跑完這段程式碼,有 1/2 的機率成功,會印出 Successful。那失敗呢?在 Promise 的 Function 裡有個 catch 可以做到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
new Promise(function (resolve, reject) {
const randomNumber = Math.floor(Math.random() * 2); // Random number 0 or 1
if (randomNumber === 0) {
resolve();
} else {
reject();
}
})
.then(() => {
console.log('Successful');
})
.catch(() => {
console.log('Failed');
});

Promise 怎麼做的?

那 Promise 究竟是怎麼做,讓這個 Promise 物件呼叫 then()catch() 對應到 resolve()reject() 的?

用狀態:一個 Promise 有 3 個狀態

  • pending
  • fulfilled
  • rejected

剛開始被初始化時,Promise 的狀態是 pending,等到我們在 Function 中呼叫 resolve()reject(),Promise 就會被改變狀態成 fulfilledrejected

Promise Flow (來源:MDN)

還記得我們後面呼叫的 .then().catch() 嗎?裡面傳入的 Callback Function 會因為 Promise 的 State 轉變成 fulfilledrejected 而被呼叫。

Promises Chaining

到目前為止,和單純的使用 Callback Function 來實做的差異不會太大,而 Promises Chaining 這個 Feature 就讓 Promise 的優點體現出來了。

我們再回頭看看,連續 3 次為偶數的程式碼,怎麼用 Promise 實現

1
2
3
4
5
6
7
8
9
10
11
function findEvenNumber(count) {
console.log(`Try count: ${count}`);
return new Promise(function (resolve, reject) {
const randomNumber = Math.floor(Math.random() * 2); // Random number 0 or 1
if (randomNumber === 0) {
resolve(count); // Pass count as variable
} else {
reject(new Error(`Failed at count: ${count}`)); // Throw error
}
});
}

我們一樣新增一個 Function 叫做 findEvenNumber,但傳入一個試第幾次的變數 count,並且回傳一個 Promise,裡面的 resolve()reject() 也分別傳入 count 及一個 Error Object。

試著呼叫這個 Function 並傳入 resolve()reject() 的 Callback Function

1
2
3
4
5
6
7
8
9
10
11
12
13
findEvenNumber(1)
.then((count) => {
return findEvenNumber(count + 1);
})
.then((count) => {
return findEvenNumber(count + 1);
})
.then((count) => {
console.log('Hooray, 3 even number in a row');
})
.catch((err) => {
console.log(err);
});

可能出現的結果就會是

1
2
Try count: 1
Error: Faied at count: 1

1
2
3
4
Try count: 1
Try count: 2
Try count: 3
Hooray, 3 even number in a row

這樣是回傳一個 Promoise 出來,再用 .then() 去鍊起來的做法,就稱為 Promises Chaining。裡面的事當然可以隨便做,常見的像是送出一個 API Request,回來後再送另外一個,就可以這樣使用。

Async/await

Async/await 是一種 Syntax,能更為直覺使用 Promise。

例如

1
2
3
4
async function plusCount(count) {
return count + 1;
}
console.log(plusCount(1));

如果沒有加這個 async 的 keyword 在宣告 Function 的前面,我們印出的值就會是 2。但加上 async 後,印出來的東西就會一個 Promise 的物件!

也就說這種 Syntax 會幫你把裡面的回傳值變成一個 Promise 的物件。

還記得前面做的找到偶數這個 Function 吧?如果需要用到 Promises Chaining 來做多次的非同步操作,用 async 搭配 await 就會變的很簡潔。

只要在 async 宣告的 Function 裡面,我們就可以使用 await 這個 keyword

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function findEvenNumber(count) {
console.log(`Try count: ${count}`);
return new Promise(function (resolve, reject) {
const randomNumber = Math.floor(Math.random() * 2); // Random number 0 or 1
if (randomNumber === 0) {
resolve(count); // Pass count as variable
} else {
reject(new Error(`Failed at count: ${count}`)); // Throw error
}
});
}

async function tryThreeEvenNumber(count) {
try {
const count1 = await findEvenNumber(count);
const count2 = await findEvenNumber(count1 + 1);
await findEvenNumber(count2 + 1);
} catch (err) {
console.log(err);
}
}

tryThreeEvenNumber(1);

得出的結果會和用 Promises Chaining 的版本一模一樣,但是做的事情卻像是處理 Synchronous 的 Function 一樣,呼叫 Function 後直接拿到回傳值、也可以用 try catch 來捕捉錯誤。

參考資料

  1. JavaScript.Info - Introduction: callbacks
  2. JavaScript.Info - Promise
  3. JavaScript.Info - Promises chaining
  4. MDN - First-class Function
  5. MDN - Promise

圖片來源

  1. Promise Flow