相关文章推荐
睡不着的抽屉  ·  JavaScript实战 - ...·  昨天    · 
帅气的面包  ·  Caused by: ...·  7 月前    · 
一身肌肉的菠萝  ·  AzureAD ...·  11 月前    · 
【译】异步递归:回调、Promise、Async

【译】异步递归:回调、Promise、Async

原文: Asynchronous Recursion with Callbacks, Promises and Async
原作者: @ColinEberhardt
译者:安秦

译者注:

我本人早就已经开始全面使用 async / await 了,在很多场景里,这种方法可以让逻辑变得很简单可靠,递归只是一种场景而已。这篇文章只是从一个简单的问题出发再一次倡议大家拥抱新功能。


创建既异步又递归的函数不是个简单的问题。这篇文章带大家看看都有哪些实现方式,其中包括回调、promise以及最终演示async函数如何以简洁胜出。

一个简单的例子

最近我在实现一个GitHub机器人,需要从一个API端点获取分页的数据。我的机器人需要获取大约1000条数据,于是就需要做10次异步操作来获取整个数据集。我探索了各种方法解决这个问题,在本文中陆续讲解。

为了文章效果,与其使用一个外部的API,我会用一个假想的例子来描绘相同的问题:

const getSentenceFragment = (offset = 0) => {
  const pageSize = 3;
  const sentence = [...'hello world'];
  return {
    data: sentence.slice(offset, offset + pageSize),
    nextPage: offset +
        pageSize < sentence.length ? offset + pageSize : undefined

这个函数将“hello world”打散成单独的字符,然后3个为一页返回,如此,获取整句话就需要4次调用:

> getSentenceFragment()
{ data: ['h', 'e', 'l'], nextPage: 3 }
> getSentenceFragment(3)
{ data: ['l', 'o', ' '], nextPage: 6 }
> getSentenceFragment(6)
{ data: ['w', 'o', 'r'], nextPage: 9 }
> getSentenceFragment(9)
{ data: ['l', 'd'], nextPage: undefined }

注意:以上代码用到了 参数默认值 箭头函数 扩展运算符 。我是在Chrome里运行这些代码片段的,因为Chrome有对这些语法的完整支持。对于其他运行环境,你可能需要先转译成ES5。

这篇文章后续都会关注一个简单的问题:怎么建一个 getSentence 函数用于获取整个句子。

文章接下来的两个小节会先讲述不考虑异步情况下的迭代法和递归法。如果你对递归已经很熟悉,可以考虑跳过去。

迭代法

下面的函数实现实用迭代来获取整个句子:

const getSentence = () => {
  let offset = 0,
    aggregateData = [];
  while (true) {
    const fragment = getSentenceFragment(offset);
    aggregateData = aggregateData.concat(fragment.data);
    if (fragment.nextPage) {
      offset = fragment.nextPage;
    } else {
      break;
  return aggregateData;

使用效果如下:

> getSentence()
["h", "e", "l", "l", "o", " ", "w", "o", "r", "l", "d"]

这种方法没啥可多说的,就是重复调用 getSentenceFragment 函数,将结果收集到 aggregateData 变量里,记录当前的偏移位置,当 fragment.nextPage 是 undefined 时,循环结束。就这么简单。

那么递归的方式长啥样?

递归方式

用递归解决这个问题可以看起来更简洁:

const getSentence = (offset = 0) => {
  const fragment = getSentenceFragment(offset);
  if (fragment.nextPage) {
    return fragment.data.concat(getSentence(fragment.nextPage));
  } else {
    return fragment.data;

使用效果:

> getSentence()
["h", "e", "l", "l", "o", " ", "w", "o", "r", "l", "d"]

这个版本的代码解构更加简单,不需要任何变量来维持当前的遍历状态。使用递归函数,调用栈会替你保存这些信息。

使用Chrome调试工具,在递归终结的条件分支里打断点,你可以看到这个函数调用了4次。你可以上下切换浏览查看 fragment 和 offset 在每一次调用的作用域里是什么值:

当终结条件达成,所有的函数调用依次返回,上弦的调用栈松开的过程中,最终结果会被构建起来返回给最初调用的地方。

现在,让我们看看引入异步性之后会发生什么。

通过回调函数进行异步递归

我们来观测的第一个方案比较老套,使用回调函数。第一步首先改变 getSentenceFragment 函数,让它以异步的方式返回结果。

只要简单地用一下 setTimeout,我们就可以如下升级 getSentenceFragment:

const getSentenceFragment = (offset, callback) => {
  const pageSize = 3;
  const sentence = [...'hello world'];
  setTimeout(() => callback({
    data: sentence.slice(offset, offset + pageSize),
    nextPage: offset +
        pageSize < sentence.length ? offset + pageSize : undefined
  }), 500);

这个函数调用时会请求一个句子片段,提供一个回调函数,在未来的某个时间点会带着结果被调用:

> getSentenceFragment(0, (data) => console.log(data)))
{ data: ['h', 'e', 'l'], nextPage: 3 }

注意:回调方案一个不幸的副作用是我们无法有效地给 offset 设定一个默认值。

那么我们如何适应变化,创建一个函数来将所有的片段集合起来并返回完整的句子?我们一步一步来拼凑。

首先是函数签名。不再是同步返回结果,我们需要个回调:

const getSentence = (offset, callback) => {

然后我们获取一个片段,这现在也变成使用回调了:

const getSentence = (offset, callback) => {
  getSentenceFragment(offset, (fragment) => {

然后,就是判读是否有下一页。我们先考虑更简单的哪一侧,就是递归结束的那种情况。

同步版的代码只是简单的将片段返回,异步版通过回调做同样的事情:

const getSentence = (offset, callback) => {
  getSentenceFragment(offset, (fragment) => {
    if (fragment.nextPage) {
    } else {
      callback(fragment.data)

现在考虑另一个分支,获取下一页,也就是发生递归的地方。这里我们要调用 getSentence,然而它现在是异步返回的了,所以我们需要再实现一个回调函数,这里也是数组拼接发生的地方:

const getSentence = (offset, callback) => {
  getSentenceFragment(offset, (fragment) => {
    if (fragment.nextPage) {
      // 递归调用 getSentence
      getSentence(fragment.nextPage, (nextFragment) => {
        callback(fragment.data.concat(nextFragment))
    } else {
      callback(fragment.data)

调用后,它会返回预期中的结果(几秒钟之后):

> getSentence(0, (sentence) => console.log(sentence));
["h", "e", "l", "l", "o", " ", "w", "o", "r", "l", "d"]

还记得同步版本,你可以在调用栈里看到递归调用吗(在终止条件里加断点)?异步版也差不多:

不过你无法查看前一次调用的作用域的详情(除非有某种时间穿梭的功能)。

使用回调实现的递归版 getSentence 函数,跟踪他的调用过程并不简单,内嵌的回调逻辑制造了更多的函数与作用域。

还是来看看基于Promise的实现方法相较如何……

用 Promise 实现异步递归

再一次,先改变 getSentenceFragment 函数:

const getSentenceFragment = (offset = 0) => new Promise((resolve, reject) => {
  const pageSize = 3;
  const sentence = [...'hello world'];
  setTimeout(() => resolve({
    data: sentence.slice(offset, offset + pageSize),
    nextPage: offset + pageSize < sentence.length ? offset + pageSize : undefined
  }), 500);

返回的 Promise 对象表示一个会异步完成的任务或操作。调用这函数,你可以看到这个对象:

> getSentenceFragment()
    .then((fragment) => console.log(fragment));
  Promise {[[PromiseStatus]]: "pending", [[PromiseValue]]: undefined}
  { data: ['h', 'e', 'l'], nextPage: 3 }

注意:再一次,我们的参数默认值回来了!

于是让我们再一次一步步构建起 getSentence 函数。第一步是调用 getSentenceFragment 函数来获取一个片段:

const getSentence = (offset = 0) =>
  getSentenceFragment(offset)
    .then(fragment => {

因为我们用的 Promise,所以 getSentenceFragment 的结果是通过 then 来获取的。

Promise 的一个有趣的方面是你可以将他们链起来。每一次 then 调用都会返回一个新的 Promise 对象。简单例子如下:

> getSentenceFragment()
    .then((fragment) => fragment.data)
    .then((letters) => console.log(letters));
  Promise {[[PromiseStatus]]: "pending", [[PromiseValue]]: undefined}
  ['h', 'e', 'l']

如果一个 then 处理函数返回的值不是一个 Promise,它会通过 Promise.resolve(value) 来转换成 Promise。

回到我们的 getSentence 实现,调用 getSentenceFragment 返回一个值给 then 处理函数,该函数返回的值又会是一个 Promise 最终成为 getSentence 的返回值。返回的内容现在还省略在 ... 中,现在来实现它们。

一次性全做完:

const getSentence = (offset = 0) =>
  getSentenceFragment(offset)
    .then(fragment => {
      if (fragment.nextPage) {
        return getSentence(fragment.nextPage)
            .then(nextFragment => fragment.data.concat(nextFragment))
      } else {
        return fragment.data;

最终运行完整的例子:

> getSentence().then((sentence) => console.log(sentence));
["h", "e", "l", "l", "o", " ", "w", "o", "r", "l", "d"]

跟回调实现类似,在终止条件加断点查看异步调用栈。

对比 Promise 版本和回调版本,代码逻辑更容易跟踪。不过跟同步版本比起来还是复杂很多。

用 async / await 实现异步递归

出现 async 函数的意义在于简化 Promise 的用法。如之前所见,吧一个简单的函数由同步改成异步会对代码逻辑复杂度产生巨大影响,我们的递归实现,回调与 Promise 看起来都很乱。

在一头扎进递归之前,先看看怎么将 getSentenceFragment 转换成 async 函数……

原来的版本:

const getSentenceFragment = (offset = 0) => {
  const pageSize = 3;
  const sentenceCharArray = [...'hello world'];
  return {
    data: sentenceCharArray.slice(offset, offset + pageSize),
    nextPage: offset + pageSize < sentenceCharArray.length ? offset + pageSize : undefined

不管是回调还是 Promise版本,结构都会需要做很多结构上的调整,然而我们只是想要我们的函数在返回之前“稍等一下”。

我们来创建一个 wait 函数——一个在指定时间之后完成的 Promise:

const wait = ms => new Promise((resolve) => setTimeout(resolve, ms));

然后将 getSentenceFragment 的签名改为 async 函数,大事可图:

const getSentenceFragment = async (offset = 0) => {
  const pageSize = 3;
  const sentence = [...'hello world'];
  await wait(500);
  return {
    data: sentence.slice(offset, offset + 3),
    nextPage: offset + 3 < sentence.length ? offset + 3 : undefined

这个 getSentenceFragment 函数会在遇到 await 的时候暂停,等待这个 Promise 有结果了再继续。简单、优雅。这个函数跟同步版本看起来几乎是一样的。

因为 async 函数返回的是一个 Promise,上面这个 getSentenceFragment 实现完全可以跟我们 Promise 版本的 getSentence 函数一起使用。

不过现在来看看 async 版本的 getSentence。再一次,先看看原来的同步版本:

const getSentence = (offset = 0) => {
  const fragment = getSentenceFragment(offset);
  if (fragment.nextPage) {
    return fragment.data.concat(getSentence(fragment.nextPage));
  } else {
    return fragment.data;

现在,getSentenceFragment 已经是异步的了,那么只要把 getSentence 加上 async 和 await 关键字,完事!

const getSentence = async (offset = 0) => {
  const fragment = await getSentenceFragment(offset)
  if (fragment.nextPage) {
    return fragment.data.concat(await getSentence(fragment.nextPage));
  } else {
    return fragment.data;

切记,你只能在一个标记 async 的函数里使用 await,调用这个函数,你任然需要用 Promise 的用法:

> getSentence().then((sentence) => console.log(sentence));
["h", "e", "l", "l", "o", " ", "w", "o", "r", "l", "d"]

真的很简单!

结论

我们来快速地回顾一下4种实现。

同步:

const getSentence = (offset = 0) => {
  const fragment = getSentenceFragment(offset);
  if (fragment.nextPage) {
    return fragment.data.concat(getSentence(fragment.nextPage));
  } else {
    return fragment.data;

异步回调:

const getSentence = (offset, callback) => {
  getSentenceFragment(offset, (fragment) => {
    if (fragment.nextPage) {
      getSentence(fragment.nextPage, (nextFragment) => {
        callback(fragment.data.concat(nextFragment))
    } else {
      callback(fragment.data)

异步 Promise:

const getSentence = (offset = 0) =>
  getSentenceFragment(offset)
    .then(fragment => {
      if (fragment.nextPage) {
        return getSentence(fragment.nextPage)
            .then(nextFragment => fragment.data.concat(nextFragment))
      } else {
        return fragment.data;

异步 async:

const getSentence = async (offset = 0) => {
  const fragment = await getSentenceFragment(offset)
  if (fragment.nextPage) {