天天看點

你真的懂Promise嗎?

前言

在異步程式設計中,Promise 扮演了舉足輕重的角色,比傳統的解決方案(回調函數和事件)更合理和更強大。可能有些小夥伴會有這樣的疑問:2020年了,怎麼還在談論Promise?事實上,有些朋友對于這個幾乎每天都在打交道的“老朋友”,貌似全懂,但稍加深入就可能疑問百出,本文帶大家深入了解這個熟悉的陌生人—— Promise.

基本用法

1.文法

new Promise( function(resolve, reject) {...} /* executor */  )      
  • 建構 Promise 對象時,需要傳入一個 executor 函數,主要業務流程都在 executor 函數中執行。
  • Promise構造函數執行時立即調用executor 函數, resolve 和 reject 兩個函數作為參數傳遞給executor,resolve 和 reject 函數被調用時,分别将promise的狀态改為fulfilled(完成)或rejected(失敗)。一旦狀态改變,就不會再變,任何時候都可以得到這個結果。
  • 在 executor 函數中調用 resolve 函數後,會觸發 promise.then 設定的回調函數;而調用 reject 函數後,會觸發 promise.catch 設定的回調函數。
你真的懂Promise嗎?

值得注意的是,Promise 是用來管理異步程式設計的,它本身不是異步的,new Promise的時候會立即把executor函數執行,隻不過我們一般會在executor函數中處理一個異步操作。比如下面代碼中,一開始是會先列印出2。

let p1 = new Promise(()=>{    setTimeout(()=>{      console.log(1)    },1000)    console.log(2)  })console.log(3) // 2 3 1      

Promise 采用了回調函數延遲綁定技術,在執行 resolve 函數的時候,回調函數還沒有綁定,那麼隻能推遲回調函數的執行。這具體是啥意思呢?我們先來看下面的例子:

let p1 = new Promise((resolve,reject)=>{  console.log(1);  resolve('浪裡行舟')  console.log(2)})// then:設定成功或者失敗後處理的方法p1.then(result=>{ //p1延遲綁定回調函數  console.log('成功 '+result)},reason=>{  console.log('失敗 '+reason)})console.log(3)// 1// 2// 3// 成功 浪裡行舟      

new Promise的時候先執行executor函數,列印出 1、2,Promise在執行resolve時,觸發微任務,還是繼續往下執行同步任務, 執行p1.then時,存儲起來兩個函數(此時這兩個函數還沒有執行),然後列印出3,此時同步任務執行完成,最後執行剛剛那個微任務,進而執行.then中成功的方法。

錯誤處理

Promise 對象的錯誤具有“冒泡”性質,會一直向後傳遞,直到被 onReject 函數處理或 catch 語句捕獲為止。具備了這樣“冒泡”的特性後,就不需要在每個 Promise 對象中單獨捕獲異常了。

要遇到一個then,要執行成功或者失敗的方法,但如果此方法并沒有在目前then中被定義,則順延到下一個對應的函數

function executor (resolve, reject) {  let rand = Math.random()  console.log(1)  console.log(rand)  if (rand > 0.5) {    resolve()  } else {    reject()  }}var p0 = new Promise(executor)var p1 = p0.then((value) => {  console.log('succeed-1')  return new Promise(executor)})var p2 = p1.then((value) => {  console.log('succeed-2')  return new Promise(executor)})p2.catch((error) => {  console.log('error', error)})console.log(2)      

這段代碼有三個 Promise 對象:p0~p2。無論哪個對象裡面抛出異常,都可以通過最後一個對象 p2.catch 來捕獲異常,通過這種方式可以将所有 Promise 對象的錯誤合并到一個函數來處理,這樣就解決了每個任務都需要單獨處理異常的問題。

通過這種方式,我們就消滅了嵌套調用和頻繁的錯誤處理,這樣使得我們寫出來的代碼更加優雅,更加符合人的線性思維。

Promise鍊式調用

我們都知道可以把多個Promise連接配接到一起來表示一系列異步驟。這種方式可以實作的關鍵在于以下兩個Promise 固有行為特性:

  • 每次你對Promise調用then,它都會建立并傳回一個新的Promise,我們可以将其連結起來;
  • 不管從then調用的完成回調(第一個參數)傳回的值是什麼,它都會被自動設定為被連結Promise(第一點中的)的完成。

先通過下面的例子,來解釋一下剛剛這段話是什麼意思,然後詳細介紹下鍊式調用的執行流程

let p1=new Promise((resolve,reject)=>{    resolve(100) // 決定了下個then中成功方法會被執行})// 連接配接p1let p2=p1.then(result=>{    console.log('成功1 '+result)    return Promise.reject(1) // 傳回一個新的Promise執行個體,決定了目前執行個體是失敗的,是以決定下一個then中失敗方法會被執行},reason=>{    console.log('失敗1 '+reason)    return 200})// 連接配接p2 let p3=p2.then(result=>{    console.log('成功2 '+result)},reason=>{    console.log('失敗2 '+reason)})// 成功1 100// 失敗2 1      

我們通過傳回 Promise.reject(1) ,完成了第一個調用then建立并傳回的promise p2。p2的then調用在運作時會從return Promise.reject(1) 語句接受完成值。當然,p2.then又建立了另一個新的promise,可以用變量p3存儲。

new Promise出來的執行個體,成功或者失敗,取決于executor函數執行的時候,執行的是resolve還是reject決定的,或executor函數執行發生異常錯誤,這兩種情況都會把執行個體狀态改為失敗的。

p2執行then傳回的新執行個體的狀态,決定下一個then中哪一個方法會被執行,有以下幾種情況:

  • 不論是成功的方法執行,還是失敗的方法執行(then中的兩個方法),凡是執行抛出了異常,則都會把執行個體的狀态改為失敗。
  • 方法中如果傳回一個新的Promise執行個體(比如上例中的Promise.reject(1)),傳回這個執行個體的結果是成功還是失敗,也決定了目前執行個體是成功還是失敗。
  • 剩下的情況基本上都是讓執行個體變為成功的狀态,上一個then中方法傳回的結果會傳遞到下一個then的方法中。

我們再來看個例子

new Promise(resolve=>{    resolve(a) // 報錯 // 這個executor函數執行發生異常錯誤,決定下個then失敗方法會被執行}).then(result=>{    console.log(`成功:${result}`)    return result*10},reason=>{    console.log(`失敗:${reason}`)// 執行這句時候,沒有發生異常或者傳回一個失敗的Promise執行個體,是以下個then成功方法會被執行// 這裡沒有return,最後會傳回 undefined}).then(result=>{    console.log(`成功:${result}`)},reason=>{    console.log(`失敗:${reason}`)})// 失敗:ReferenceError: a is not defined// 成功:undefined      

async & await

從上面一些例子,我們可以看出,雖然使用 Promise 能很好地解決回調地獄的問題,但是這種方式充滿了 Promise 的 then() 方法,如果處理流程比較複雜的話,那麼整段代碼将充斥着 then,語義化不明顯,代碼不能很好地表示執行流程。

ES7中新增的異步程式設計方法,async/await的實作是基于 Promise的,簡單而言就是async 函數就是傳回Promise對象,是generator的文法糖。很多人認為async/await是異步操作的終極解決方案:

  • 文法簡潔,更像是同步代碼,也更符合普通的閱讀習慣;
  • 改進JS中異步操作串行執行的代碼組織方式,減少callback的嵌套;
  • Promise中不能自定義使用try/catch進行錯誤捕獲,但是在Async/await中可以像處理同步代碼處理錯誤。

不過也存在一些缺點,因為 await 将異步代碼改造成了同步代碼,如果多個異步代碼沒有依賴性卻使用了 await 會導緻性能上的降低。

async function test() {  // 以下代碼沒有依賴性的話,完全可以使用 Promise.all 的方式  // 如果有依賴性的話,其實就是解決回調地獄的例子了  await fetch(url1)  await fetch(url2)  await fetch(url3)}      

觀察下面這段代碼,你能判斷出列印出來的内容是什麼嗎?

let p1 = Promise.resolve(1)let p2 = new Promise(resolve => {  setTimeout(() => {    resolve(2)  }, 1000)})async function fn() {  console.log(1)// 當代碼執行到此行(先把此行),建構一個異步的微任務// 等待promise傳回結果,并且await下面的代碼也都被列到任務隊列中  let result1 = await p2  console.log(3)  let result2 = await p1  console.log(4)}fn()console.log(2)// 1 2 3 4      

如果 await 右側表達邏輯是個 promise,await會等待這個promise的傳回結果,隻有傳回的狀态是resolved情況,才會把結果傳回,如果promise是失敗狀态,則await不會接收其傳回結果,await下面的代碼也不會在繼續執行。

let p1 = Promise.reject(100)async function fn1() {  let result = await p1  console.log(1) //這行代碼不會執行}      

我們再來看道比較複雜的題目:

console.log(1)setTimeout(()=>{console.log(2)},1000)async function fn(){    console.log(3)    setTimeout(()=>{console.log(4)},20)    return Promise.reject()}async function run(){    console.log(5)    await fn()    console.log(6)}run()//需要執行150ms左右for(let i=0;i<90000000;i++){}setTimeout(()=>{    console.log(7)    new Promise(resolve=>{        console.log(8)        resolve()    }).then(()=>{console.log(9)})},0)console.log(10)// 1 5 3 10 4 7 8 9 2      

做這道題之前,讀者需明白:

  • 基于微任務的技術有 MutationObserver、Promise 以及以 Promise 為基礎開發出來的很多其他的技術,本題中resolve()、await fn()都是微任務。
  • 不管宏任務是否到達時間,以及放置的先後順序,每次主線程執行棧為空的時候,引擎會優先處理微任務隊列,處理完微任務隊列裡的所有任務,再去處理宏任務。

接下來,我們一步一步分析:

  • 首先執行同步代碼,輸出 1,遇見第一個setTimeout,将其回調放入任務隊列(宏任務)當中,繼續往下執行
  • 運作run(),列印出 5,并往下執行,遇見 await fn(),将其放入任務隊列(微任務)
  • await fn() 目前這一行代碼執行時,fn函數會立即執行的,列印出3,遇見第二個setTimeout,将其回調放入任務隊列(宏任務),await fn() 下面的代碼需要等待傳回Promise成功狀态才會執行,是以6是不會被列印的。
  • 繼續往下執行,遇到for循環同步代碼,需要等150ms,雖然第二個setTimeout已經到達時間,但不會執行,遇見第三個setTimeout,将其回調放入任務隊列(宏任務),然後列印出10。值得注意的是,這個定時器 推遲時間0毫秒實際上達不到的。根據HTML5标準,setTimeOut推遲執行的時間,最少是4毫秒。
  • 同步代碼執行完畢,此時沒有微任務,就去執行宏任務,上面提到已經到點的setTimeout先執行,列印出4
  • 然後執行下一個setTimeout的宏任務,是以先列印出7,new Promise的時候會立即把executor函數執行,列印出8,然後在執行resolve時,觸發微任務,于是列印出9
  • 最後執行第一個setTimeout的宏任務,列印出2

常用的方法

1、Promise.resolve()

Promise.resolve(value)方法傳回一個以給定值解析後的Promise 對象。Promise.resolve()等價于下面的寫法:

Promise.resolve('foo')// 等價于new Promise(resolve => resolve('foo'))      

Promise.resolve方法的參數分成四種情況。

(1)參數是一個 Promise 執行個體

如果參數是 Promise 執行個體,那麼Promise.resolve将不做任何修改、原封不動地傳回這個執行個體。

const p1 = new Promise(function (resolve, reject) {  setTimeout(() => reject(new Error('fail')), 3000)})const p2 = new Promise(function (resolve, reject) {  setTimeout(() => resolve(p1), 1000)})p2  .then(result => console.log(result))  .catch(error => console.log(error))// Error: fail      

上面代碼中,p1是一個 Promise,3 秒之後變為rejected。p2的狀态在 1 秒之後改變,resolve方法傳回的是p1。由于p2傳回的是另一個 Promise,導緻p2自己的狀态無效了,由p1的狀态決定p2的狀态。是以,後面的then語句都變成針對後者(p1)。又過了 2 秒,p1變為rejected,導緻觸發catch方法指定的回調函數。

(2)參數不是具有then方法的對象,或根本就不是對象

Promise.resolve("Success").then(function(value) { // Promise.resolve方法的參數,會同時傳給回調函數。  console.log(value); // "Success"}, function(value) {  // 不會被調用});      

(3)不帶有任何參數

Promise.resolve()方法允許調用時不帶參數,直接傳回一個resolved狀态的 Promise 對象。如果希望得到一個 Promise 對象,比較友善的方法就是直接調用Promise.resolve()方法。

Promise.resolve().then(function () {  console.log('two');});console.log('one');// one two      

(4)參數是一個thenable對象

thenable對象指的是具有then方法的對象,Promise.resolve方法會将這個對象轉為 Promise 對象,然後就立即執行thenable對象的then方法。

let thenable = {  then: function(resolve, reject) {    resolve(42);  }};let p1 = Promise.resolve(thenable);p1.then(function(value) {  console.log(value);  // 42});      

2、Promise.reject()

Promise.reject()方法傳回一個帶有拒絕原因的Promise對象。

new Promise((resolve,reject) => {    reject(new Error("出錯了"));});// 等價于 Promise.reject(new Error("出錯了"));  
// 使用方法Promise.reject(new Error("BOOM!")).catch(error => {    console.error(error);});      

值得注意的是,調用resolve或reject以後,Promise 的使命就完成了,後繼操作應該放到then方法裡面,而不應該直接寫在resolve或reject的後面。是以,最好在它們前面加上return語句,這樣就不會有意外。

new Promise((resolve, reject) => {  return reject(1);  // 後面的語句不會執行  console.log(2);})      

3、Promise.all()

let p1 = Promise.resolve(1)let p2 = new Promise(resolve => {  setTimeout(() => {    resolve(2)  }, 1000)})let p3 = Promise.resolve(3)Promise.all([p3, p2, p1])  .then(result => { // 傳回的結果是按照Array中編寫執行個體的順序來    console.log(result) // [ 3, 2, 1 ]  })  .catch(reason => {    console.log("失敗:reason")  })      

Promise.all 生成并傳回一個新的 Promise 對象,是以它可以使用 Promise 執行個體的所有方法。參數傳遞promise數組中所有的 Promise 對象都變為resolve的時候,該方法才會傳回, 新建立的 Promise 則會使用這些 promise 的值。

如果參數中的任何一個promise為reject的話,則整個Promise.all調用會立即終止,并傳回一個reject的新的 Promise 對象。

4、Promise.allSettled()

有時候,我們不關心異步操作的結果,隻關心這些操作有沒有結束。這時,ES2020 引入Promise.allSettled()方法就很有用。如果沒有這個方法,想要確定所有操作都結束,就很麻煩。Promise.all()方法無法做到這一點。

假如有這樣的場景:一個頁面有三個區域,分别對應三個獨立的接口資料,使用 Promise.all 來并發請求三個接口,如果其中任意一個接口出現異常,狀态是reject,這會導緻頁面中該三個區域資料全都無法出來,顯然這種狀況我們是無法接受,Promise.allSettled的出現就可以解決這個痛點:

Promise.allSettled([  Promise.reject({ code: 500, msg: '服務異常' }),  Promise.resolve({ code: 200, list: [] }),  Promise.resolve({ code: 200, list: [] })]).then(res => {  console.log(res)  /*    0: {status: "rejected", reason: {…}}    1: {status: "fulfilled", value: {…}}    2: {status: "fulfilled", value: {…}}  */  // 過濾掉 rejected 狀态,盡可能多的保證頁面區域資料渲染  RenderContent(    res.filter(el => {      return el.status !== 'rejected'    })  )})      

Promise.allSettled跟Promise.all類似, 其參數接受一個Promise的數組, 傳回一個新的Promise, 唯一的不同在于, 它不會進行短路, 也就是說當Promise全部處理完成後,我們可以拿到每個Promise的狀态, 而不管是否處理成功。

5、Promise.race()

Promise.all()方法的效果是"誰跑的慢,以誰為準執行回調",那麼相對的就有另一個方法"誰跑的快,以誰為準執行回調",這就是Promise.race()方法,這個詞本來就是賽跑的意思。race的用法與all一樣,接收一個promise對象數組為參數。

Promise.all在接收到的所有的對象promise都變為FulFilled或者Rejected狀态之後才會繼續進行後面的處理,與之相對的是Promise.race隻要有一個promise對象進入FulFilled或者Rejected狀态的話,就會繼續進行後面的處理。

// `delay`毫秒後執行resolvefunction timerPromisefy(delay) {    return new Promise(resolve => {        setTimeout(() => {            resolve(delay);        }, delay);    });}// 任何一個promise變為resolve或reject的話程式就停止運作Promise.race([    timerPromisefy(1),    timerPromisefy(32),    timerPromisefy(64)]).then(function (value) {    console.log(value);    // => 1});      

上面的代碼建立了3個promise對象,這些promise對象會分别在1ms、32ms 和 64ms後變為确定狀态,即FulFilled,并且在第一個變為确定狀态的1ms後,.then注冊的回調函數就會被調用。

6、Promise.prototype.finally()

ES9 新增 finally() 方法傳回一個Promise。在promise結束時,無論結果是fulfilled或者是rejected,都會執行指定的回調函數。這為在Promise是否成功完成後都需要執行的代碼提供了一種方式。這避免了同樣的語句需要在then()和catch()中各寫一次的情況。

比如我們發送請求之前會出現一個loading,當我們請求發送完成之後,不管請求有沒有出錯,我們都希望關掉這個loading。

this.loading = truerequest()  .then((res) => {    // do something  })  .catch(() => {    // log err  })  .finally(() => {    this.loading = false  })      

finally方法的回調函數不接受任何參數,這表明,finally方法裡面的操作,應該是與狀态無關的,不依賴于 Promise 的執行結果。

實際應用

假設有這樣一個需求:紅燈 3s 亮一次,綠燈 1s 亮一次,黃燈 2s 亮一次;如何讓三個燈不斷交替重複亮燈?三個亮燈函數已經存在:

function red() {    console.log('red');}function green() {    console.log('green');}function yellow() {    console.log('yellow');}      

這道題複雜的地方在于需要“交替重複”亮燈,而不是亮完一遍就結束的一錘子買賣,我們可以通過遞歸來實作:

// 用 promise 實作let task = (timer, light) => {  return new Promise((resolve, reject) => {    setTimeout(() => {      if (light === 'red') {        red()      }      if (light === 'green') {        green()      }      if (light === 'yellow') {        yellow()      }      resolve()    }, timer);  })}let step = () => {  task(3000, 'red')    .then(() => task(1000, 'green'))    .then(() => task(2000, 'yellow'))    .then(step)}step()      

同樣也可以通過async/await 的實作:

//  async/await 實作let step = async () => {  await task(3000, 'red')  await task(1000, 'green')  await task(2000, 'yellow')  step()}step()      

使用 async/await 可以實作用同步代碼的風格來編寫異步代碼,毫無疑問,還是 async/await 的方案更加直覺,不過深入了解Promise 是掌握async/await的基礎。