javascript 中的 Promise

原創性聲明:本文完全為筆者原創,請尊重筆者勞動力。轉載務必注明原文地址。

發現有一段時間沒有寫文章了,中了一段時間農藥的毒,后面都變得有點懶了。

今天寫寫javascript中的這個Promise,還記得第一次碰到這個東西是好久以前在angularJS的環境下。angularJS$resource獲取了后端RESTfull api 接口的數據后是一個Promise對象,直接用雙向綁定到DOM上是能正確顯示的,但是控制臺打印它里面的某個屬性值死活就是undefined,后來磕磕碰碰摸索久了就知道有個then,今天再來總結一下這個東西。

前戲

Promise譯為保證。表示異步處理的方法承諾返回一個值。Promise對象也是用于解決異步中晦澀復雜的回調現象。至于如何解決,不妨先看看在Promise之前,一個異步回調的例子:

var getData = function(url, callback) {
  var req = new XMLHttpRequest();
  req.open('GET', URL, true);
  req.onload = function () {
    callback(req);
  };
  req.send();
}

var url = 'api/user/username/:id';
getData(url, function(req) {
  if (req.status == 200) {
    $('#username').text(req.statusText); // 用jquery將請求的數據寫入dom
  } else {
    console.error(new Error(req.statusText);
  }
});

如果拿到username后不是顯示這么簡單,還需要把username作為請求參數繼續請求別的數據(例如:age),然后展示age呢。

實際情況當然是直接請求一個user就行了,這里只是舉例方便。

改動一下代碼:

var getData = function(url, callback) {
  var req = new XMLHttpRequest();
  req.open('GET', URL, true);
  req.onload = function () {
    callback(req.responseText);
  };
  req.send();
}

var url = 'api/user/username/:id';
getData(url, function(data) {
});

var url = 'api/user/username/:id';
getData(url, function(req) {
  if (req.status == 200) {
    var url = 'api/user/age/:username';
    getData(url, function(req) {
      if (req.status == 200) {
        $('#age').text(req.statusText); // 用jquery將請求的數據寫入dom
      } else {
        console.error(new Error(req.statusText));
      }
    });
  } else {
    console.error(new Error(req.statusText));
  }
});

顯然,這看起來不僅有點理解費勁,而且代碼也不好看。但是,要用回調實現這里面的異步請求,只能這樣干。事實上,這是一種強行異步變同步的辦法,通過回調,以確保數據拿到之后再做下一步的處理,所以這是事實上的同步。如果用Promise,會怎么樣呢?

var getData = function(url) {
  return new Promise(function(resolve, reject) {
    var req = new XMLHttpRequest();
    req.open('GET', URL, true);
    req.onload = function () {
      if (req.status == 200) {
        resolve(req.responseText);
      } else {
        reject(new Error(req.statusText));
      }
    };
    req.send();
  });
}

var url = 'api/user/username/:id';
getData(url).then(function(username) {
  var url = 'api/user/age/:username';
  getData(url).then(function(age) {
    $('#age').text(age); // 用jquery將請求的數據寫入dom
  }, function(error) {
    console.log(error);
  });
}, function(error) {
  console.log(error);
})

乍一看,代碼也沒有少很多嘛。的確,但是仔細理解這兩段代碼時,就會感受到它們的區別。采用Promise時,調用getData時你不需要關心它內部的代碼,給你的感覺就是getData(url)已經返回了我想要的結果(username),于是自然地就在then()方法中的第一個參數(匿名函數)中直接用這個結果(username)去發起下一個請求(請求age),而在then()的第二個參數(匿名函數)中對錯誤進行處理。

而采用回調方法,你總是需要去把該參數函數帶入getData中。雖然它代碼的順序看上去和Promise很像,但代碼執行的流程卻是離不開回調的本質。如果回調多了,就可能會陷入回調地獄。理解和維護都會很蛋疼。

Promise

上面的例子就基本闡明了Promise的簡單用法。構建一個Promise對象可以這樣:

var promise = new Promise(function(resolve, reject) {
  // 異步處理,處理結束后、調用resolve 或 reject
});

然后就可以調用Promise實例的then方法:

promise.then(onFulfilled, onRejected);

onFulfilled, onRejected是兩個參數函數。

  • resolve(成功)時:
    onFulfilled 會被調用
  • reject(失敗)時
    onRejected 會被調用

也就是說,promise執行成功(一般是后端交互請求數據),那么就會調用onFulfilled方法,所以在這個方法里繼續寫拿到數據后要執行的代碼即可,如果發生了錯誤(例如:后端報錯),則會執行onRejected方法,在里面定義錯誤處理的代碼即可。

上面涉及Promise的兩個API方法:1. 構造函數Promise(); 2.實例方法then()

怎么把一個常量包裝為Promise對象?

var promise = Promise.resolve(99);

當然,這個對象的then方法就完全沒有第二個函數參數的必要了。

說到第二個函數參數onRejected,它是用于發生異常時被調用的。此外,還可以通過catch來捕獲異常,如下:

var promise = Promise.resolve(99);
promise.then(function(value){
  console.log(value); // 99
}).catch(function(error){
  console.log(error);
})

onRejected效果一致。當然,此處的catch也沒有意義。

Promise.resolve(99)可以認為是下面代碼片段的語法糖:

Promise.resolve(99).then(function(value){
    resolve(value); // 此時,這個promise對象立即進入確定(即resolved)狀態,并將99傳遞給后面then里所指定的 onFulfilled 函數
});

這里涉及了Promise的一個靜態方法: resolve,除構造方法之外的另一個構建Promise的方法。

Promise的狀態

用new Promise 實例化的promise對象有以下三個狀態。

  1. has-resolution -Fulfilled(譯文:履行)
    resolve(成功)時。此時會調用 onFulfilled

  2. has-rejection - Rejected(譯文:拒絕)
    reject(失敗)時。此時會調用 onRejected

  3. unresolved - Pending(譯文:未決定)
    既不是resolve也不是reject的狀態。也就是promise對象剛被創建后的初始化狀態等。

promise對象的狀態,從Pending轉換為FulfilledRejected之后, 這個promise對象的狀態就不會再發生任何變化。也就是說,PromiseEvent等不同,在.then 后執行的函數可以肯定地說只會被調用一次。另外,FulfilledRejected這兩個中的任一狀態都可以表示為Settled(不變的,穩妥的:表示這個Promise的狀態已經回歸穩定,即完成了FulfilledRejected)。

Thenable對象

什么是Thenable對象?從名字上看,就是可以then的對象——可以調用then方法的對象。除了Promise還有對象可以調用then方法嗎?答案是肯定的,比如:

$.ajax('/json/comment.json'); // 可以繼續`.then()`

這是jquery的jqXHR對象,它其實就是一個Thenable對象。但它不是一個Promise,因為不符合ECMAScript 6中的標準。

如何將它轉換為一個標準的Promise對象呢?

var promise = Promise.resolve($.ajax('/json/comment.json'));// => promise對象
promise.then(function(value){
   console.log(value);
});
  1. 簡單總結一下 Promise.resolve 方法的話,可以認為它的作用就是將傳遞給它的參數填充(Fulfilled)到promise對象后并返回這個promise對象。
  2. 此外,Promise的很多處理內部也是使用了 Promise.resolve 算法將值轉換為promise對象后再進行處理的。
Promise方法鏈(promise chain)

前面,接觸了then().catch()這種鏈式調用的方式,其實Promise的鏈式調用更加豐富:

function taskA() {
    console.log("Task A");
}
function taskB() {
    console.log("Task B");
}
function onRejected(error) {
    console.log("Catch Error: A or B", error);
}
function finalTask() {
    console.log("Final Task");
}

var promise = Promise.resolve();
promise
    .then(taskA)
    .then(taskB)
    .catch(onRejected)
    .then(finalTask);

結果顯而易見:

Task A
Task B
Final Task

上面的代碼的執行流程用圖來展示,就像這樣:

image.png

顯然,catch會處理TaskA,也會處理TaskB。那如果TaskA發生了錯誤,還會執行TaskB嗎? 答案是不會。如下圖:

image.png

這時,我們再回到文章最開頭的那個Promise例子,先根據id獲取username,再根據username獲取age,最后寫入dom中,這個過程顯然對于promise chain鏈式調用的應用場景再符合不過了:

var getData = function(url) {
  return new Promise(function(resolve, reject) {
    var req = new XMLHttpRequest();
    req.open('GET', URL, true);
    req.onload = function () {
      if (req.status == 200) {
        resolve(req.responseText);
      } else {
        reject(new Error(req.statusText));
      }
    };
    req.send();
  });
}

var url = 'api/user/username/:id';
getData(url)
.then(function(username) {
  var url = 'api/user/age/:username';
  return getData(url);  // a. 向下一個then中傳遞參數
}).then(function(age) {
  $('#age').text(age);
}).catch(function(err){
  console.log(err);
});

我們是怎樣往第二個then傳遞異步查詢的結果age呢?通過一個關鍵的 return

通過連續的then方法調用,整個流程(兩個步驟)無論在理解上,還是代碼美觀上,都變得非常優雅!甚至讓你在編寫它的時候忘記它原來是個異步的過程——這簡直和寫同步代碼是一樣一樣的嘛。

另外,我們還注意到,原本應該有兩個catch變成了一個catch倒金字塔的惡心沒有了,滿屏的花括號也沒有了。流程變得清晰。這就是使用Promise的良好開發體驗。

【疑問】:稍微細心點,不難發現(a)return的是getData(url),這個結果應該是一個Promise對象,而不是一個普通值(例如: 24(歲)),為什么這樣也可以在下一個then中通過age參數接收到呢?經過實踐測試,返回普通值Promise對象,都可以正常接收。
【結論】:每個方法中 return 的值不僅只局限于字符串或者數值類型,也可以是對象或者promise對象等復雜類型。

事實上,在每次調用then方法后,都會返回一個新的Promise對象,以供下一次鏈式調用。如果then中匿名函數返回的是普通類型,Promise在內部也會將其轉換為一個新的Promise對象(注意:是新的Promise對象)。

image.png

如上圖:兩個promise object一定是兩個不同的Promise對象,盡管then中可能什么也沒有做,他們resolve之后的值也盡管可能相同。

由此,可能引出一個可能常犯的錯誤

不正確的:

function asyncCall() {
    var promise = Promise.resolve();
    promise.then(function() { // a
        // 任意處理
        return newVar;
    });
    return promise;
}

(a)處調用then時,返回的是一個新的Promise對象,而不再是promise那個變量,因此這個then中的異常是不會被外部捕獲的,而且它的返回值也無法在外部得到。

正確的:

function asyncCall() {
    var promise = Promise.resolve();
    return promise.then(function() {
        // 任意處理
        return newVar;
    });
}

或者

function asyncCall() {
    var promise = Promise.resolve();
    var promise1 = promise.then(function() {
        // 任意處理
        return newVar;
    });
    return promise1;
}
Promise.all方法

這個方法之前沒有提到過。仍以最開始案例延伸。現在我們不需要根據username獲取age了,可以直接根據id獲取age,也就是說我們有兩個請求:1. 根據id獲取username2. 根據id獲取age。我們希望這兩個請求都完成后,再將結果usernameage插入到DOM中。這時,Promise.all就可以用上了:

var getData = function(url) {
  return new Promise(function(resolve, reject) {
    var req = new XMLHttpRequest();
    req.open('GET', URL, true);
    req.onload = function () {
      if (req.status == 200) {
        resolve(req.responseText);
      } else {
        reject(new Error(req.statusText));
      }
    };
    req.send();
  });
}

var urlUsername = 'api/user/username/:id';
var urlAge = 'api/user/age/:id';
var requestArr = [getData(urlUsername), getData(urlAge)]; // 兩個Promise對象組成的數組
Promise.all(requestArr).then(function(data) { // a. data的值是什么呢?
  var username = data[0]; // b
  var age = data[1]; // c
  $('#username').text(username);
  $('#age').text(age);
}).catch(function(err) {
  console.log(err);
});

可以看出,Promise.all的參數是一個數組,這個數組的成員是Promise對象。有點像把多個異步請求放到一個Promise里處理的感覺,這樣做的好處是一來代碼簡潔了一些,更重要的是(a)處的then方法能確保在兩個請求完成后再執行,也就是兩個Promise對象的狀態都變成了Settled

Promise.all 在接收到的所有的對象promise都變為 FulFilled 或者 Rejected 狀態之后才會繼續進行后面的處理

(a)處的data又是什么值呢?username,or age?

顯然從(b)(c)的代碼可以看到data是個數組,對應于requestArr中兩個Promise對象所代表的值(順序也是保持一致的)。

Promise.all處理的多個Promise不是按順序執行的,而是同時、并行執行。也就是說上面的兩個請求是同時、并發進行的,而不是請求了username,再請求age

Promise.race方法

理解了Promise.all,再看Promise.race就簡單了。Promise.race的用法和all一樣,也是接收一個Promise對象組成的數組,resolve后的值(data)格式也是一樣的。不同之處在于:

Promise.race只要有一個promise對象進入 FulFilled 或者 Rejected 狀態的話,就會繼續進行后面的處理

同樣以上面那個例子。如果將all改為race,那么只要有一個promiseSettled,then就會開始執行,而其他promise就不會繼續執行then方法了。同樣then中接收到的data,也不再是數組,而是先Settled的那個Promise對象resolve的值。

race這個意思,也能大致看出它的這個特性。

Deferred和Promise

Deferred譯為延期。它和Promise不同,它沒有共通的規范,換句話說,在ECMAScript內容里有對Promise的定義標準,但是沒有Deferred的定義標準,更通俗的說,你用javascript語言可以直接像用MathDate那樣直接在代碼里去用Promise,但是Deferred是不存在的。它的存在純粹是市面上各種類庫實現出來的。我們自己也可以簡單實現一個Deferred類:

deferred.js

function Deferred() {
    this.promise = new Promise(function (resolve, reject) {
        this._resolve = resolve;
        this._reject = reject;
    }.bind(this));
}
Deferred.prototype.resolve = function (value) {
    this._resolve.call(this.promise, value);
};
Deferred.prototype.reject = function (reason) {
    this._reject.call(this.promise, reason);
};

上面的 Function Deferred就是我們實現的Deferred類。熟悉bindcall原型繼承的話,不難理解上面代碼的含義:新建了一個對象Deferred,它有一個屬性promise, 內部構建了一個Promise實例,把這個實例的一些api方法通過原型繼承給了Deferred,Deferred可以通過調用自己的resolve或reject等方法控制其屬性promise的狀態

為什么要這么麻煩呢?

為了更好的操作Promise

比如,在之前的案例中,Promise什么時候resolvereject,我們通常只能在Promise構造函數中調用。現在用上剛寫的Deferred,會顯得更靈活,我們改造一下getData

var getData = function(url) {
    var deferred = new Deferred();
    var req = new XMLHttpRequest();
    
    req.open('GET', URL, true);
    req.onload = function () {
      if (req.status == 200) {
        deferred.resolve(req.responseText);
      } else {
        deferred.reject(new Error(req.statusText));
      }
    };
    req.send();
    return deferred.promise;
}

var url = 'api/user/username/:id';
getData(url).then(function(data) {
  $('#username').text(data);
}).catch(function(err) {
  console.log(err);
});

我們看到getData里面已經看不到Promise的構造函數了,用defered可以自由調用resolvereject。試想,我們有一個Promise對象,我們不知道它的狀態是否已經Settled,我們可以用Deferred手動resolve一下:

var promise = ... ;// 一個來源于其他地方的promise
var deferred = new Deferred();
deferred.resolve(promise);
return deferred.promise;

返回的deferred.promise狀態是確定的。

所以,其實Deferred并不抽象,若要說抽象,那就是對callbindjavascript 原型繼承的理解還不透徹。很多類庫都有實現Deferred這個東西,只是大家都約定俗成用Deferred這個名字,例如jqueryangular,而且各類庫的實現方式未必相同。

DeferredPromise并不是處于競爭的關系,而是Deferred內涵了Promise,也就包裝了一下。

【個人觀點】:就像Promise也是包裝了一下resovle之后的具體值,附加一些api方法,避開了繁雜的回調,提升了開發體驗和閱讀代碼體驗。這讓我想起了java 8中的Optional,它也是對一個對象的包裝,提供一些api方法,避免了繁雜的非空判斷,提升了開發體驗和代碼的優雅性。

【參考】:

  1. JavaScript Promise迷你書;
  2. ECMAScript 6 入門
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。