JavaScript - Promise
故事是這樣的 ...
在古老的 ES5 時代,農夫每天辛勤工作,但是辛苦種下的程式碼卻每天遭受蟲子的侵襲,一名身處在那個時代的農夫曾說 ... 「我宛如身在十八層 Callback Hell」。
農夫們痛苦不堪,不過為了生活,只能咬著牙使用一個叫 console.log 這個笨重的工具每天到田裡除蟲,但是蟲子就好像無性生殖的生物一樣,消滅一個,來了兩個,直到某天 ...
「痛苦終將過去」,突如其來的聲音四面環繞,就像杜比 9.1 聲道一樣。
一位名為普羅米修斯的人遠道而來,身著奇異服裝,手裡拿著農夫們不曾見過的工具。普羅米修斯聲稱他來至於名為 ES6 的天國,擁有先見之明的他,被指派降臨凡間拯救世人免於遭受蟲害侵擾。
普羅米修斯拿著工具手一揮,「嘩 !」,一大群蟲子落地沒有了生命跡象,農夫們紛紛拍手叫好,感動得痛哭流涕。此時普羅米修斯將手上的工具交給一位農夫,跟他說 「工欲善其事,必先利其器。這個工具叫做 Promise,你要好好使用他,並且傳承下去」。農夫允諾後,普羅米修斯瞬間化為塵煙,消失的無影無縱,記錄這一切的農夫稱這之後的時代為 ES6。
時光飛逝,普羅米修斯的出現也讓時代快速進步,當初被授予 Promise 的農夫也兌現傳承的諾言,直到現今 ES7 時代,每位農夫人手一把 Promise 改良後的工具,名為 Async/Await,他們相信這是普羅米修斯的化身,誓言善用它並結合自身的力量,絕對不讓十八層 Callback Hell 重回人間 ...
( 以上是幹話 )
JavaScript ES5、ES6、ES7 非同步演進示意圖 :
Callback Hell 示意圖
- 示意圖
- JavaScript
const Satan = () => {
setTimeout(() => {
console.log('去去 bug 走')
setTimeout(() => {
console.log('去去 bug 走...')
setTimeout(() => {
console.log('去去 bug 走 !!!')
setTimeout(() => {
console.log('去去 bug 走 (/‵Д′)/~ ╧╧')
setTimeout(() => {
console.log('去去 bug 走 (╬゚д゚)▄︻┻┳═一')
setTimeout(() => {
console.log('去去 bug 走 ▆▅▃ 崩╰(〒皿〒)╯潰 ▃▅▇')
setTimeout(() => {
console.log('去世')
}, 3500)
}, 3000)
}, 2500)
}, 2000)
}, 1500)
}, 1000)
}, 500)
}
Codewars 的一位勇者為了磨練自己,親身體驗地獄
所以在 ES5 時代為什麼會有 Callback Hell ?
從上面那張 setTimeout
的地獄圖我們可以知道,當我們需要執行非同步函式,並且要在非同步任務完成後接著做某件事,需要調用當初在函式中傳入的參數,我們稱這種函式為 Callback Function。
const print = () => console.log(`I'm Callback`)
setTimeout(print, 0) // Callback Function
可能有些人覺得 setTimeout 的例子還好,自己也很少用,那我們以一個 ES5 時代常見的 AJAX 方式來舉例 :
封裝 XMLHttpRequest
function ajax(method, url, onSuccess, onError) {
const xhr = new XMLHttpRequest()
xhr.open(method, url)
xhr.onload = function () {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
onSuccess(JSON.parse(xhr.response))
} else {
onError('error')
}
}
xhr.send()
}
此時當我們需要依照順序進行 ajax 請求,就會敲敲打開地獄的大門 ...
ajax(
'GET',
'http://localhost:3000/1',
res => {
console.log(res)
ajax(
'GET',
'http://localhost:3000/2',
res => {
console.log(res)
ajax(
'GET',
'http://localhost:3000/3',
res => {
console.log(res)
ajax(
'GET',
'http://localhost:3000/4',
res => {
console.log(res)
},
error => {
console.log(error)
}
)
},
error => {
console.log(error)
}
)
},
error => {
console.log(error)
}
)
},
error => {
console.log(error)
}
)
你可能會想說,把 onSuccess
和 onError
拆出來就好啦
const successHandler = res => console.log(res)
const errorHandler = message => console.log(message)
function ajax1() {
ajax('GET', `${apiURL}/owner`, ajax2, errorHandler)
}
function ajax2() {
ajax('GET', `${apiURL}/owner`, ajax3, errorHandler)
}
function ajax3() {
ajax('GET', `${apiURL}/owner`, ajax4, errorHandler)
}
function ajax4() {
ajax('GET', `${apiURL}/owner`, successHandler, errorHandler)
}
這種情況就是 Callback 還有 Callback 還有 Callback 還有 Callback ...
想像一下,如果 ajax1
、ajax2
、ajax3
都散落在你專案的各個角落,甚至中間穿插著同步、第三方套件、非同步程式碼的執行,其中一個環節爆掉,debug 的過程會讓你多想砍人。
不是說這樣設計不行,但缺點實在很明顯 :
1. 缺乏順序性,在複雜的環境中無法清晰表達邏輯
Callback Hell 除了造成程式碼測試的難度增加,也牴觸我們人類大腦的思維,思考程式碼邏輯的過程中就會讓你頭痛欲裂,進而影響到螢幕、鍵盤和滑鼠的性命安全。
2. 控制反轉,缺乏信任的程式
我們在開發過程中,時常會使用到第三方套件 API,或者你同事的 code,如果你不夠熟悉這個第三方套件,或者你不夠熟悉你同事,導致無法準確控制這些第三方套件的 API,還有你同事的 code,根本無法預測這種多重的 Callback 何時會爆炸。
當然可以用比較特殊的設計方式來解決 Callback 帶來的問題,歐萊禮出版的 「你所不知道的 JS - 非同步處理與效能 - Kyle Simpson」 在第二章節講 Callbacks 有提到解決方案,但邏輯可能過於複雜,導致程式太笨重,增加維護的難度。
—「因此 Promise 誕生了」
Promise 使用說明書
Promise 從執行到結束,一共有三種狀態 :
- Pending : 正在執行中,還沒有結果
- Fulfilled : 執行有了結果,你選擇將這個結果當作 成功 狀態
- Rejected : 執行有了結果,你選擇將這個結果當作 失敗 狀態
從概念圖我們可以得知,當 Promise 裡面調用了 resolve
或者 reject
,這是一個重大的決定,從這一刻起整個過程就不可以反悔走回頭路,也就是說你的程式已經確定了狀態 ( Settled )。
生活化例子 :
基本撰寫方式
new Promise((resolve, reject) => {
// 裡面依然是同步執行
if (true) resolve('Microtask') // resolve 將發起微任務
else reject('Microtask') // reject 將發起微任務
})
比較要注意的是,Promise 的回調函式中程式碼的執行依然是 同步函式,遇到 resolve()
或者 reject()
,Promise 會改變狀態為 Fulfilled
或是 Rejected
,此時才能調用 .then()
,.then()
內的函式將放到微任務佇列等待執行。
思考以下 Promise 執行 console 的結果順序為何 :
const promise = new Promise((resolve, reject) => {
console.log(1)
resolve(2)
reject(3)
console.log(4)
}).then(res => console.log(res))
// console.log 結果順序 ?
執行結果
1
4
2
好了 Promise 的基本寫法我會了,then ?
就是 then
.then(onFulfilled, onRejected)
當我們在 Promise 調用了 resolve()
或 reject()
,意味著此時 Promise 已經改變成功或失敗的狀態,接著就可以使用 .then()
,然後 JavaScript 引擎會發起一個微任務,這個任務由 then 的參數 ( Callback ) 所執行,並且這個 Callback 的參數帶有 resolve(result)
的 result 結果。
let promise = new Promise((resolve, reject) => {
resolve(1)
}).then(res => console.log(res))
// 1
then
的第二個參數也是給定 Callback,但是較少使用,它的作用等同於 catch
。
let promise = new Promise((resolve, reject) => {
conosle.log(abc)
}).then(
res => console.log(res), // fulfilled
err => console.log(err) // rejected
)
// 因為沒有 abc 變數,所以會印出 abc is not defined 的錯誤訊息
then
的無限鏈式調用之術
基於 then
可以利用 return 返回新的對象,進入到下一個 then
,所以可以無限調用。
let promise = new Promise((resolve, reject) => {
resolve(1)
})
.then(res => res + 1)
.then(res => res + 2)
.then(res => console.log(res))
// ?
執行結果
4
光是 then
的這個特性,就解決了早期 callback function 設計中缺乏順序性的問題,如果一筆資料回來需要經過多筆程序處理,也可以很好的將各個程序拆分在 then
中,讓程式碼整體邏輯更清晰。
出錯了 ? 沒關係我來 catch
當我們在 Promise 調用了 reject()
,JavaScript 引擎同樣會發起一個微任務,這個任務由 catch
的 callback 函式所執行,並且這個 callback 的參數帶有 reject(error)
的 error 結果,或者其他錯誤訊息。
剛剛有提到 then
的第二個參數等同於 catch
,不過實務上比較常使用 catch
處理錯誤。
let promise = new Promise((resolve, reject) => {
conosle.log(abc)
})
.then(res => console.log(res)) // fulfilled
.catch(err => console.log(err)) // rejected
// 因為沒有 abc 變數,所以會印出 abc is not defined 的錯誤訊息
封裝 Promise
實務上經常將 Promise 封裝在函式中,當我們需要時才會調用它。
function promise() {
return new Promise((resolve, reject) => {
// Do something...
})
}
Promise 提供的 API
Promise.all([iterable])
透過迭代物件的方式傳入多個 Promise 函式,全部的 Promise 函式執行完畢後才會回傳陣列 ( Array ) 結果,若其中一個 Promise 函式被 reject 或出錯,則 Promise.all 可使用 catch
處理失敗。
動態加入 iterator 迭代物件讓 Promise.all
執行 :
const fetch = []
Object.values(myCats).forEach(cat => {
fetch.push(promise(cat))
})
const iterator = fetch[Symbol.iterator]()
Promise.all(iterator)
.then(results => {
rets.forEach(result => {
console.log(result)
})
})
.catch(err => {
console.error(err)
})
Promise.race([iterable])
與 Promise.all
使用方法類似,透過迭代物件傳入多個 Promise 函式,當其中有 Promise 函式被 resolve
或 reject
時,將會被回傳。
Promise 在 Event Loop 扮演的角色
上回介紹 JavaScript 在瀏覽器的 Event Loop 我們知道 Promise.then( ... ) 會進入微任務佇列,需要注意它會在什麼時候被執行。
當同步回調在 Call Stack 執行完畢清空時,沒有宏任務或者宏任務被放到 Call Stack 執行完畢清空時,就會輪到 微任務 被放進 Call Stack 執行。
Promise 小試身手
// 阻塞函式
function blockingTest() {
const delay = 2000
const end = Date.now() + delay
console.log('blocking start')
while (Date.now() < end) {}
console.log('blocking end')
}
function promise() {
console.log(1) // 1
new Promise((resolve, reject) => {
console.log(2) // 2
blockingTest() // 同步執行阻塞測試
console.log(3) // 3
fetch(`https://jsonplaceholder.typicode.com/todos/1`)
.then(res => res.json())
.then(res => {
resolve(res) // 微任務
console.log(4) // 4
})
console.log(5) // 5
}).then(res => {
console.log(6, res) // 6
})
console.log(7) // 7
}
promise()
console.log
印出的順序為何 ?
執行結果
1
2
blocking start
blocking end
3
5
7
4
6, [res]