聊一聊Promise的坑

16dbe6c15483a64c.png

這不是一篇介紹Promise的文章,若是你還不太瞭解Promise,能夠先看下我以前的 關於Promise以及你可能不知道的6件事,以爲寫得還能夠的但願能動動小手點個贊,謝謝啦(*^▽^*)

Lesson One: APromise is a Promise.

不行!說的是一輩子!差一年、一個月、一天、一個時辰...都不算一輩子!-- 程蝶衣

承諾(Promise)始終應該是承諾(Promise),即便落空,也應該是一個失敗(Rejected)的承諾(Promise);node

Promise 對象漸漸成爲了現代 JavaScript 程序異步接口的標準返回。Promise 相對於 Callback,擁有兩個先天的優點:git

  • Promise 的值在肯定後是不可變的。
  • Promise 確保結果必定是異步的,不會出現 releaseZalgo 的問題。
If you have an APIwhich takes a callback,
and sometimes that callback is called immediately,
and other times that callback is called at some point in the future,
then you will render any code using this API impossible to reason about, andcause the release of Zalgo.

咱們重點來看第二點,一樣也是Callback的一個重大缺點,就是結果太不可控了,除非咱們百分之百肯定這個接口是異步的,不然有可能出現上文所說的狀況,這個接口一下子是異步的(第一次網絡請求),一下子是同步的(直接返回本地Cache),並且更糟糕的是,若是這個做者仇視社會的話,沒準還會調用好幾回回調,而這些都是你無法控制的(┑( ̄Д ̄)┍攤手)。而這些 Callback 的缺點一樣是 Promise 的賣點,但你覺得用了 Promise 就大功告成了嘛: No!github

// 一個簡單的除法程序
  function divide(numerator, denominator) {

    if (typeof numerator !== 'number' || typeof denominator !== 'number' ) {
      throw new TypeError('Must be number!');
    }
  
    if (denominator === 0) {
      throw new Error("Cannot divide by 0!");
    }
  
    return new Promise((resolve, reject) => {
      resolve(numerator / denominator);
   });
  }

好了,一個還算嚴謹的除法程序(原諒我用Promise實現),作了類型校驗,還作了被除數非 0 的校驗,給你 3 秒鐘說一下這程序有什麼問題,3...2...等不及了,這個程序最大的問題在於,雖然用 Promise不像回調那樣會很明顯的把異步和同步返回混淆,但一不當心,咱們把校驗的邏輯寫成了同步的。這時候若是一味天真的少年用了咱們這個「強大」的 Promise 函數。npm

// 用着挺好
  divide(3, 1)
    .then(res => console.log(`Get: ${res}`))
    .catch(err => console.log(`Failed: ${err}`)) 
  
  > Get: 3

  // 測試下錯誤狀況
  divide(3, 0)
    .then(res => console.log(`Get: ${res}`))
    .catch(err => console.log(`Failed: ${err}`))
  
  > Error: Cannot divide by 0!
      at divide
      ...
      ...
      
咦,怎麼拋錯了,不是都寫了 catch 了嗎,少年心灰意冷地看了一下源碼,「MD,智障,我來改一下吧」,咱們的實現被深深鄙視了一番。


  // 一個簡單的除法程序改進版
  function divide(numerator, denominator) {
  
    return new Promise((resolve, reject) => {
      if (typeof numerator !== 'number' || typeof denominator !== 'number' ) {
        reject(new TypeError('Must be number!'));
      }
  
      if (denominator === 0) {
        reject(new Error("Cannot divide by 0!"));
      }
  
      resolve(numerator / denominator);
   });
  }

Tips 編程

當咱們本身着手設計一個返回 Promise 對象的函數時,請儘可能都採用當即返回 new Promise 的形式。segmentfault

function promiseFactory() {
    return new Promise((resolve, reject) => {
      ...
      ...
    });
  }

固然,若是咱們的 Promise 工廠函數依賴了另外一個 Promise 對象的結果的時候,也能夠直接 return 那個 Promise 對象。promise

function promiseFactory3() {
    return promiseFactory1()
      .then(promiseFactory2);
  }

不少時候,因爲咱們的疏忽大意,一些鬆散的邏輯或者意料以外的輸入都會讓咱們理想中的 Promise 返回化爲泡影。但若是你把全部邏輯都寫在 Promise 構造器或 Promise 對象的 then/catch 函數中的話,即便一個意外的輸入致使內部拋了錯,也能(絕大部分狀況下)返回一個 Rejected 的 Promise,而不是一個未捕獲的錯誤。網絡

因此,即便用了 Promise,也可能致使 release Zalgo 的發生,因此請你在下次寫完一個 Promise 返回的函數的時候,再仔細瞅瞅,它必定會返回一個 Promise 嗎?(說好的一生呢,混蛋( ̄ε(# ̄));app


Lesson Two: Reject or Throw?

她習慣向左走,他習慣向右走,他們始終未曾相遇。-- 幾米

固然,咱們是在討論使用 Promise 構造器的用法,你在 then 裏面都沒 reject 呢。咱們在前一章說過,始終在Promise 構造器中書寫邏輯的話,即便出現了意外的輸入,也能絕大部分狀況下返回一個Rejected 的 Promise,好了,本章討論的就是其餘狀況,坦誠說,這一點也很多見。異步

仍是以上一個除法程序爲例。

// 一個簡單的除法程序 throw 版
  function divide(numerator, denominator) {
  
    return new Promise((resolve, reject) => {
      if (typeof numerator !== 'number' || typeof denominator !== 'number' ) {
        throw new TypeError('Must be number!');
      }
  
      if(denominator === 0) {
        throw new Error("Cannot divide by 0!");
      }
  
      resolve(numerator / denominator);
   });
  }

效果和以前是如出一轍的,並且 throw 的用法看起來還更常見,但 reject 和 throw 有一個本質的不一樣!reject是回調,而throw只是一個同步的語句,若是在另外一個異步的上下文中拋出,在當前上下文中是沒法捕獲到的。例以下面的代碼,咱們用 setTimeout 模擬一個異步的拋錯。

// 一個簡單的除法程序異步 throw 版
  function divide(numerator, denominator) {
  
    return new Promise((resolve, reject) => {
      if (typeof numerator !== 'number' || typeof denominator !== 'number' ) {
        setTimeout(function() {
        throw new TypeError('Must be number!');
       }, 0);
      }
  
      if(denominator === 0) {
        throw new Error("Cannot divide by 0!");
      }
  
      resolve(numerator / denominator);
    });
  }

  divide('asd', 'asd')
    .then(res => console.log(`Get: ${res}`))
    .catch(err => console.log(`Failed: ${err}`))
  
  > Get: NaN
  > TypeError: Must be number!
    at ...

果真,這個錯誤沒有被 Promise捕捉到,還致使了另一個問題,咱們成功經過了校驗,返回了 NaN,這些都不是咱們想要的結果。

固然一般你也不會寫這樣的代碼,但咱們仍是有那麼多的 callback-style 的 API 啊。一不注意就可能寫成下面那樣。

// 檢查文件內容 throw 版
  function checkFileContent(file, str) {
    return new Promise((resolve, reject) => {
      fs.readFile(file, 'utf8', (err, data) => { 
        if (err) {
          throw err;
        } 
      
        if (!~data.indexOf(str)) {
          throw new Error(`No such content: ${str}`);
        }
      
        resolve(true);
      })
    });  
  }

  checkFileContent('test.js', 'Promise')
    .then(res => console.log(`Get: ${res}`))
    .catch(err => console.log(`Failed: ${err}`))

  > Get: true

  checkFileContent('test.js', 'xxx')
    .then(res => console.log(`Get: ${res}`))
    .catch(err => console.log(`Failed: ${err}`))

  > Error: No such content: xxx
    at ...

很不幸,這個函數除非徹底知足咱們的預期(包含某些內容的文件),其他狀況都會拋出一個咱們沒法 catch 到的錯誤,更不幸的是,這樣的錯誤也沒法用try/catch捕捉到,你要不當心寫了這樣的程序,而且只測試了經過的狀況,頗有可能忽然的一天,你的程序就崩潰了。那時,你的心裏是否是也要崩潰了呢。

固然,這種異步 throw 的做法在某些狀況下也是頗有用的,能夠防止未知的錯誤被 Promise 吞掉,形成程序 Debug 的困難。例如 Q 中的 done 函數,就是相似下面的實現。

Promise.prototype.done = function() {
    return this.catch(function(e) {
      setTimeout(function() {
        throw e;
      }, 0);
    }); 
  };

Tips

在 Promise 構造器中,除非你明確知道使用 throw 的正確姿式,不然都請使用 reject。

// 檢查文件內容 reject 版
  function checkFileContent(file, str) {
    return new Promise((resolve, reject) => {
      fs.readFile(file, 'utf8', (err, data) => { 
        if (err) {
          reject(err);
        } 
      
        if (!~data.indexOf(str)) {
          reject(new Error(`No such content: ${str}`));
        }
      
        resolve(true);
      })
    });  
  }

另外,在異步回調函數中,除了咱們本身寫的throw語句以外,任何其餘緣由形成的錯誤都會致使拋出咱們沒法捕捉到的異常。例如JSON解析,因此,在異步回調中請千萬注意,不要出現意料以外的錯誤拋出,全部可能的錯誤都請用 reject 明確拒絕。

// 檢查文件內容 reject 版 + JSON
  function checkFileContent(file, str) {
    return new Promise((resolve, reject) => {
      fs.readFile(file, 'utf8', (err, data) => { 
        if (err) {
          reject(err);
        } 
      
        if (!~data.indexOf(str)) {
          reject(new Error(`No such content: ${str}`));
        }
        
        try {
          JSON.parse(data);
        } catch (e) {
          reject(e);
        }   
        
        resolve(true);
      })
    });  
  }

Lesson Three: Early Return

你見,或者不見我,我就在那裡。不悲不喜。 -- 倉央嘉措

以前說過 Promise 的一大優勢,就是結果不變性,一旦 Promise 的值肯定爲 fulfilled 或者 rejected 後,不管過多久,獲取到的 Promise 對象的值都是同樣的。

// 一個簡單的除法程序改進版
  function divide(numerator, denominator) {
  
    return new Promise((resolve, reject) => {
      if (typeof numerator !== 'number' || typeof denominator !== 'number' ) {
        reject(new TypeError('Must be number!'));
      }
      console.log('After validating type...');
      if (denominator === 0) {
        reject(new Error("Cannot divide by 0!"));
      }
     console.log('After validating non-zero denominator...');
      resolve(numerator / denominator);
   });
  }

如上圖所示,咱們在原有程序的基礎上增長了一些日誌來查看 Promise 內部的執行狀態。

divide(3, 1)
    .then(res => console.log(`Get: ${res}`))
    .catch(err => console.log(`Failed: ${err}`))
    
  > After validating type...
  > After validating non-zero denominator...
  > Get: 3
  // 結果看起來很不錯。再來測試個錯誤輸入。
  
  divide(3, 0)
    .then(res => console.log(`Get: ${res}`))
    .catch(err => console.log(`Failed: ${err}`))
    
  > After validating type...
  > After validating non-zero denominator...
  > Failed: Error: Cannot divide by 0!
  // !!! 怎麼回事

忽然感到這世界森森的惡意,不是說Promise肯定後不變嘛,怎麼都reject還接着走。咳咳,少年,不要驚慌,咱們說的是Promise肯定後不變,不表明reject以後函數就不執行了啊,大家年輕人啊,仍是 too young too simple,蛤蛤。

在 JavaScript 函數中,只有return/yield/throw會中斷函數的執行,其餘的都沒法阻止其運行到結束的,這也是所謂的 Run-to-completion 特性。

像 resolve/reject 不過只是一個回調而已,而所謂的不變性只是說,當遇到第一個 resolve/reject 後,便根據其結果給此Promise打上了一個tag,而且不能更改,然後面的該幹啥繼續幹,不幹本 Promise 的事兒了。

Tips

解決這個問題的方法也很簡單,就是在 resolve/reject 以前加上 return 便可,跟咱們日常函數中的用法同樣,固然了,由於這自己就是一個普通的函數嘛。

// 一個簡單的除法程序改進版 提早 return
  function divide(numerator, denominator) {
  
    return new Promise((resolve, reject) => {
      if (typeof numerator !== 'number' || typeof denominator !== 'number' ) {
        return reject(new TypeError('Must be number!'));
      }
      console.log('After validating type...');
      if (denominator === 0) {
        return reject(new Error("Cannot divide by 0!"));
      }
     console.log('After validating non-zero denominator...');
      return resolve(numerator / denominator);
      
      // 隨便你怎麼弄 反正不會執行到我!
      for (var i = 0, j = 10000; i < j; i++) {
        doSomething(i);
      }
   });
  }

對於這段代碼來講,執行後續代碼的後果是打印出多餘的日誌,實際狀況確定比這複雜得多,好比某個異步調用或者網絡請求,甚至是一個CPU密集型的循環操做,我相信全部這些都不是你想要的,因此請你在resolve/reject語句前面加上return,除非你真的想把後續的代碼一直運行到結束。


Lession Four: Back to Callback

妳相信壹切都永不會改變。然後妳離開了,壹年,兩年,當妳回來時,壹切都變了。-- 天堂電影院
現代 Web 的不少新穎的 API 都已經採用了 Promise 做爲返回,例如你們都很熟悉的 Fetch,還有很讓人期待的 ServiceWorker 等。然而,這並非一篇介紹如何使用某某 API 的說明書,而是談另一個問題,在 Promise 和 Callback 同時存在的宇宙上,如何寫出一個同時坐擁二者的異步 API。

由於在 Node.js 中,全部的原生異步 API 基本都是採用了 Error-first callbacks,甚至能夠被簡稱了 Node-style 了,例以下面很簡單的一個讀取文件的例子:

fs.readFile('/foo.txt', function(err, data) {
    if (err) return;
    console.log(data);
  });

好了,咱們試着簡單包裝一下。若是第二個參數傳入了函數,就直接調用原生的readFile。不然,返回一個 Promise。

function readFile2(filename, cb) {
    if (typeof cb === 'function') {
      return fs.readFile(filename, cb);
    }
    return new Promise((resolve, reject) => {
      fs.readFile(filename, function(err, data) {
        if (err) return reject(err);
        resolve(data);
      });
    });
  }

好了,咱們成功寫了一個既能使用 Promise 又能使用 Callback 的函數,這樣,不管使用咱們庫的用戶想要什麼 Style 都能一一知足。固然,實際狀況比這複雜得多,還得考慮多個參數等的狀況,不然 Q: Interfacing with Node.jsCallbacks 中也不會有一堆與 Node-style 交互的函數了。

上面是對原生 API 封裝的狀況,此外,愈來愈多經常使用的三方庫都支持直接返回一個 Promise 對象,例如 mongoose,這時,若是咱們要包裝一個同時支持二者的 API 就變得簡單了。咱們能夠利用 Promise 的鏈式特性,直接在 Promise 的結尾添加相關邏輯,而無需在中間步驟中反覆調用 callback(null, data) 或者 callback(err, null)(這不只僅是麻煩的問題,還會由於邏輯不嚴謹致使 callback 調用屢次的問題,你看,這又是 Promise 的優勢,下降你犯錯的機率)。

// 還記得大明湖畔的除法程序嘛
  function divide(numerator, denominator) {
  
    return new Promise((resolve, reject) => {
      if (typeof numerator !== 'number' || typeof denominator !== 'number' ) {
        return reject(new TypeError('Must be number!'));
      }
      
      if (denominator === 0) {
        return reject(new Error("Cannot divide by 0!"));
      }
     
      return resolve(numerator / denominator);
   });
  }

讓咱們嘗試添加 Callback 支持。

// 除法二代目,能夠支持 Callback 了
  function divide2(numerator, denominator, callback) {
    var promise = divide(numerator, denominator);
    if (typeof callback === 'function') {
      promise.then(res => {
        callback(null, res);
      }, err => {
        callback(err, null);
      });
    } else {
      return promise;
    }
  }

So easy, 不但這樣,並且咱們能夠很容易抽象一個函數,對於那些非可變參數的 Promise 工廠函數添加 Callback 返回。實際上,有不少庫都寫了這樣一個函數,我在 NPM 上搜了一圈,找到了一個下載量特別大的,確定靠譜,promise-nodify,嘖嘖。

promise-nodify image

var nodify = require('promise-nodify');

  function divide(numerator, denominator) {
  
    return new Promise((resolve, reject) => {
      if (typeof numerator !== 'number' || typeof denominator !== 'number' ) {
        return reject(new TypeError('Must be number!'));
      }
      
      if (denominator === 0) {
        return reject(new Error("Cannot divide by 0!"));
      }
     
      return resolve(numerator / denominator);
   });
  }
  
  
  // 擁抱 promise-nodify 的三代目
  function divide3(numerator, denominator, callback) {
  
    var promise = divide(numerator, denominator);
   
    if (typeof callback === 'function') {
      return nodify(promise, callback);
    } else {
      return promise;
    }
  }

讓咱們測試一下:

divide3(3, 1, (err, data) => {
      console.log(err, data);
    });
    > null 3
    
    divide3(3, 0, (err, data) => {
      console.log(err, data);
    });
    > [Error: Cannot divide by 0!] null
    
    divide3("3", 1, (err, data) => {
      console.log(err, data);
    });
    > [TypeError: Must be number!] null

完美經過,今後,Promise 和 Callback 手牽手肩並肩,過上了幸福的二人世界。

Happy Ending.

...
...
...

然而,有那麼一天,咱們不當心在用 divide3 的時候,手一抖,寫錯了個字。

divide3(3, 1, (err, data) => {
    consale.log(err, data); // 把 console 寫錯了
  });
  
  >

你沒有看錯,什麼都沒有,編程中最怕的不是報錯,而是不報錯,若是在你龐大的代碼塊中有這麼一個地方,默默地出現了異常,又默默地消失,不留痕跡,這樣太恐怖了。

這一切都是爲何,相信你也猜到了,由於 Promise。

來看看 promise-nodify 的源代碼。(讓我想到了leftPad 事件)

module.exports = function nodify(promise, callback) {
    if (typeof callback === "function") {
      promise.then(function (resp) {
        callback(null, resp);
      }, function (err) {
        callback(err, null);
      });
    }
  };

那咱們的異常是從在哪兒被吞沒的呢?

module.exports = function nodify(promise, callback) {
    if (typeof callback === "function") {
      promise.then(function (resp) {
        callback(null, resp); ==》 這句話拋了異常,然而被這個 promise 吞沒了。
      }, function (err) {
        callback(err, null);
      });
    }
  };

相信你們都明白了緣由,再看看這個模塊的下載量,不得不爲這些用戶擔心啊 ╮(╯◇╰)╭。

知道了緣由,讓咱們試着改一下,就用前面所說的使用 setTimeout 在 Promise 鏈的結尾異步拋錯。

module.exports = function nodify2(promise, callback) {
    if (typeof callback === "function") {
      promise.then(function (resp) {
        callback(null, resp);
      }, function (err) {
        callback(err, null);
      }).catch(function(err) {
        setTimeout(function() {
          throw err;
        });
      });
    }
  };
  
  divide3(3, 1, (err, data) => {
    consale.log(err, data);
  });
  
  > throw err;
    ...
    ReferenceError: consale is not defined
    ...

終於成功發現了 consale 的拼寫錯誤,媽媽不再擔憂咱們出現 typo 了。

Tips

可以兼容 Promise 和 Callback 確實是件很棒的事情,用第三方代碼前請儘可能理解其原理,短小的話徹底能夠本身寫一個。Promise 雖好,可不要亂用哦,實時牢記它會吞沒錯誤的風險。

另外,上面那種實現也是有問題的,仔細看你就會發現,它會使得錯誤棧多了一層。更好的方法以下:

// 下面使用了 process.nextTick,除此以外,還能夠用 setImmediate。具體區別,不贅述了。
  module.exports = function nodify3(promise, callback) {
    if (typeof callback === "function") {
      promise.then(function (resp) {
        process.nextTick(callback.bind(null, null, res));
      }, function (err) {
        process.nextTick(callback.bind(null, err, null));
      });
    }
  };

最後

但願你看完以後可以繼續喜好並使用 Promise,若是我遇到過的問題可以幫助你的話,那就更好了,Good Luck!

相關文章
相關標籤/搜索