ES6 Promise:模式与反模式

原文:ES6 Promises: Patterns and Anti-Patterns
做者:Bobby Brennanjavascript

当几年前,第一次使用 NodeJS 的时候,对如今被称为“ 回调地狱 ”的写法感到很困扰。幸运的是,如今是 2017 年了,NodeJS 已经采用大量 JavaScript 的最新特性,从 v4 开始已经支持 Promise。html

尽管 Promise 可让代码更加简洁易读,但对于只熟悉回调函数的人来讲,可能对此仍是会有所怀疑。在这里,将列出我在使用Promise 时学到的一些基本模式,以及踩的一些坑。java

注意:在本文中将使用箭头函数 ,若是你还不是很熟悉,其实很简单,建议先读一下使用它们的好处node

模式与最佳实践

使用 Promise

若是使用的是已经支持 Promise 的第三方库,那么使用起来很是简单。只需关心两个函数:then()catch()。例如,有一个客户端 API 包含三个方法,getItem()updateItem(),和deleteItem(),每个方法都返回一个 Promise:es6

Promise.resolve()
  .then(_ => {
    return api.getItem(1)
  })
  .then(item => {
    item.amount++
    return api.updateItem(1, item);
  })
  .then(update => {
    return api.deleteItem(1);
  })
  .catch(e => {
    console.log('error while working on item 1');
  })

每次调用 then() 会在 Promise 链中建立一个新的步骤,若是链中的任何一个地方出现错误,就会触发接下来的 catch()then()catch() 均可以返回一个值或者一个新的 Promise,结果将被传递到 Promise 链的下一个then()web

为了比较,这里使用回调函数来实现相同逻辑:数据库

api.getItem(1, (err, data) => {
  if (err) throw err;
  item.amount++;
  api.updateItem(1, item, (err, update) => {
    if (err) throw err;
    api.deleteItem(1, (err) => {
      if (err) throw err;
    })
  })
})

要注意的第一个区别是,使用回调函数,咱们必须在过程的每一个步骤中进行错误处理,而不是用单个的 catch-all 来处理。回调函数的第二个问题更直观,每一个步骤都要水平缩进,而使用 Promise 的代码则有显而易见的顺序关系。api

回调函数 Promise 化

须要学习的第一个技巧是如何将回调函数转换为 Promise。你可能正在使用仍然基于回调的库,或是本身的旧代码,不过不用担忧,由于只须要几行代码就能够将其包装成一个 Promise。这是将 Node 中的一个回调方法 fs.readFile 转换为 Promise的示例:数组

function readFilePromise(filename) {
  return new Promise((resolve, reject) => {
    fs.readFile(filename, 'utf8', (err, data) => {
      if (err) reject(err);
      else resolve(data);
    })
  })
}

readFilePromise('index.html')
  .then(data => console.log(data))
  .catch(e => console.log(e))

关键部分是 Promise 构造函数,它接收一个函数做为参数,这个函数有两个函数参数:resolvereject。在这个函数里完成全部工做,完成以后,在成功时调用 resolve,若是有错误则调用 rejectpromise

须要注意的是只有一个resolve 或者 reject 被调用,即应该只被调用一次。在咱们的示例中,若是 fs.readFile 返回错误,咱们将错误传递给 reject,不然将文件数据传递给resolve

Promise 的值

ES6 有两个很方便的辅助函数,用于经过普通值建立 Promise:Promise.resolve()Promise.reject()。例如,可能须要在同步处理某些状况时一个返回 Promise 的函数:

function readFilePromise(filename) {
  if (!filename) {
    return Promise.reject(new Error("Filename not specified"));
  }
  if (filename === 'index.html') {
    return Promise.resolve('<h1>Hello!</h1>');
  }
  return new Promise((resolve, reject) => {/*...*/})
}

注意,虽然能够传递任何东西(或者不传递任何值)给 Promise.reject(),可是好的作法是传递一个Error

并行运行

Promise.all是一个并行运行 Promise 数组的方法,也就是说是同时运行。例如,咱们有一个要从磁盘读取文件的列表。使用上面建立的 readFilePromise 函数,将以下所示:

let filenames = ['index.html', 'blog.html', 'terms.html'];

Promise.all(filenames.map(readFilePromise))
  .then(files => {
    console.log('index:', files[0]);
    console.log('blog:', files[1]);
    console.log('terms:', files[2]);
  })

我甚至不会使用传统的回调函数来尝试编写与之等效的代码,那样会很凌乱,并且也容易出错。

串行运行

有时同时运行一堆 Promise 可能会出现问题。好比,若是尝试使用 Promise.all 的 API ​​去检索一堆资源,则可能会在达到速率限制时开始响应429错误

一种解决方案是串行运行 Promise,或一个接一个地运行。可是在 ES6 中没有提供相似 Promise.all 这样的方法(为何?),但咱们可使用 Array.reduce 来实现:

let itemIDs = [1, 2, 3, 4, 5];

itemIDs.reduce((promise, itemID) => {
  return promise.then(_ => api.deleteItem(itemID));
}, Promise.resolve());

在这种状况下,咱们须要等待每次调用 api.deleteItem() 完成以后才能进行下一次调用。这种方法,比为每一个 itemID 写 .then() 更简洁更通用:

Promise.resolve()
  .then(_ => api.deleteItem(1))
  .then(_ => api.deleteItem(2))
  .then(_ => api.deleteItem(3))
  .then(_ => api.deleteItem(4))
  .then(_ => api.deleteItem(5));

Race

ES6 提供的另外一个很方便的函数是 Promise.race。跟 Promise.all 同样,接收一个 Promise 数组,并同时运行它们,但不一样的是,会在一旦任何 Promise 完成或失败的状况下返回,并放弃全部其余的结果。

例如,咱们能够建立一个在几秒钟以后超时的 Promise:

function timeout(ms) {
  return new Promise((resolve, reject) => {
    setTimeout(reject, ms);
  })
}

Promise.race([readFilePromise('index.html'), timeout(1000)])
  .then(data => console.log(data))
  .catch(e => console.log("Timed out after 1 second"))

须要注意的是,其余 Promise 仍将继续运行 ,只是看不到结果而已。

捕获错误

捕获错误最多见的方式是添加一个 .catch() 代码块,这将捕获前面全部 .then() 代码块中的错误 :

Promise.resolve()
  .then(_ => api.getItem(1))
  .then(item => {
    item.amount++;
    return api.updateItem(1, item);
  })
  .catch(e => {
    console.log('failed to get or update item');
  })

在这里,只要有 getItem 或者 updateItem 失败,catch()就会被触发。可是若是咱们想分开处理 getItem 的错误怎么办?只需再插入一个catch() 就能够,它也能够返回另外一个 Promise。

Promise.resolve()
  .then(_ => api.getItem(1))
  .catch(e => api.createItem(1, {amount: 0}))
  .then(item => {
    item.amount++;
    return api.updateItem(1, item);
  })
  .catch(e => {
    console.log('failed to update item');
  })

如今,若是getItem()失败,咱们经过第一个 catch 介入并建立一条新的记录。

抛出错误

应该将 then() 语句中的全部代码视为 try 块内的全部代码。return Promise.reject()throw new Error() 都会致使下一个 catch() 代码块的运行。

这意味着运行时错误也会触发 catch(),因此不要去假设错误的来源。例如,在下面的代码中,咱们可能但愿该 catch() 只能得到 getItem 抛出的错误,可是如示例所示,它还会在咱们的 then() 语句中捕获运行时错误。

api.getItem(1)
  .then(item => {
    delete item.owner;
    console.log(item.owner.name);
  })
  .catch(e => {
    console.log(e); // Cannot read property 'name' of undefined
  })

动态链

有时,咱们想要动态地构建 Promise 链,例如,在知足特定条件时,插入一个额外的步骤。在下面的示例中,在读取给定文件以前,咱们能够选择建立一个锁定文件:

function readFileAndMaybeLock(filename, createLockFile) {
  let promise = Promise.resolve();

  if (createLockFile) {
    promise = promise.then(_ => writeFilePromise(filename + '.lock', ''))
  }

  return promise.then(_ => readFilePromise(filename));
}

必定要经过重写 promise = promise.then(/*...*/) 来更新 Promise 的值。参看接下来反模式中会提到的 屡次调用 then()

反模式

Promise 是一个整洁的抽象,但很容易陷入某些陷阱。如下是我遇到的一些最多见的问题。

重回回调地狱

当我第一次从回调函数转到 Promise 时,发现很难摆脱一些旧习惯,仍像使用回调函数同样嵌套 Promise:

api.getItem(1)
  .then(item => {
    item.amount++;
    api.updateItem(1, item)
      .then(update => {
        api.deleteItem(1)
          .then(deletion => {
            console.log('done!');
          })
      })
  })

这种嵌套是彻底没有必要的。有时一两层嵌套能够帮助组合相关任务,可是最好老是使用 .then() 重写成 Promise 垂直链 。

没有返回

我遇到的一个常常会犯的错误是在一个 Promise 链中忘记 return 语句。你能发现下面的 bug 吗?

api.getItem(1)
  .then(item => {
    item.amount++;
    api.updateItem(1, item);
  })
  .then(update => {
    return api.deleteItem(1);
  })
  .then(deletion => {
    console.log('done!');
  })

由于咱们没有在第4行的 api.updateItem() 前面写 return,因此 then() 代码块会当即 resolove,致使 api.deleteItem() 可能在 api.updateItem() 完成以前就被调用。

在我看来,这是 ES6 Promise 的一个大问题,每每会引起意想不到的行为。问题是, .then() 能够返回一个值,也能够返回一个新的 Promise,undefined 彻底是一个有效的返回值。就我的而言,若是我负责 Promise API,我会在 .then() 返回 undefined 时抛出运行时错误,但如今咱们须要特别注意 return 建立的 Promise。

屡次调用 .then()

根据规范,在同一个 Promise 上屡次调用 then() 是彻底有效的,而且回调将按照其注册顺序被调用。可是,我并未见过须要这样作的场景,而且在使用返回值和错误处理时可能会产生一些意外行为:

let p = Promise.resolve('a');
p.then(_ => 'b');
p.then(result => {
  console.log(result) // 'a'
})

let q = Promise.resolve('a');
q = q.then(_ => 'b');
q = q.then(result => {
  console.log(result) // 'b'
})

在这个例子中,由于咱们在每次调用 then() 不更新 p 的值,因此咱们看不到 'b' 返回。可是每次调用 then() 时更新 q,因此其行为更可预测。

这也适用于错误处理:

let p = Promise.resolve();
p.then(_ => {throw new Error("whoops!")})
p.then(_ => {
  console.log('hello!'); // 'hello!'
})

let q = Promise.resolve();
q = q.then(_ => {throw new Error("whoops!")})
q = q.then(_ => {
  console.log('hello'); // We never reach here
})

在这里,咱们指望的是抛出一个错误来打破 Promise 链,但因为没有更新 p 的值,因此第二个 then() 仍会被调用。

有可能在一个 Promise 上屡次调用 .then() 有不少理由 ,由于它容许将 Promise 分配到几个新的独立的 Promise 中,可是还没发现真实的使用场景。

混合使用回调和 Promise

很容易进入一种陷阱,在使用基于 Promise 库的同时,仍在基于回调的项目中工做。始终避免在 then()catch() 使用回调函数 ,不然 Promise 会吞噬任何后续的错误,将其做为 Promise 链的一部分。例如,如下内容看起来是一个挺合理的方式,使用回调函数来包装一个 Promise:

function getThing(callback) {
  api.getItem(1)
    .then(item => callback(null, item))
    .catch(e => callback(e));
}

getThing(function(err, thing) {
  if (err) throw err;
  console.log(thing);
})

这里的问题是,若是有错误,咱们会收到关于“Unhandled promise rejection”的警告,即便咱们添加了一个 catch() 代码块。这是由于,callback()then()catch() 都会被调用,使之成为 Promise 链的一部分。

若是必须使用回调来包装 Promise,可使用 setTimeout (或者是 NodeJS 中的 process.nextTick)来打破 Promise:

function getThing(callback) {
  api.getItem(1)
    .then(item => setTimeout(_ => callback(null, item)))
    .catch(e => setTimeout(_ => callback(e)));
}

getThing(function(err, thing) {
  if (err) throw err;
  console.log(thing);
})

不捕获错误

JavaScript 中的错误处理有点奇怪。虽然支持熟悉的 try/catch 范例,可是没有办法强制调用者以 Java 的方式处理错误。然而,使用回调函数,使用所谓的“errbacks”,即第一个参数是一个错误回调变得很常见。这迫使调用者至少认可错误的可能性。例如,fs 库:

fs.readFile('index.html', 'utf8', (err, data) => {
  if (err) throw err;
  console.log(data);
})

使用 Promise,又将很容易忘记须要进行错误处理,特别是对于敏感操做(如文件系统和数据库访问)。目前,若是没有捕获到 reject 的 Promise,将在 NodeJS 中看到很是丑的警告:

(node:29916) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): Error: whoops!
(node:29916) DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

确保在主要的事件循环中任何 Promise 链的末尾添加 catch() 以免这种状况。

总结

但愿这是一篇有用的关于常见 Promise 模式和反模式的概述。若是你想了解更多,这里有一些有用的资源:

更多的 Promise 模式反模式

或者阅读来自 DataFire 团队的内容

相关文章
相关标签/搜索