Promise
是我最喜歡的es6語法,也是面試中最容易問到的部分。那麼怎麼做到在使用中得心應手,在面試中脫穎而出呢?
先來個面試題做做:
面試題:用Promise封裝一下原生ajax
面試官經常會讓手寫一個Promise封裝,寫出下面這一版就行了(想了解更多的可自行擴充套件):
function ajaxMise(url, method, data, async, timeout) {
var xhr = new XMLHttpRequest()
return new Promise(function (resolve, reject) {
xhr.open(method, url, async);
xhr.timeout = options.timeout;
xhr.onloadend = function () {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304)
resolve(xhr);
else
reject({
errorType: 'status_error',
xhr: xhr
})
}
xhr.send(data);
//錯誤處理
xhr.onabort = function () {
reject(new Error({
errorType: 'abort_error',
xhr: xhr
}));
}
xhr.ontimeout = function () {
reject({
errorType: 'timeout_error',
xhr: xhr
});
}
xhr.onerror = function () {
reject({
errorType: 'onerror',
xhr: xhr
})
}
})
}
複製程式碼
Promise簡介
Promise
是一個物件,儲存著未來將要結束的事件。她有兩個特徵,引用阮一峰老師的描述就是:
(1)物件的狀態不受外界影響。Promise物件代表一個非同步操作,有三種狀態:pending(進行中)、fulfilled(已成功)和rejected(已失敗)。只有非同步操作的結果,可以決定當前是哪一種狀態,任何其他操作都無法改變這個狀態。這也是Promise這個名字的由來,它的英語意思就是“承諾”,表示其他手段無法改變。
(2)一旦狀態改變,就不會再變,任何時候都可以得到這個結果。Promise物件的狀態改變,只有兩種可能:從pending變為fulfilled和從pending變為rejected。只要這兩種情況發生,狀態就凝固了,不會再變了,會一直保持這個結果,這時就稱為 resolved(已定型)。如果改變已經發生了,你再對Promise物件新增回撥函式,也會立即得到這個結果。這與事件(Event)完全不同,事件的特點是,如果你錯過了它,再去監聽,是得不到結果的。
Promise基本用法
let promise1 = new Promise(function (resolve, reject){
setTimeout(function (){
resolve('ok') //將這個promise置為成功態(fulfilled),會觸發成功的回撥
},1000)
})
promise1.then(fucntion success(val) {
console.log(val) //一秒之後會列印'ok'
})
複製程式碼
最簡單程式碼實現一個Promise
class PromiseM {
constructor (process) {
this.status = 'pending'
this.msg = ''
process(this.resolve.bind(this), this.reject.bind(this))
return this
}
resolve (val) {
this.status = 'fulfilled'
this.msg = val
}
reject (err) {
this.status = 'rejected'
this.msg = err
}
then (fufilled, reject) {
if(this.status === 'fulfilled') {
fufilled(this.msg)
}
if(this.status === 'rejected') {
reject(this.msg)
}
}
}
//測試程式碼
var mm=new PromiseM(function(resolve,reject){
resolve('123');
});
mm.then(function(success){
console.log(success);
},function(){
console.log('fail!');
});
複製程式碼
Micro-task / event loop
上面提到Promise
和事件的不同,除此之外還有一個重要不同,就是Promise
建立是micro-task
。再看一道面試題:
面試題:寫出下面程式碼的輸出順序
console.log('script start');
setTimeout(function () {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function () {
console.log('promise1');
}).then(function () {
console.log('promise2');
});
console.log('script end');
複製程式碼
正確答案是:'script start'、'script end'、'promise1'、'promise2'、'setTimeout'。原因就是:
- setTimeout(或者事件)註冊的是一個task,由Event Loop控制
- Promise註冊的是一個micro-task
Event Loop
是js的一個重要機制,就是遇到事件或者setTimeout
等就會把對應的回撥函式放入一個事件佇列(task queue),等到主程式執行完畢就依次把佇列裡的函式壓入棧中執行。可以參考阮一峰老師的JavaScript 執行機制詳解:再談Event Loop,不過貌似老師的網站被攻擊還沒有恢復。
但是Promise
不是上面的機制,她建立的是一個微任務(micro-task),micro-task
的執行總是在當前執行棧結束和下一個task
執行之前,順序就是“當前執行棧” -> “micro-task” -> “task queue中取一個回撥” -> “micro-task” -> ... (不斷消費task queue) -> “micro-task”,總之就是當前執行棧為空時,就到了一個micro-task
的檢查點。
下面是micro-task
的定義:
Microtasks are usually scheduled for things that should happen straight after the currently executing script, such as reacting to a batch of actions, or to make something async without taking the penalty of a whole new task.
Promise
註冊的是micro-task
,所以上面題目中:主執行緒中'script start'、'script end'先列印,然後清空微任務佇列,'promise1'、'promise2'列印,然後取出task queue
中的回撥執行,'setTimeout'列印。
為什麼出現promise
Promise
提供了對js非同步程式設計的新的解決方案,因為我們一直使用的回撥函式其實是存在很大問題,只是限制於js的單執行緒等原因不得不大量書寫。當然Promise
並不是完全擺脫回撥,她只是改變了傳遞迴調的位置。那麼傳統的回撥存在什麼問題呢?
巢狀
這裡所說的巢狀是指大量的回撥函式會使得程式碼難以讀懂和修改,試想一個這個場景:讓你把下面的url4的呼叫提到url2之前。你需要非常小心的剪下程式碼,並且笨拙的貼上,result4這個引數你還不敢修改,因為這要額外花費很多功夫並且存在風險。
$.ajax('url1',function success(result1){
$.ajax('url2',function success(result2){
$.ajax('url3',function success(result3){
$.ajax('url4',function success(result4){
//……
})
})
})
})
複製程式碼
當然,上面的問題有點戲劇成分,現實中極少出現這種難搞的情況。與此相比,回撥函式帶來的思維上的難以理解是更致命的,因為我們的大腦更喜歡同步的邏輯,這也是為什麼await
關鍵字那麼受歡迎的原因。
我記得有一次我給後端的同學做JS新特性分享的時候,說到await
關鍵字,有個人驚呼:“哇!這個不錯啊,這就可以像寫java一樣寫程式碼了”。
信任
除去書寫的不優雅和維護的困難以外,回撥函式其實還存在信任問題。
事實上回撥函式不一定會像你期望的那樣被呼叫。因為控制權不在你的手上。這種問題被稱作“控制反轉”。例如下面的例子:
$.ajax('xxxxxx',function success(result1){
//比如成功之後我會運算元據庫記錄結算金額
})
複製程式碼
上面是jQuery
中的ajax
呼叫,我們期望在某些事件結束後,讓第三方(jQ)幫我們執行我的程式(回撥)。
那麼,我們和第三方之間並沒有一個契約或者規範可以遵循,除非你把你想使用的第三方庫通讀一遍,保證它做了你想做的事,但事實上你很難確定。即使在自己的程式碼中,或者自己編寫的工具,我們都很難做到百分之百信任。
Promise解決方案
Promise
是一個規範,嘗試以一種更加友好的方式書寫程式碼。Promise
物件接受一個函式作為引數,函式提供兩個引數:
- resolve:將
promise
從未完成切換到成功狀態,也就是上面提到的從pending
切換到fufilled
,resolve
可以傳遞引數,下一級promise
中的成功函式會接收到它 - reject:將
promise
從未完成切換到失敗狀態,即從pending
切換到rejected
let promise1 = new Promise(function(reslove, reject){
//reslove或者reject或者出錯
})
promise1.then(fufilled, rejected).then().then() //這是虛擬碼
promise1.then(fufilled, rejected)//可以then多次
function fufilled(data) {
console.log(data)
}
function rejected(e){
console.log(e)
}
複製程式碼
正如上面提到的兩個特徵,一旦狀態改變,這個Promise
就已經完成決議(不會再更改),並且返回一個新的Promise
,可以鏈式呼叫。並且可以註冊多個then
方法,他們同時決議並且互不影響。這種設計明顯比回撥函式要優雅的多,也更易於理解和維護。那麼在信任問題上她又有哪些改善呢?
Promise
通過通知的機制將“控制反轉”的關係又“反轉”回來。回撥是我傳遞給第三方一個函式,期望它在事件發生時幫我執行,而Promise
是在大家都遵循規範的前提下,我會在事件發生時得到通知,這時我決定做一些事(執行一些函式)。看到了吧,這是有本質差異的。
此外,回撥函式還有以下信任問題,Promise
也都做了相關約束:
- 回撥呼叫過早
- 回撥呼叫過晚(或者沒有呼叫)
- 呼叫次數太多
- 沒有把引數成功傳遞給你的回撥
- 吐掉了錯誤或者異常
過早或者過晚
一個Promise
回撥一定會在當前棧執行完畢和下一個非同步時機點上呼叫,即使像下面這樣的同步resolve
程式碼也會非同步執行,而你傳給工具庫的回撥函式卻可能被同步執行(呼叫過早)或者被忘記執行(或者過晚)。
new Promise(function (resolve) {
resolve(111111);
})
複製程式碼
次數太多或者沒有傳遞引數
Promise
只能被決議一次,如果你多次決議,她只會執行第一次決議,例如:
new Promise(function (reslove, reject) {
resolve()
setTimeout(function () {
resolve(2)
},1000)
resolve(3)
}).then(function (val) {
console.log(val) //undefined
})
複製程式碼
成功回撥的引數是通過resolve
傳遞的,例如像上面的程式碼一樣,沒有傳遞引數,那麼val收到的會是undefined
,所以,無論如何都會收到引數。注意:resolve
只接收一個引數,之後的引數會被忽略。
吞掉錯誤
Promise
的錯誤處理機制是這樣的:如果顯示的呼叫reject
並傳遞錯誤理由,這個訊息會傳遞給拒絕回撥。
此外,如果任意過程中出現錯誤(例如TypeError或者ReferenceError),這個錯誤會被捕捉,並且使這個Promise
拒絕,也就是說這個錯誤訊息也會傳遞給拒絕回掉,這與傳統的回撥是不同的,傳統的回撥一旦出錯會引起同步相應,而不出錯則是非同步。
promise併發控制
all / race
all
和race
兩個函式都是併發執行promise
的方法,他們的返回值也是promise
,all
會等所有的promise
都決議之後決議,而race
是隻要有一個決議就會決議。
Promise.all([promise1, promise2, promise3]).then(function(values) {
console.log(values);
});
複製程式碼
注意:如果引數為空,
all
方法會立刻決議,而race
方法會掛住。
面試題:封裝一個promise.all方法
Promise.all = function(ary) {
let num = 0
let result = []
return new Promise(function(reslove, reject){
ary.forEach(promise => {
promise.then(function(val){
if(num >= ary.length){
reslove(result)
}else{
result.push(val)
num++
}
},function(e){
reject(e)
})
})
})
}
複製程式碼
thenalbe
如何檢測一個物件是Promise?
你肯能會想到 instanceof Promise
,但遺憾的是不可以。原因是每種環境都封裝了自己的Promise
,而不是使用原生的ES6 Promise
。
所以目前判斷Promise
的一種方法就是判斷它是不是thenable
物件(如果它是一個物件或者函式,並且它具有then
方法)。
這是一種js常見的型別檢測方法——鴨子型別檢測:
鴨子型別檢測:如果它看起來像鴨子,叫起來也像鴨子,那麼它就是鴨子
resolve/reject
resolve
返回一個立即成功的Promise
,reject
返回一個立即失敗的Promise
,他們是new Promise
的語法糖,所以下面兩個寫法是等價的:
let p1 = new Promise(function(resolve, reject){
reslove(11111)
})
let p2 = Promise.resolve(11111) //這和上面的寫法結果一樣
複製程式碼
此外,如果傳入reslove
方法的引數不是promise
而是一個thenable
值,那麼reslove
會將它展開。最終的決議值由then
方法來決定。
錯誤處理
上面提到,Promise
是非同步處理錯誤,也就是說我的錯誤要在下一個Promise
才能捕獲到,大多情況這是好的,但是存在一個問題:如果捕獲錯誤的程式碼再出現錯誤呢?
我的做法通常是在程式碼的最後加catch
:
let p1 = new Promise(function(reslove, reject){
ajax('xxxxx')
})
p1
.then(fullfilled, rejected)
.then(fullfilled, rejected)
.catch(function(e){
//處理錯誤
})
複製程式碼
結尾
文章到這裡就結束了,如果你看完了並且因此思考了一些東西,我很高興。
接下來會繼續更新Promise
+generator
、非同步函式等Promise
相關知識,願共同進步。