关于promise的一些常见问题

前言

Promise 是 一种 JavaScript 异步操作解决方案,让我们可以使用方便理解的正常的程序流程(同步),来处理异步操作。但新手刚开始使用这一特性的时候,经常会犯一些常见的错误,回顾自己以前的代码我发现有不少错误都是自己曾经犯过的。

我们来看看下面这几种promise写法的区别:

doSomething()
  .then(function () {
  return doSomethingElse();
}).then(finalHandler);;

doSomething()
  .then(function () {
  doSomethingElse();
}).then(finalHandler);;

doSomething()
  .then(doSomethingElse())
  .then(finalHandler);;

doSomething()
  .then(doSomethingElse)
  .then(finalHandler);;

具体的区别我们稍后再细说,如果你觉得四种写法让你感到困惑了的话那么对于 promise 的理解恐怕还需要再加强一下。

新手错误 1: then方法内部仍使用回调或嵌套then

我们可以看看下面的代码:

remotedb.allDocs({
  include_docs: true,
  attachments: true
}).then(function (result) {
  var docs = result.rows;
  docs.forEach(function(element) {
    localdb.put(element.doc).then(function(response) {
      alert("Pulled doc with id " + element.doc._id + " and added to local db.");
    }).catch(function (err) {
      if (err.name == 'conflict') {
        localdb.get(element.doc._id).then(function (resp) {
          localdb.remove(resp._id, resp._rev).then(function (resp) {
// et cetera...

习惯了回调函数的思路在刚接触到 promise 的时候很难去改变,虽然强迫了自己使用 promise,但在第一层 then 方法内部又开始继续使用回调函数或者嵌套的 then 调用,这样的代码非常的不 promisey,在阅读时也和之前的回调地狱一样,完全没有发挥 promise 的优势。我们可以改成下面这样的写法:

remotedb.allDocs(...).then(function (resultOfAllDocs) {
  return localdb.put(...);
}).then(function (resultOfPut) {
  return localdb.get(...);
}).then(function (resultOfGet) {
  return localdb.put(...);
}).catch(function (err) {
  console.log(err);
});

这样子感受就好多了,这种写法被称为 composing promises 。这样可以带来清晰的代码结构,避免始料不及的错误。也可以快速的问题定位,避免难以调试更甚至于失败了而没有任何反馈。稍后我们还会详细说明。

新手错误 2: then方法内使用forEach循环(或for/while循环)进行异步操作

很多人可能会写出下面这样的代码:

// I want to remove() all docs
db.allDocs({include_docs: true}).then(function (result) {
  result.rows.forEach(function (row) {
    db.remove(row.doc);  
  });
}).then(function () {
  // I naively believe all docs have been removed() now!
});

问题出在哪里呢?第一个 then 方法可能没有等到循环内的异步操作结束就返回了一个 undefined,因此第二个 then 方法内并不确定所有的 remove 操作都完成了!这个时候我们就需要使用 Promise.all 了:

db.allDocs({include_docs: true}).then(function (result) {
  return Promise.all(result.rows.map(function (row) {
    return db.remove(row.doc);
  }));
}).then(function (arrayOfResults) {
  // All docs have really been removed() now!
});

为了直观的感受,我们再来手写另一个简单的例子:

// 模拟一个异步的操作
const log = data => {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log(data)
      resolve()
    })
  })
}

// 目标是异步打印数组内的每一个数,都完成后再打印done
new Promise((resolve, reject) => {
  var arr = [1,2,3,4,5]
  resolve(arr)
}).then((arr) => {
  arr.forEach(i => {
    log(i)
  })
}).then(() => {
  console.log('done')
}).catch(err => {
  console.log(err)
})

// 输出 done 1 2 3 4 5
// 结果很明显不是我们想要的

我们使用 Promise.all 来改写:

new Promise(function(resolve) {
  var arr = [1,2,3,4,5]
  resolve(arr)
}).then(function(arr) {
  // 注意这里的两个return,为了演示更直观因此不使用箭头函数了
  // The Promise.all() method returns a single Promise that resolves when all of the promises in the iterable argument have resolved or when the iterable argument contains no promises. It rejects with the reason of the first promise that rejects.
  return Promise.all(arr.map(function(i) {
    return log(i)
  }))
}).then(function() {
  console.log('done')
}).catch(function(err) {
  console.log(err)
})

//输出 1 2 3 4 5 done

Promise.all 方法用于将多个 Promise 实例,包装成一个新的 Promise 实例,它接受一个可遍历的对象作为参数,参数里的元素都是 Promise 对象的实例,参数内所有的都 resolve 以后它也会被 resolve,如果有一个被 reject,那么会同第一个被 reject 的 promise 实例一样被 reject,中文说起来有点绕,我们可以改写一下上面的例子:

const log = data => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log(data)
      if (data === 3 || data === 5) {
        return reject(data + ' is not what I wanted')
      }
      resolve()
    })
  })
}

// 再次运行,结果为:
// 1
// 2
// 3
// 4
// 5
// 3 is not what I wanted

新手错误 3: 没有添加 .catch()

这也是一个很常见的错误,很多人都会自信自己的代码不会抛出错误,或者就是忘记了添加 catch 方法,因此一旦运行出错后往往会给 debug 带来困难。如果实在不想费神去处理错误,其实简单的添加一句代码就够了:

somePromise().then(function () {
  return anotherPromise();
}).then(function () {
  return yetAnotherPromise();
}).catch(console.error.bind(console));
// 可能有的公司不允许线上代码有console,可以视具体情况而定

新手错误 4: 使用 deferred

JavaScript 对于异步处理问题的历史很长,在promise还没有脱颖而出进入规范之前就有过许多的解决方案,jQuery 和 Angular 都使用过 "deferred" 模式,但现在已经被 ES6 的 Promise 规范所替代,因此最好不要再在自己的代码里使用了。更多的内容可以参考这篇文章

另外对于非 promise 化的 API,我们也可以很容易地使用 promise 来封装一层,比如基于回调函数的 nodejs 中的 fs.readFile() :

new Promise(function (resolve, reject) {
  fs.readFile('myfile.txt', function (err, file) {
    if (err) {
      return reject(err);
    }
    resolve(file);
  });
}).then(/* ... */)

新手错误 5: 返回值的困惑

看看下面的代码有什么问题:

somePromise().then(function () {
  someOtherPromise();
}).then(function () {
  // Gee, I hope someOtherPromise() has resolved!
  // Spoiler alert: it hasn't.
});

这是一个非常非常常见的问题,我刚开始完全搞不清楚,究竟哪里要return,哪里不要return,这里似乎不要return也能跑通?

所有的 promise 都有一个 then 方法,then 方法返回的应该是一个新的 promise 实例,这也是为什么我们能够使用链式写法。那在 then 方法内部,不同的返回值对后续链式有什么影响呢?

somePromise().then(function () {
  // I'm inside a then() function!
});

这里我们可以做三件事:

  1. 返回另一个 promise
  2. 返回一个同步的值,或者 undefined
  3. 抛出一个同步的错误 我们来一一分析一下。

1.返回另一个 promise

一个很常见的例子:

getUserByName('nolan').then(function (user) {
  return getUserAccountById(user.id);
}).then(function (userAccount) {
  // I got a user account!
});

注意我们 return 了一个新的 promise,这个 return 非常关键,如果没有的话,在第二个 then 方法内就无法获得user.id,而是获得一个 undefined。

2.返回一个同步的值,或者 undefined

return 一个undefined是一个很常见的错误,但返回一个同步的值可以把同步的代码转换成很 promisey 的代码。还是上面那个例子,我们假设可以从缓存中获取用户id:

getUserByName('nolan').then(function (user) {
  if (inMemoryCache[user.id]) {
    return inMemoryCache[user.id];    // returning a synchronous value!
  }
  return getUserAccountById(user.id); // returning a promise!
}).then(function (userAccount) {
  // I got a user account!
});

你可以看到,第二个 then 方法实际上并不在乎你是使用的异步还是同步的方式来获取的数据。但要小心的是,在js中一个函数如果没有return语句的话会默认return undefined,因此推荐在then方法内总是显式的使用return或者throw语句。

3.抛出一个同步的错误

这和上一条类似,比如我们在用户如果登出后抛出一个错误:

getUserByName('nolan').then(function (user) {
  if (user.isLoggedOut()) {
    throw new Error('user logged out!'); // throwing a synchronous error!
  }
  if (inMemoryCache[user.id]) {
    return inMemoryCache[user.id];       // returning a synchronous value!
  }
  return getUserAccountById(user.id);    // returning a promise!
}).then(function (userAccount) {
  // I got a user account!
}).catch(function (err) {
  // Boo, I got an error!
});

catch 方法会在用户登出或者某个promise被reject时接住错误,它也不会在乎这个错误到底是来自于同步或者异步的操作。这在我们开发时的某些时候还是很有用的,比如我们需要在then方法内解析一个JSON数据,它也许是不规范的,那么抛出的错误也可以被catch方法接住,而不像回调函数那样被吞掉了。

上面这些错误都是一些新手比较常见的,接下来说说一些稍微 "高级" 的问题。

高级问题 1: 不知道使用 Promise.resolve()

之前我们已经学习了promise可以把同步的代码也包装成异步的,那么你可能会经常写出这样的代码:

new Promise(function (resolve, reject) {
  resolve(someSynchronousValue)
}).then(/* ... */)

其实你可以使用更简洁的写法:

Promise.resolve(someSynchronousValue).then(/* ... */)

这样同步代码中的错误也可以被很好的处理,你可以很容易就通过这种方式来把一些同步的API转化成这种promise化的API:

function somePromiseAPI() {
  return Promise.resolve().then(function () {
    doSomethingThatMayThrow();
    return 'foo';
  }).then(/* ... */).catch(/* ... */);
}

同步代码中抛出的错误都可能没有被正确处理,但使用 Promise.resolve() 来封装,总是可以使得错误被 catch() 方法接住。

类似的,Promise.reject() 可以用来返回一个马上被reject的promise。

Promise.reject(new Error('some awful error'));

高级问题 2: catch()其实就是then(null, ...)的语法糖?

somePromise().catch(function (err) {
  // handle error
});

somePromise().then(null, function (err) {
  // handle error
});

上面这两个其实确实是等同的,但是我们来看看下面这样的:

somePromise().then(function () {
  return someOtherPromise();
}).catch(function (err) {
  // handle error
});

somePromise().then(function () {
  return someOtherPromise();
}, function (err) {
  // handle error
})

在我们使用 then(resolveHandler, rejectHandler)这样的格式的时候,rejectHandler 实际上负责的是前一个promise所抛出的错误,而不是由 resolveHandler所抛出的错误。因此,为了避免造成混淆,尽量不要使用then方法的第二个参数,而优先使用catch方法。

somePromise().then(function () {
  throw new Error('oh noes');
}).catch(function (err) {
  // I caught your error! :)
});

somePromise().then(function () {
  throw new Error('oh noes');
}, function (err) {
  // I didn't catch your error! :(
});

除非是在编写一个必须抛出错误的异步 Mocha 测试用例时:

it('should throw an error', function () {
  return doSomethingThatThrows().then(function () {
    throw new Error('I expected an error!');
  }, function (err) {
    should.exist(err);
  });
});

高级问题 3: promises vs promise factories

我们有的时候需要执行一个连续的promise队列,但不像 Promise.all 一样是并行的,你可能会考虑用一个数组来循环迭代执行:

function executeSequentially(promises) {
  var result = Promise.resolve();
  promises.forEach(function (promise) {
    result = result.then(promise);
  });
  return result;
}

但实际上这个循环里的promise依然是并发执行的,因为promise一被创建就开始执行了,因此我们需要的是一个promise工厂函数来在需要的时候才创建promise:

function executeSequentially(promiseFactories) {
  var result = Promise.resolve();
  promiseFactories.forEach(function (promiseFactory) {
    result = result.then(promiseFactory);
  });
  return result;
}

高级问题 4:依赖多个promise的结果

一般来说我们都会依赖于上一个promise的结果,但有时候也可能会有依赖于多个promise结果的需求:

getUserByName('nolan').then(function (user) {
  return getUserAccountById(user.id);
}).then(function (userAccount) {
  // dangit, I need the "user" object too!
});

我们可以把 user 用一个变量在外层作用域存放起来,但这样处理并不是那么优雅。

var user;
getUserByName('nolan').then(function (result) {
  user = result;
  return getUserAccountById(user.id);
}).then(function (userAccount) {
  // okay, I have both the "user" and the "userAccount"
});

或者干脆还是...使用嵌套的 then 方法...

getUserByName('nolan').then(function (user) {
  return getUserAccountById(user.id).then(function (userAccount) {
    // okay, I have both the "user" and the "userAccount"
  });
});

如果嵌套和缩进实在太多,就把函数抽出来放在具名函数里:

function onGetUserAndUserAccount(user, userAccount) {
  return doSomething(user, userAccount);
}

function onGetUser(user) {
  return getUserAccountById(user.id).then(function (userAccount) {
    return onGetUserAndUserAccount(user, userAccount);
  });
}

getUserByName('nolan')
  .then(onGetUser)
  .then(function () {
  // at this point, doSomething() is done, and we are back to indentation 0
});

高级问题 5: promise塌陷

这个问题可能没那么常见,看看下面的代码:

Promise.resolve('foo')
  .then(Promise.resolve('bar'))
  .then(function (result) {
    console.log(result);
  });

结果会是什么呢?它会打印 'foo'。

问题出在第一个then方法上,我们传入的其实并不是一个函数,而是一个已经resolve的对象,实际上上面的代码等价于:

Promise.resolve('foo')
  .then(null)
  .then(function (result) {
    console.log(result);
  });

不过中间有多少个 then(null) ,它都会无视之而输出 foo,then方法应该接受一个函数作为参数才能实现我们想要的:

Promise.resolve('foo').then(function () {
  return Promise.resolve('bar');
}).then(function (result) {
  console.log(result);
});

因此一定要记住,总是给then方法传入一个函数作为参数!

最后

最后看到这里,我相信各位对开头的问题也都有了自己的理解。

Puzzle #1

doSomething().then(function () {
  return doSomethingElse();
}).then(finalHandler);

答案:

doSomething
|-----------------|
                  doSomethingElse(undefined)
                  |------------------|
                                     finalHandler(resultOfDoSomethingElse)
                                     |------------------|

Puzzle #2

doSomething().then(function () {
  doSomethingElse();
}).then(finalHandler);

答案:

doSomething
|-----------------|
                  doSomethingElse(undefined)
                  |------------------|
                  finalHandler(undefined)
                  |------------------|

Puzzle #3

doSomething().then(doSomethingElse())
  .then(finalHandler);

答案:

doSomething
|-----------------|
doSomethingElse(undefined)
|---------------------------------|
                  finalHandler(resultOfDoSomething)
                  |------------------|

Puzzle #4

doSomething().then(doSomethingElse)
  .then(finalHandler);

答案:

doSomething
|-----------------|
                  doSomethingElse(resultOfDoSomething)
                  |------------------|
                                     finalHandler(resultOfDoSomethingElse)
                                     |------------------|

最后可以查看这个JSBin看看效果。

ref: