Promise 陷阱

144
作者 ziczhu
2016.04.15 10:56* 字数 4332

前言

这不是一篇介绍 Promise 的文章,如果你暂时还不知道 Promise 是什么,可以参考这本非常棒的小书:JavaScript Promise迷你书(中文版)。本文只介绍使用 Promise 时候容易遇到的一些坑和注意事项。

Lesson One: A Promise is a Promise.

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

-- 程蝶衣

承诺 (Promise) 始终都应该是承诺 (Promise),即使落空,也应该是一个失败 (Rejected) 的承诺 (Promise)。

Promise 对象渐渐成为了现代 JavaScript 程序异步接口的标准返回。Promise 相对于 Callback,拥有两个先天的优势:

  • Promise 的值在确定后是不可变的。
  • Promise 确保结果一定是异步的,不会出现 release Zalgo 的问题。

If you have an API which 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, and cause 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,还有很让人期待的 Service Worker 等。然而,这并不是一篇介绍如何使用某某 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.js Callbacks 中也不会有一堆与 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!

欢迎打赏! ( ̄0  ̄)y


收款二维码
日记本