首頁 > 軟體

JS非同步程式設計Promise物件詳解

2022-06-26 14:00:55

1、單執行緒模型

單執行緒模型指的是,JavaScript 只在一個執行緒上執行。也就是說,JavaScript 同時只能執行一個任務,其他任務都必須在後面排隊等待。注意,JavaScript 只在一個執行緒上執行,不代表 JavaScript 引擎只有一個執行緒。事實上,JavaScript 引擎有多個執行緒,單個指令碼只能在一個執行緒上執行(稱為主執行緒),其他執行緒都是在後臺配合。

JavaScript 之所以採用單執行緒,而不是多執行緒,跟歷史有關係。JavaScript 從誕生起就是單執行緒,原因是不想讓瀏覽器變得太複雜,因為多執行緒需要共用資源、且有可能修改彼此的執行結果,對於一種網頁尾本語言來說,這就太複雜了。

如果 JavaScript 同時有兩個執行緒,一個執行緒在網頁 DOM 節點上新增內容,另一個執行緒刪除了這個節點,這時瀏覽器應該以哪個執行緒為準?是不是還要有鎖機制?
所以,為了避免複雜性,JavaScript 一開始就是單執行緒,這已經成了這門語言的核心特徵,將來也不會改變。

2、同步任務和非同步任務

程式裡面所有的任務,可以分成兩類:同步任務(synchronous)和非同步任務(asynchronous)。

同步任務是那些沒有被引擎掛起、在主執行緒上排隊執行的任務。只有前一個任務執行完畢,才能執行後一個任務。

非同步任務是那些被引擎放在一邊,不進入主執行緒、而進入任務佇列的任務。只有引擎認為某個非同步任務可以執行了(比如 Ajax 操作從伺服器得到了結果),該任務(採用回撥函數的形式)才會進入主執行緒執行。排在非同步任務後面的程式碼,不用等待非同步任務結束會馬上執行,也就是說,非同步任務不具有“堵塞”效應。

舉例來說,Ajax 操作可以當作同步任務處理,也可以當作非同步任務處理,由開發者決定。如果是同步任務,主執行緒就等著 Ajax 操作返回結果,再往下執行;如果是非同步任務,主執行緒在發出 Ajax 請求以後,就直接往下執行,等到 Ajax 操作有了結果,主執行緒再執行對應的回撥函數。

3、任務佇列和事件迴圈

JavaScript 執行時,除了一個正在執行的主執行緒,引擎還提供一個任務佇列(task queue),裡面是各種需要當前程式處理的非同步任務。(實際上,根據非同步任務的型別,存在多個任務佇列。為了方便理解,這裡假設只存在一個佇列。)首先,主執行緒會去執行所有的同步任務。等到同步任務全部執行完,就會去看任務佇列裡面的非同步任務。

如果滿足條件,那麼非同步任務就重新進入主執行緒開始執行,這時它就變成同步任務了。等到執行完,下一個非同步任務再進入主執行緒開始執行。一旦任務佇列清空,程式就結束執行。非同步任務的寫法通常是回撥函數。一旦非同步任務重新進入主執行緒,就會執行對應的回撥函數。

如果一個非同步任務沒有回撥函數,就不會進入任務佇列,也就是說,不會重新進入主執行緒,因為沒有用回撥函數指定下一步的操作。JavaScript 引擎怎麼知道非同步任務有沒有結果,能不能進入主執行緒呢?答案就是引擎在不停地檢查,一遍又一遍,只要同步任務執行完了,引擎就會去檢查那些掛起來的非同步任務,是不是可以進入主執行緒了。這種迴圈檢查的機制,就叫做事件迴圈(Event Loop)。

維基百科的定義是:“事件迴圈是一個程式結構,用於等待和傳送訊息和事件(a programming construct that waits for and dispatches events or messages in a program)”。

4、非同步操作的模式

4.1回撥函數

f2寫成f1的回撥函數。

function f1(callback) {
  // ...
  callback();
}
 
function f2() {
  // ...
}
 
f1(f2);

回撥函數的優點是簡單、容易理解和實現,缺點是不利於程式碼的閱讀和維護,各個部分之間高度耦合(coupling),使得程式結構混亂、流程難以追蹤(尤其是多個回撥函數巢狀的情況),而且每個任務只能指定一個回撥函數。

4.2事件監聽

f1.on('done', f2);
 
function f1() {
  setTimeout(function () {
    // ...
    f1.trigger('done');
  }, 1000);
}

f1.trigger('done')表示,執行完成後,立即觸發done事件,從而開始執行f2

這種方法的優點是比較容易理解,可以繫結多個事件,每個事件可以指定多個回撥函數,而且可以“去耦合”(decoupling),有利於實現模組化。缺點是整個程式都要變成事件驅動型,執行流程會變得很不清晰。閱讀程式碼的時候,很難看出主流程。

4.3 釋出/訂閱

事件完全可以理解成“訊號”,如果存在一個“訊號中心”,某個任務執行完成,就向訊號中心“釋出”(publish)一個訊號,其他任務可以向訊號中心“訂閱”(subscribe)這個訊號,從而知道什麼時候自己可以開始執行。這就叫做“釋出/訂閱模式”(publish-subscribe pattern),又稱“觀察者模式”(observer pattern)。

f2向訊號中心jQuery訂閱done訊號。

jQuery.subscribe('done', f2);
 
function f1() {
  setTimeout(function () {
    // ...
    jQuery.publish('done');
  }, 1000);
}

上面程式碼中,jQuery.publish('done')的意思是,f1執行完成後,向訊號中心jQuery釋出done訊號,從而引發f2的執行。

f2完成執行後,可以取消訂閱(unsubscribe)。

jQuery.unsubscribe('done', f2);

這種方法的性質與“事件監聽”類似,但是明顯優於後者。因為可以通過檢視“訊息中心”,瞭解存在多少訊號、每個訊號有多少訂閱者,從而監控程式的執行。

5、Promise 物件的狀態

Promise 物件通過自身的狀態,來控制非同步操作。Promise 範例具有三種狀態。

  • 非同步操作未完成(pending)
  • 非同步操作成功(fulfilled)
  • 非同步操作失敗(rejected)

上面三種狀態裡面,fulfilledrejected合在一起稱為resolved(已定型)。

這三種的狀態的變化途徑只有兩種。

  • 從“未完成”到“成功”
  • 從“未完成”到“失敗”

一旦狀態發生變化,就凝固了,不會再有新的狀態變化。這也是 Promise 這個名字的由來,它的英語意思是“承諾”,一旦承諾成效,就不得再改變了。這也意味著,Promise 範例的狀態變化只可能發生一次。

因此,Promise 的最終結果只有兩種。

  • 非同步操作成功,Promise 範例傳回一個值(value),狀態變為fulfilled。
  • 非同步操作失敗,Promise 範例丟擲一個錯誤(error),狀態變為rejected。

6、Promise 建構函式

JavaScript 提供原生的Promise建構函式,用來生成 Promise 範例。

var promise = new Promise(function (resolve, reject) {
  // ...
 
  if (/* 非同步操作成功 */){
    resolve(value);
  } else { /* 非同步操作失敗 */
    reject(new Error());
  }
});

上面程式碼中,Promise建構函式接受一個函數作為引數,該函數的兩個引數分別是resolvereject。它們是兩個函數,由 JavaScript 引擎提供,不用自己實現。

resolve函數的作用是,將Promise範例的狀態從“未完成”變為“成功”(即從pending變為fulfilled),在非同步操作成功時呼叫,並將非同步操作的結果,作為引數傳遞出去。reject函數的作用是,將Promise範例的狀態從“未完成”變為“失敗”(即從pending變為rejected),在非同步操作失敗時呼叫,並將非同步操作報出的錯誤,作為引數傳遞出去。

下面是一個例子。

function timeout(ms) {
  return new Promise((resolve, reject) => {
    setTimeout(resolve, ms, 'done');
  });
}
 
timeout(100)

上面程式碼中,timeout(100)返回一個 Promise 範例。100毫秒以後,該範例的狀態會變為fulfilled

7、then() 用法辨析

Promise 的用法,簡單說就是一句話:使用then方法新增回撥函數。但是,不同的寫法有一些細微的差別,請看下面四種寫法,它們的差別在哪裡?

// 寫法一
f1().then(function () {
  return f2();
});
 
// 寫法二
f1().then(function () {
  f2();
});
 
// 寫法三
f1().then(f2());
 
// 寫法四
f1().then(f2);

為了便於講解,下面這四種寫法都再用then方法接一個回撥函數f3。寫法一的f3回撥函數的引數,是f2函數的執行結果。

f1().then(function () {
  return f2();
}).then(f3);

寫法二的f3回撥函數的引數是undefined

f1().then(function () {
  f2();
  return;
}).then(f3);

寫法三的f3回撥函數的引數,是f2函數返回的函數的執行結果。

f1().then(f2())
  .then(f3);

寫法四與寫法一隻有一個差別,那就是f2會接收到f1()返回的結果。

f1().then(f2)
  .then(f3);

8、Promise 優缺點

優點:讓回撥函數變成了規範的鏈式寫法,程式流程可以看得很清楚。它有一整套介面,可以實現許多強大的功能,比如同時執行多個非同步操作,等到它們的狀態都改變以後,再執行一個回撥函數;再比如,為多個回撥函數中丟擲的錯誤,統一指定處理方法等等。

而且,Promise 還有一個傳統寫法沒有的好處:它的狀態一旦改變,無論何時查詢,都能得到這個狀態。這意味著,無論何時為 Promise 範例新增回撥函數,該函數都能正確執行。所以,你不用擔心是否錯過了某個事件或訊號。如果是傳統寫法,通過監聽事件來執行回撥函數,一旦錯過了事件,再新增回撥函數是不會執行的。

缺點:編寫的難度比傳統寫法高,而且閱讀程式碼也不是一眼可以看懂。你只會看到一堆then,必須自己在then的回撥函數裡面理清邏輯。

9、微任務

Promise 的回撥函數屬於非同步任務,會在同步任務之後執行。

new Promise(function (resolve, reject) {
  resolve(1);
}).then(console.log);
 
console.log(2);
// 2
// 1

上面程式碼會先輸出2,再輸出1。因為console.log(2)是同步任務,而then的回撥函數屬於非同步任務,一定晚於同步任務執行。

但是,Promise 的回撥函數不是正常的非同步任務,而是微任務(microtask)。它們的區別在於,正常任務追加到下一輪事件迴圈,微任務追加到本輪事件迴圈。這意味著,微任務的執行時間一定早於正常任務。

setTimeout(function() {
  console.log(1);
}, 0);
 
new Promise(function (resolve, reject) {
  resolve(2);
}).then(console.log);
 
console.log(3);
// 3
// 2
// 1

上面程式碼的輸出結果是321。這說明then的回撥函數的執行時間,早於setTimeout(fn, 0)。因為then是本輪事件迴圈執行,setTimeout(fn, 0)在下一輪事件迴圈開始時執行。

到此這篇關於Promise非同步程式設計模式的文章就介紹到這了。希望對大家的學習有所幫助,也希望大家多多支援it145.com。


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