Promise的坑

16dbe6c15483a64c.png

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

Lesson One: APromise is a Promise.

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

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

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

  • 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!

  // 一個簡單的除法程序
  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 函數。

  // 用着挺好
  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 的形式。

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

當然,如果我們的 Promise 工廠函數依賴了另一個 Promise 對象的結果的時候,也可以直接 return 那個 Promise 對象。

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

很多時候,由於我們的疏忽大意,一些鬆散的邏輯或者意料之外的輸入都會讓我們理想中的 Promise 返回化爲泡影。但如果你把所有邏輯都寫在 Promise 構造器或 Promise 對象的 then/catch 函數中的話,即使一個意外的輸入導致內部拋了錯,也能(絕大部分情況下)返回一個 Rejected 的 Promise,而不是一個未捕獲的錯誤。

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


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!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章