在上一篇文章中,我們深入了解 Javascript 非同步的核心,到底在背景做了哪些事,才構成了我們實際看到的畫面。
今天讓我們來了解一些常見的非同步操作,callback、Promise,他們的演化脈絡,以及各自的優缺點吧!
中文可以叫做「回呼函式」,基本上就是一個普通的函式,但因為被應用在「非同步結束後才呼叫」,所以多得到一個 callback function 的稱號。
有沒有覺得 callback 這個詞特別眼熟?昨天在討論非同步核心時,有提到一個:
Callback Queue:用來存放從 Web api 過來,準備要進入 Call Stack 的指令
沒錯,就是你想的那樣!
Callback Queue 就是用來讓 callback function 排隊等待的地方。
callback function 最常見的就是 DOM 事件綁定:
elem.addEventListener('click', callback);
或者計時器:
setTimeout(callback, 1000);
這個東西很常會跟我們在 Day 8 提到的高階函式 HoF(Higher-order function) 搞混。
簡單來說,把一個 function A,傳進另外一個 function B 當參數,等到 B 的事情做完,他會去呼叫並執行那個 A。
我們會稱呼 function A 為 callback function(因為被帶入),而 function B 為 HoF(因為有函式參數帶入)。
所以上面兩個例子中,addEventListener
、setTimeout
就是 HoF,而 callback
就是 callback function。
面對不知道要等多久的事情,與其站在那邊等,不如直接給它一個 function,告訴它事情做完就來執行 function,我就可以去做其他事情了。
生活化來說,就像訂網購一樣,我在網站上下訂商品(執行 function B),但不可能下訂之後就立刻準備好,所以我也同時告訴它,包裹準備好之後,幫我送到我家(執行 function A)。
值得注意的是,我完成下訂後,就可以去澆我的花、追我的劇,不用特地等包裹準備好才跟對方說地址,我可以同時去做我其他事情,包裹就會自動送到了。
寫成簡單的程式碼就像是這樣:
const doOrder = () => {
console.log('傳送交易資料到主機預計 2 秒...');
setTimeout(() => {
console.log('傳送完成,開始配送預計 10 秒...');
setTimeout(() => {
console.log('包裹已送達!');
}, 10000);
}, 2000);
};
doOrder();
console.log('訂單送出去了,可以去澆花、追劇囉!');
執行結果
傳送交易資料到主機預計 2 秒...
訂單送出去了,可以去澆花、追劇囉!
// (2秒空檔)
傳送完成,開始配送預計 10 秒...
// (10秒空檔)
包裹已送達!
在 setTimeout
的第一個參數,就是我們的 callback function,我們沒有被動地等 setTimeout
的秒數跑完,而是主動告訴它下一步是什麼,然後就可以利用時間去做其他事。
可以說,callback function 是一個轉被動為主動的姿態啊!
聽起來很棒,對於需要等待的事情,我不用等,直接告訴它下一步要做什麼,這樣我就可以去逍遙了。
但如果今天有一件事情,我告訴它下一步,還要再告訴它下一步的下一步,以及下一步的下一步的下一步...
我下一步你的下一步!
我 callback 的時候 callback!
延續上一個例子,我需要在下訂單之前,先查詢我的錢包是否有餘額,那就會變這樣:
const doOrder = () => {
console.log('查詢錢包預計 3 秒...');
setTimeout(() => {
console.log('查詢完成,傳送交易資料到主機預計 2 秒...');
setTimeout(() => {
console.log('傳送完成,開始配送預計 10 秒...');
setTimeout(() => {
console.log('包裹已送達!');
}, 10000);
}, 2000);
}, 3000);
};
doOrder();
console.log('訂單送出去了,可以去澆花、追劇囉!');
執行結果
查詢錢包預計 3 秒...
訂單送出去了,可以去澆花、追劇囉!
傳送交易資料到主機預計 2 秒...
// (2秒空檔)
傳送完成,開始配送預計 10 秒...
// (10秒空檔)
包裹已送達!
是不是離波動拳愈來愈近了呢?
沒錯,callback 本身是很好的方式,但在處理一連串巢狀 callback 時,視覺上容易出錯(這個右括號是哪一層的右括號?),雖然可以用 linter 解決,但要做錯誤處理(error handling)也會更為複雜,每一層的 code 又會增加,最後變成一大坨難以維護的「程式碼群」。
於是 Promise 誕生了,拯救在地獄浮沉的 callback 們。
Promise 已經是 ES6 的標準了,可以想像是一種特別的物件,這種物件是用狀態機的方式,來表達任務執行的狀態,主要有三種狀態:
pending
fulfilled
rejected
一個 Promise 剛建立的時候是 pending
,接下來有兩種可能:
resolve
並回傳 result,轉變成fulfilled
狀態,reject
並回傳 error,轉變成 rejected
的狀態。生活化來說,Promise 就像是在商店街跟店家點餐時,店家不可能一點餐就立刻出餐,但也不好意思讓客人一直站在旁邊等,所以店家會給你這個:
取餐叫號機,就像是 Promise,你剛拿到時,餐點還沒好,所以是 pending
,當餐點做好時,就會震動閃光(?)轉成 fulfilled
狀態,告訴你該做下一步了。(不過取餐叫號機好像很少有 rejected
狀態?也許是你的餐做失敗了,請回來重新點餐...)
我們可以用 .then()
的方式拿到他的 result,.catch()
抓到他的error:
const doSomethingReturnPromise = () => {
// 用 setTimeout 來模擬一個非同步的 Promise
return new Promise((resolve, reject) => {
setTimeout(() => {
// 一秒後回傳資料
resolve({ data: 123 });
}, 1000);
})
};
doSomethingReturnPromise()
.then(function (result) {
// ...
})
.catch(function (err) {
// ...
});
可以看到其實 Promise 也運用了一些 callback 進來,但最大的不同是,他的 then()
跟 catch()
都可以回傳一個 Promise (可以想像成,每個階段工作人員都會給你一個叫號機),因此可以「串接」下去:
doSomethingReturnPromise()
.then(function (result) {
// ...
// return 另一個 promise
})
.then(function (result) {
// ...
// return 另一個 promise
})
.then(function (result) {
// ...
})
.catch(function (err) {
// ...
});
在 callback hell 遇到的問題,多層的縮排瞬間就被拉成一層,只要每個 .then()
都回傳 Promise,就可以確保 A 完成了做 B,B完成了做 C,你要串幾個都很容易擴充(promise chain)。
而且錯誤處理的部分也簡單了許多,中間不管有幾個 .then()
,只要有任何一個 Promise 轉為 rejected
,就會進入 .catch()
,不用再一個個處理 error,只要處理一次即可。
針對剛剛的例子,試著寫寫看比較貼近真實一點的版本。比如我要先取得錢包餘額,確定如果還有餘額,才可以進行下單動作。
const doQueryWallet = () => {
// 用 setTimeout 模擬從資料庫 IO
return new Promise((resolve, reject) => {
setTimeout(() => {
// 一秒後回傳資料
resolve({ balance: 100 });
}, 1000);
})
};
const doOrder = () => {
// 用 setTimeout 模擬資料庫 IO
return new Promise((resolve, reject) => {
setTimeout(() => {
// 一秒後回傳資料
resolve({ status: 'ok' });
}, 1000);
})
};
doQueryWallet()
.then(function (result) {
if (result.balance > 0) {
return doOrder();
} else {
console.log('餘額不足');
// 往下回傳 pending 的 Promise (類似中斷 Promise)
return new Promise((resolve, reject) => {})
}
})
.then(function (result) {
if (result.status === 'ok') {
console.log('下單成功');
} else {
console.log('下單失敗');
}
})
.catch(function (err) {
console.error(err);
});
ES6 的 fetch
語法是最容易接觸到的 Promise 語法:
fetch('https://api.jokes.one/jod')
.then(function(response) {
return response.json();
})
.then(function(myJson) {
console.log(myJson);
})
.catch(function(error) {
console.log(error.message);
});
(上面這一段真的可以複製貼到 console 執行,每日笑話lol)
非同步系列的演化脈絡,雖然大家可能早就用 async
/await
飛來飛去了,但就像我們學習歷史一樣,鑑古知今。
重點就在於「解決了什麼問題」。
當我們更清楚這些新技術的誕生,不是為了出現而橫空出世,而是為了解決實際 coding 會遇到,可讀性、可維護性的問題,才會對於手上正在用的工具更加清晰。
有感於許多踏入前端領域的人,已經是框架時代的起飛時期,這些人可能不知道 jQuery 有劃時代的意義,只覺得是過時的產物,這樣盲目追求的人,容易不清楚手上工具的優勢與劣勢,簡單的展示頁面也動輒 Angular、React 飛來飛去,其實是很危險的。
希望這幾天的「非同步講古入門」,能夠點出每個工具在那個時代的意義。
一次次的等候
在愈來愈深的洞穴
漸漸迷失了歸途