callback hell 與 Promise ,一起來把 setTimeout 封裝成 Promise 吧!

為什麼回調地獄(callback hell)會發生?以計時器和網路請求慢慢說起,一步一步從歷史的脈絡,滿滿揭開解開 Promise 的面紗。

realdennis
8 min readJan 10, 2019

Promise 的出現,是為了解救被 callback hell 殘害的芸芸眾生,回調地獄是 JavaScript 從業者最害怕碰到的,尤其在接手前人程式碼時,打開看到滿滿的 callback 嵌套,真的很想遞出辭職信。那麼 Promise 的出現是怎麼解救遠古的 JS 教徒、芸芸眾生呢。

本篇文章是由 《事件 (Event) 的註冊、觸發與傳遞》 所抽離出來,預設讀者已經有基本的 JavaScript 事件機制的基礎。

目錄

  1. 事件順序 — 回呼地獄 (callback hell)
  2. 計時器事件
  3. 網路請求
  4. 承諾(Promise)
  5. 實作 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(承諾)

Source: MDN

先說文解字,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 非同步操作按照次序執行?

[JavaScript] 兩種方法讓Promise按照順序執行 (mergePromise)

寫在最後 — 我們來把 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!

--

--