promise詳解

寒東設計師發表於2018-07-04

      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

      allrace兩個函式都是併發執行promise的方法,他們的返回值也是promiseall會等所有的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返回一個立即成功的Promisereject返回一個立即失敗的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相關知識,願共同進步。

相關文章