首頁 > 軟體

深入學習JavaScript中的promise

2022-06-24 10:00:24

為什麼要用Promise?

我們知道JavaScript是單執行緒的,一次只能執行一個任務,會阻塞其他任務。因此,所有的網路任務、遊覽器事件等都是非同步的,我們可以使用非同步回撥函數來進行非同步操作。

有這麼一個場景,我可以通過6個人能夠認識到任何一個人。但是我們不知道當前的人聯絡到下一個人的時間是多久,假如這是一個非同步的操作。

可以用如下程式碼錶示:

  function ConnectPeople(i) {
    console.log(`我聯絡到了第${i}個人`);
    return i + 1;
  }
  let i = 1;
  setTimeout(() => {
    const result1 = ConnectPeople(i);
    setTimeout(() => {
      const result2 = ConnectPeople(result1);
      setTimeout(() => {
        const result3 = ConnectPeople(result2);
        setTimeout(() => {
          const result4 = ConnectPeople(result3);
          setTimeout(() => {
            const result5 = ConnectPeople(result4);
            setTimeout(() => {
              const result6 = ConnectPeople(result5);
              setTimeout(() => {
                const result7 = ConnectPeople(result6);
                setTimeout(() => {
                  const result8 = ConnectPeople(result7);
                  setTimeout(() => {
                    const result9 = ConnectPeople(result8);
                    setTimeout(() => {
                      const result10 = ConnectPeople(result9);
                    }, 10000);
                  }, 5000);
                }, 3000);
              }, 2000);
            }, 3000);
          }, 2000);
        }, 1000);
      }, 500);
    }, 2000);
  }, 1000);

如上所示,當我們聯絡到了第一個人後,再去聯絡第二個人,然後再去聯絡第三個人...直到我聯絡到了10個人。乍一看,程式碼好像還挺規整,但是如果100個人,1000個人呢?由於回撥很多,函數作為引數層層巢狀,就陷入了回撥地獄。這種情況下,就像是金字塔一樣的程式碼非常不利於閱讀。

但是還好,我們有解決辦法。

使用Promise解決非同步控制問題

什麼是Promise?

Promise物件的主要⽤途是通過鏈式調⽤的結構,將原本回撥巢狀的非同步處理流程,轉化成“物件.then().then()...”的鏈式結構,這樣雖然仍離不開回撥函數,但是將原本的回撥巢狀結構,轉化成了連續調⽤的結構,這樣就可以在閱讀上程式設計上下左右結構的非同步執⾏流程了。

因此,Promise的作⽤是解決“回撥地獄”,他的解決⽅式是將回撥巢狀拆成鏈式調⽤,這樣便可以按照上下順序來進⾏非同步程式碼的流程控制。 如下程式碼所示,我們使用了Promise,程式碼也從原先的金字塔形式轉變成了從上往下的執行流程。

  function ConnectPeople(i) {
    console.log(`我聯絡到了第${i}個人`);
    return i + 1;
  }
  const p = new Promise((resolve) => {
    setTimeout(() => {
      resolve(ConnectPeople(1));
    }, 1000);
  });
  p.then((v1) => {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve(ConnectPeople(v1));
      }, 1000);
    });
  }).then((v2) => {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve(ConnectPeople(v2));
      }, 1000);
    });
  });

Promise的結構

根據上面的程式碼案例,我們發現Promise需要通過new關鍵字同時傳入一個引數來建立,所以我們可以嘗試列印一下window物件console.log(window)(window 物件在瀏覽器中有兩重身份,一個是ECMAScript 中的 Global 物件,另一個就是瀏覽器視窗的 JavaScript 介面),可以發現存在一個Promise的建構函式。

Promise初始化的時候需要傳入一個函數,如下所示:

const p = new Promise(fn) // fn是初始化的時候呼叫的函數,它是同步的回撥函數

回撥函數

什麼是回撥函數?

JavaScript中的回撥函數結構,預設是同步的結構。由於JavaScript單執行緒非同步模型的規則,如果想要編寫非同步的程式碼,必須使⽤回撥巢狀的形式才能實現,所以回撥函數結構不⼀定是非同步程式碼,但是非同步程式碼⼀定是回撥函數結構。

為什麼非同步程式碼一定是回撥函數結構?

我們知道JavaScript是單執行緒非同步模型,嚴格按照同步在前非同步在後的順序執行。如果用預設的上下結構,我們拿不到非同步回撥中的結果。

如下所示,程式碼執行的時候會先執行同步程式碼,非同步程式碼會掛起,繼續執行同步程式碼,到1s的時候掛起的任務會進入佇列,到2s的時候會繼續執行同步程式碼列印1,然後從任務佇列中取任務將num變成100,列印num。 所以實際執行效果是,過2秒後,先列印1再列印100。

var num = 1;
setTimeout(()=>{
    num = 100
    console.log(num)
},0)
var d = new Date().getTime()
var d1 = new Date().getTime()
while ( d1 - d < 1000 ) {
    d1 = new Date().getTime()
}
console.log(num) // 1

刨析Promise

翻譯一下promise,結果是承諾,保證,紅寶書中的解釋是期約

它有三個狀態:

  • pending 待定,初始狀態
  • fulfilled 兌現,已完成,通常代表成功執行了某一任務。初始化函數中的resolve()執行時,狀態就會變味fulfilled,而且.then函數註冊的回撥會開始執行,resolve中傳遞的引數會進入回撥函數成為形參。
  • rejected 拒絕,通常代表執行一次任務失敗,呼叫reject()時,catch註冊的函數就會觸發,並且reject中傳遞的內容會變成回撥函數的形參。

三種狀態之間的關係:

當物件建立之後同⼀個Promise物件只能從pending狀態變更為fulfilled或rejected中的其中⼀種,並且狀態⼀旦變更就不會再改變,此時Promise物件的流程執⾏完成並且finally函數執⾏。

我們列印一下Promise物件,發現它的建構函式中定義了allallSettledanyracerejectresolve方法(這些是實體方法),它的原型上存在catchfinallythen方法(這些是原型方法)。

原型方法——catchfinallythen

首先看下面程式碼:

  new Promise(function (resolve, reject) {
    resolve();
    reject();
  })
    .then(function () {
      console.log("then執⾏");
    })
    .catch(function () {
      console.log("catch執⾏");
    })
    .finally(function () {
      console.log("finally執⾏");
    });

執行後依次列印then執行->finally執行,發現.catch的回撥沒有執行。

再看如下程式碼:

  new Promise(function (resolve, reject) {
    reject();
    resolve();
  })
    .then(function () {
      console.log("then執⾏");
    })
    .catch(function () {
      console.log("catch執⾏");
    })
    .finally(function () {
      console.log("finally執⾏");
    });

這個串程式碼和之前的程式碼唯一的不同在於Promise中的回撥先執行了resolve()還是先執行了reject(),列印結果是catch執行->finally執行,發現.then的回撥沒有執行。

那如果Promise的回撥不執行reject()和resolve()呢?

會發現什麼輸出都沒有!

注意:Promise.prototype.catch()其實是一個語法糖,相當於是呼叫 Promise.prototype.then(null, onRejected)。.then中其實是可以傳入2個回撥函數,第一個回撥函數是resolve()後執行,第二個回撥函數是reject()後執行,2個是互斥的。

這是因為Promise的非同步回撥部分如何執⾏,取決於我們在初始化函數中的操作,並且初始化函數中⼀旦調⽤了resolve後⾯再執⾏reject也不會影響then執⾏,catch也不會執⾏,反之同理。

⽽在初始化回撥函數中,如果不執⾏任何操作,那麼promise的狀態就仍然是pending,所有註冊的回撥函數都不會執⾏。

由此可見,執行完resolve()之後才能夠執行.then的回撥;執行reject()之後才能夠執行.catch的回撥;finally()的回撥會在執行完.then或.catch之後執行。

這時候,我們就會想,是不是可以把resolve或者reject的呼叫設定在非同步函數內去呼叫,這樣是不是就能解決回撥地獄的問題了?

所以我們就去嘗試一下:

  new Promise(function (resolve, reject) {
    setTimeout(() => {
      console.log(111);
      resolve();
    }, 2000);
  })
    .then(function () {
      return new Promise(function (resolve, reject) {
        setTimeout(() => {
          console.log(222);
          resolve();
        }, 2000);
      });
    })
    .then(function () {
      return new Promise(function (resolve, reject) {
        setTimeout(() => {
          console.log(333);
          resolve();
        }, 2000);
      });
    })
    .catch(function () {
      console.log("catch執⾏");
    })
    .finally(function () {
      console.log("finally執⾏");
    });

上面程式碼每隔2s依次列印111->222->333 finally執行。333執行後立馬執行finally。

為什麼要在.then的回撥函數中return一個Promise呢?

因為下一個非同步的執行,需要等待前一個非同步執行完畢後才呼叫,我們需要用到resolve來控制.then執行的時機。

那如果我們不指明return返回值,它會返回什麼呢?是如何實現鏈式呼叫呢?

看下面程式碼:

  const p2 = new Promise((resolve, reject) => {
    resolve();
  });
  const p3 = p2.then(() => {
    console.log("resolved");
  });
  console.log(p3, 111);

p2.then的回撥函數中沒有return,但是我們知道一般來說函數返回值預設返回undefined,但是undefined中不會存在.then的方法。

因此我們就看一下p3裡面到底是什麼。有些人會想,.then是非同步呼叫的,它是一個微任務,那存取p3是不是不太正確?

我們列印一下p3,就會看到如下資訊:

第一行p3的狀態還是pending,當我們點開,發現已經變成了fulfilled了,因為參照型別是按地址存取的,當我們點開的時候會發現指向這個地址裡最後的資料是什麼。普通物件同理。

如下所示,我們console的時候a物件的name還是a,但是我們點開後發現程式執行完後a物件的實際name變成了b。

  const a = { name: "a" };
  console.log(a);
  a.name = "b";

迴歸正傳,我們發現p3裡面有3個欄位,[[Prototype]]我們很熟悉,這個是一個指向當前物件原型的指標。在大多數遊覽器中是可以通過__proto__存取到的。

我們嘗試著去存取:

  console.log(p3, 111);
  console.log(p3.__proto__);
  console.log(p3.__proto__ === Promise.prototype); // true

我們可以看到.then預設返回的有3個欄位,然後通過原型鏈來實現鏈式呼叫:

  • [[Prototype]]代表Promise的原型物件
  • [[PromiseState]]代表Promise物件當前的狀態
  • [[PromiseResult]]代表Promise物件的值,分別對應resolve或reject傳⼊的結果

本質就是在我們調⽤這些⽀持鏈式調⽤的函數的結尾時,他⼜返回了⼀個包含他⾃⼰的物件或者是⼀個新的⾃⼰,這些⽅式都可以實現鏈式調⽤。

中斷鏈式呼叫的方式:

中斷的⽅式可以使⽤丟擲⼀個異常或返回⼀個rejected狀態的Promise物件

鏈式呼叫的基本形式:

  • 只要有then()並且觸發了resolve,整個鏈條就會執⾏到結尾,這個過程中的第⼀個回撥函數的引數是resolve傳⼊的值
  • 後續每個函數都可以使⽤return返回⼀個結果,如果沒有返回結果的話下⼀個then中回撥函數的引數就是undefined
  • 返回結果如果是普通變數,那麼這個值就是下⼀個then中回撥函數的引數
  • 如果返回的是⼀個Promise物件,那麼這個Promise物件resolve的結果會變成下⼀次then中回撥的函數的引數
  • 如果then中傳⼊的不是函數或者未傳值,Promise鏈條並不會中斷then的鏈式調⽤,並且在這之前最後⼀次的返回結果,會直接進⼊離它最近的正確的then中的回撥函數作為引數

前面幾條我們都能懂,第5條什麼意思的? 看下面程式碼:

  const p2 = new Promise((resolve, reject) => {
    console.log(1);
    resolve();
  });
  p2.then(() => {
    console.log(2);
    return 123;
  })
    .then()
    .then("456")
    .then((res) => {
      console.log(res);
    });

發現只列印了1 2 和 123,return的123進入了最後一個.then的回撥函數中作為引數。

resolve和reject

至於resolve和reject,我們通過上面已經知道了resolve和reject能夠更改Promise的狀態,而Promise的狀態是不可逆的,且是私有的。所以我們必須在Promise內部呼叫resolve或者reject。

當然,resolve和reject也能夠傳入引數,而傳入的引數,會變為.then或.catch的回撥函數中的引數。

那如果傳入一個Promise作為引數呢???

resolve()

實際上,如果在resolve中傳入一個promise,那它的行為就相當於是一個空包裝。Promise.resolve()可以說相當於是一個冪等方法,會保留傳入期約的狀態。

let p = Promise.resolve(7);
setTimeout(console.log, 0, p === Promise.resolve(p)); // true
setTimeout(console.log, 0, p === Promise.resolve(Promise.resolve(p))); // true

reject()

會範例化一個拒絕的期約並丟擲一個非同步錯誤,不能通過try...catch捕獲,只能通過拒絕處理程式捕獲。

如果給reject傳入一個promise,則這個promise會成為返回的拒絕promise的理由。

  const p1 = new Promise(() => {});
  const p2 = Promise.resolve(111);
  const r3 = Promise.reject(p1);
  const r4 = Promise.reject(p2);
  console.log(r3);
  console.log(r4);

Promise常用API——all()、allSettled()、any()、race()

all()

假如我們有一個需求,一個頁面需要請求3個介面才能渲染,並且要求3個介面必須全部返回。如果我們通過鏈式呼叫的方式,介面1請求了再去請求介面2然後去請求介面3,全都成功了再去渲染頁面。這種就很耗時,所以就有了一個all的方法來解決。

Promise.all([promise物件,promise物件,...]).then(回撥函數)

Promise.all()的引數是一個Promise陣列,只有陣列中所有的Promise的狀態變成了fulfilled之後才會執行.then回撥的第一個回撥函數,並且將每個Promise結果的陣列變為回撥函數的引數。如果Promise中有一個rejected,那麼就會觸發.catch()的回撥。

  let p1 = new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("第⼀個promise執⾏完畢");
    }, 1000);
  });
  let p2 = new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("第⼆個promise執⾏完畢");
    }, 2000);
  });
  let p3 = new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("第三個promise執⾏完畢");
    }, 3000);
  });
  Promise.all([p1, p3, p2])
    .then((res) => {
      console.log(res);
    })
    .catch(function (err) {
      console.log(err);
    });
    // 3s後列印 ['第⼀個promise執⾏完畢', '第三個promise執⾏完畢', '第⼆個promise執⾏完畢']

race()

race()方法與all()方法的使用格式相同,不同的是,回撥函數的引數是promise陣列中最快執行完畢的promise的返回值,它的狀態可能是fulfilled也有可能是rejected,但是是最快返回的。

根據race這個單詞就能理解,相當於一群promise進行比賽,誰先到終點第一就是誰,不管是男是女。

  //promise.race()相當於將傳⼊的所有任務進行一個競爭
  let p1 = new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("第⼀個promise執⾏完畢");
    }, 5000);
  });

  let p2 = new Promise((resolve, reject) => {
    setTimeout(() => {
      reject("第⼆個promise執⾏完畢");
    }, 2000);
  });
  let p3 = new Promise((resolve) => {
    setTimeout(() => {
      resolve("第三個promise執⾏完畢");
    }, 3000);
  });
  Promise.race([p1, p3, p2])
    .then((res) => {
      console.log(res);
    })
    .catch(function (err) {
      console.error(err);
    });
    // 2秒後列印第二個promise執行完畢

allSettled()

該方法需要傳入所有不在pendding狀態的promise陣列,然後通過該方法可以知道陣列中的promise的當前狀態。

當有多個彼此不依賴的非同步任務成功完成時,或者總是想知道每個promise的結果時,通常使用它。

const promise1 = Promise.resolve(3);
const promise2 = new Promise((resolve, reject) => setTimeout(reject, 100, 'foo'));
const promises = [promise1, promise2];

Promise.allSettled(promises).
  then((results) => results.forEach((result) => console.log(result.status)));

// "fulfilled"
// "rejected"

any()

這個方法目前還是實驗性的,不是所有的遊覽器都能夠支援。

接受一個promise陣列,只要有一個promise的狀態變成了fulfilled,那麼這個方法就會返回這個promise;

如果所有的promise的狀態都是rejected,那麼就返回失敗的promise,並且把單一的錯誤集合在一起。

const pErr = new Promise((resolve, reject) => {
  reject("總是失敗");
});
const pSlow = new Promise((resolve, reject) => {
  setTimeout(resolve, 500, "最終完成");
});
const pFast = new Promise((resolve, reject) => {
  setTimeout(resolve, 100, "很快完成");
});
Promise.any([pErr, pSlow, pFast]).then((value) => {
  console.log(value);
})
// 很快完成

到此這篇關於深入學習JavaScript中的promise的文章就介紹到這了,更多相關JavaScript promise內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com