callback hell 與 Promise ,一起來把 setTimeout 封裝成 Promise 吧!
為什麼回調地獄(callback hell)會發生?以計時器和網路請求慢慢說起,一步一步從歷史的脈絡,滿滿揭開解開 Promise 的面紗。
Promise 的出現,是為了解救被 callback hell 殘害的芸芸眾生,回調地獄是 JavaScript 從業者最害怕碰到的,尤其在接手前人程式碼時,打開看到滿滿的 callback 嵌套,真的很想遞出辭職信。那麼 Promise 的出現是怎麼解救遠古的 JS 教徒、芸芸眾生呢。
本篇文章是由 《事件 (Event) 的註冊、觸發與傳遞》 所抽離出來,預設讀者已經有基本的 JavaScript 事件機制的基礎。
目錄
- 事件順序 — 回呼地獄 (callback hell)
- 計時器事件
- 網路請求
- 承諾(Promise)
- 實作 Promise 化的
setTimeout
— sleep 函數
回呼函數的次序與回呼地獄
我們拿火影忍者舉例:如果你是卡卡西的話「當敵人太多的話,我就要開寫輪眼」、「寫輪眼打開後,快掛之前要使用千鳥」、「千鳥用完之後必須注意影分身」
卡卡西.addEventListener('敵人太多', function(){
卡卡西.addEventListener('快掛了',function(){
寫輪眼();
卡卡西.addEventListener('寫輪眼開完了',function(){
跑路();
})
})
})
這就是常見的回呼地獄 (callback hell) ,這也是很多人在辦公室需要處理的事情太多的時候,常常會做到不知道自己在做什麼的原因。
為了不過度嵌套太多註冊事件、回呼函數,後來的開發者想到了各種的 pattern ,最後 Promise 被納入了 ES6 的標準裡頭。等等也會提到,先稍微期待一下。
計時器事件 setTimeout、setInterval
計時器是 JavaScript 一個很常見的方法,而它的語法與前面的註冊事件相當相似,我們先介紹它的用法。
var timeoutID = scope.setTimeout(code[, delay]);
第一個參數正是上面扯半天的回呼函數 (callback),第二個參數則是微秒數。
舉一個例子:
當你室友是個喜歡同時操作很多 session 的阿宅:「欸你十分鐘之後幫我開一下電腦,我要連回去,拜託!」
setTimeout(()=> 開肥宅的電腦(), 10*60*1000);
內置的計時器在跳動了十分鐘之後,就會去觸發這個事件,其實跟剛剛的 click 事件真的沒什麼兩樣。
註:clearSetTimeout(timerId)
可以把計時器事件停止。
但通常你室友不會這麼容易饒了你,真實的環境下你可能會遇到像是這樣子的請求:「嘿,十分鐘之後幫我開個電腦,打開後過兩分鐘幫我連上 Steam ,並且幫我下載幾個遊戲。」
setTimeout(()=>{
開電腦();
you.addEventListener('opened',()=>{
setTimeout(()=>{
連上steam(); },2*60*1000)
})
},10*60*1000)
網路請求的事件
因為 JavaScript 是單執行緒的緣故,我們不希望一個操作「太久」,如同一個奧客如果點餐花了十分鐘,服務生這段時間內沒有辦法服務其他客人。
所以許多長時間操作,如IO、Network請求,我們都會把它非同步射出去,接下來我們就只等待事件提醒我們,東西回來了。
網路請求其實主要分為兩種,在古代的時候只有 XMLHttpRequest (有名的ajax 就是針對此做封裝),以及後來的 fetch (後來的標準,但也參考了 ajax 的做法)。
而 XHR 主要透過為請求(XHR實體)的事件目標註冊 load
事件來監聽請求回應 (response)。最簡單的做法如下,所以跟上頭提到的 addEventListener
是一樣意思。
function reqListener () {
console.log(this.responseText);
}var oReq = new XMLHttpRequest();
oReq.addEventListener("load", reqListener);
oReq.open("GET", "http://www.example.org/example.txt");
oReq.send();
後來的 fetch
標準則是這個樣子,可以看到它用到了 then 方法的鏈式調用。
fetch('http://example.com/movies.json')
.then(function(response) {
return response.json();
})
.then(function(myJson) {
console.log(myJson);
});
這個 then 方法是由 Promise 的原型所提供的, fetch 則是 promise 的實體。
因為網路請求有兩種的事件處理機制,所以特別提出來做說明,我們馬上來看看 Promise 吧!
Promise(承諾)
先說文解字,Promise 是承諾的英文,我們承諾有兩種可能信守承諾(resolve)、承諾失敗(reject)。在承諾的結果出現前,會進入一個不知道到底成功還是失敗的狀態(pending)。
Promise → pending → ( resolve or reject )
幸運的是,Promise 的 API 相當友好,可以透過 then 與 catch 的兩個方法,把回呼函數放進去,針對兩個最終狀態的處理。
function success(){
// do something when success
}
function fail(){
// do something when fail
}(Statement will pending)
.then(success)
.catch(fail)
最後不管是 then 還是 catch ,都會把 callback 給 Promise 化,使用常常看一看到各種非同步的程式鏈式調用。
fetch('https://your-domain/api.json')
.then(res=>res.json()) /* 這也是個非同步 */
.then(j=>console.log(j.id)) /* json() resolve 之後 */
.catch(()=>alert('fail'))
關於 Promise 的 API 我必須說很難三言兩語一次講完,不過恭喜你,只要知道 Promise 可以輕鬆在 then 的部分,填入狀態完成的回調,我們就能回答很久很久以前的疑問 — 如何解決 callback hell、如何讓我們的回傳 Promise 非同步操作按照次序執行?
寫在最後 — 我們來把 setTimeout 封裝成一個 Promise 操作吧!
傳統的 setTimeout:
setTimeout( ()=>{} , 1000)
我們希望這個暫停一段時間之後,能做一些其他事,又不想撰寫太過嵌套的回呼函數。
/* 最終希望的函數 sleep */sleep(5).then(()=>console.log('5秒過去'))
我們來一步一步實作吧!
function sleep(sec){
setTimeout(()=>{},sec*1000)
}
初步應該會是這個樣子,可是沒有包裹 Promise ,依舊沒辦法被 then 方法給庇護。
function sleep(sec){
return new Promise((resolve,reject)=>{
setTimeout(()=>resolve(),sec*1000)
})
}
完成!在 setTimeout
去觸發之前,sleep 函數會回傳 pending 狀態!
但我們仍然不滿足,我們希望能讓 reject 也派上用場,所以增加一個特性,超過十秒的話 reject 掉。
/* 想象中的sleep */
sleep(5).then(()=>{})
sleep(11).then(()=>{}).catch(e=>console.log(e)) // Error:睡太久了八?
我們拿上面來改
function sleep(sec){
return new Promise((resolve,reject)=>{
if(sec > 10) reject(new Error('睡太久了八!'));
setTimeout(()=>resolve(),sec*1000)
})
}sleep(11).catch(e=>console.log(e)) // Error: 睡太久了八!
Done!