如何解决递归异步承诺?

5

我正在尝试使用 Promise,并且在处理异步递归 Promise 时遇到了问题。

场景是,一名运动员开始跑100米赛跑,我需要定期检查他们是否已经完成比赛,一旦完成比赛,就打印出他们的时间。

编辑以澄清

在现实世界中,运动员正在服务器上运行。 startRunning 涉及向服务器发出 ajax 请求。 checkIsFinished 也涉及向服务器发出 ajax 请求。下面的代码是模仿这个过程的一个尝试。为了保持简单,代码中的时间和距离都是硬编码的。对于没有表述清楚的部分表示歉意。

结束编辑

我想要编写以下代码:

startRunning()
  .then(checkIsFinished)
  .then(printTime)
  .catch(handleError)

在哪里

var intervalID;
var startRunning = function () {
  var athlete = {
    timeTaken: 0,
    distanceTravelled: 0
  };
  var updateAthlete = function () {
    athlete.distanceTravelled += 25;
    athlete.timeTaken += 2.5;
    console.log("updated athlete", athlete)
  }

  intervalID = setInterval(updateAthlete, 2500);

  return new Promise(function (resolve, reject) {
    setTimeout(resolve.bind(null, athlete), 2000);
  })
};

var checkIsFinished = function (athlete) {
  return new Promise(function (resolve, reject) {
    if (athlete.distanceTravelled >= 100) {
      clearInterval(intervalID);
      console.log("finished");
      resolve(athlete);

    } else {
      console.log("not finished yet, check again in a bit");
      setTimeout(checkIsFinished.bind(null, athlete), 1000);
    }    
  });
};

var printTime = function (athlete) {
  console.log('printing time', athlete.timeTaken);
};

var handleError = function (e) { console.log(e); };

我看到第一次创建的 checkIsFinished 的 Promise 没有被解决。如何确保该 Promise 被解决,以便调用 printTime

可以尝试以下方式:

resolve(athlete);

我可以做到

Promise.resolve(athlete).then(printTime);

但如果可能的话,我想要避免那种情况,我真的很想能够编写

startRunning()
  .then(checkIsFinished)
  .then(printTime)
  .catch(handleError)

3
这段代码很难理解,但似乎它在错误地使用 Promise。除非我完全误解了这个问题,否则建议您花一些时间重新思考这个设计,可能先阅读有关 Promise 的资料会有所帮助。 - Amit
你需要明白每个.then()只会被执行一次,而且Promise只会被解决或拒绝一次并永远保持这样。所以,第一次到达checkIsFinished时,你返回一个Promise,然后使用setTimeout递归调用自己。但是这个新的Promise没有任何作用,因为setTimeout只返回一个句柄,而不是Promise,所以这个新的Promise也保持着等待状态,然后你重复了这个过程——可能导致内存泄漏。花些时间阅读和理解JavaScript事件循环的微妙之处,这将节省你大量的时间和挫折感。 - caasjj
当你执行 setTimeout(checkIsFinished.bind(null, athlete), 1000); 时,你在一个新的Promise内部创建了它,因此旧的Promise永远不会被解决。 - Grundy
@Amit,我已经编辑了我的问题以澄清情况。 - user5325596
@caasjj,@Grundy,感谢您们的评论。我明白setTimeout(checkIsFinished.bind(null, athlete), 1000)会创建一个新的Promise并且最初的Promise会被“丢失”。我的问题基本上是有没有办法解决这个问题?我已经编辑了问题以尝试澄清情况。 - user5325596
3个回答

9

问题在于你正在将返回一个promise的函数传递给setTimeout。该promise就会丢失。一种临时解决方法可能是对执行程序函数进行递归:

var checkIsFinished = function (athlete) {
  return new Promise(function executor(resolve) {
    if (athlete.distanceTravelled >= 100) {
      clearInterval(intervalID);
      console.log("finished");
      resolve(athlete);
    } else {
      console.log("not finished yet, check again in a bit");
      setTimeout(executor.bind(null, resolve), 1000);
    }    
  });
};

但是,我认为这是一个很好的例子,说明为什么应该避免promise-constructor anti-pattern (因为混合使用promise代码和非promise代码不可避免地会导致像这样的错误)。
为了避免出现这种错误,我遵循以下最佳实践:
  1. 只处理返回promise的异步函数。
  2. 当一个函数不返回promise时,用promise constructor包装它。
  3. 尽可能地包装狭窄(使用尽可能少的代码)。
  4. 不要将promise constructor用于其他任何事情。
这样做之后,我发现代码更容易理解,更难出错,因为一切都遵循相同的模式。
将这个思路应用到你的例子中,我得到了如下代码(我使用es6箭头函数来简化。它们在Firefox和Chrome 45中工作):

var console = { log: msg => div.innerHTML += msg + "<br>",
                error: e => console.log(e +", "+ e.lineNumber) };

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

var startRunning = () => {
  var athlete = {
    timeTaken: 0,
    distanceTravelled: 0,
    intervalID: setInterval(() => {
      athlete.distanceTravelled += 25;
      athlete.timeTaken += 2.5;
      console.log("updated athlete ");
    }, 2500)
  };
  return wait(2000).then(() => athlete);
};

var checkIsFinished = athlete => {
  if (athlete.distanceTravelled < 100) {
    console.log("not finished yet, check again in a bit");
    return wait(1000).then(() => checkIsFinished(athlete));
  }
  clearInterval(athlete.intervalID);
  console.log("finished");
  return athlete;
};

startRunning()
  .then(checkIsFinished)
  .then(athlete => console.log('printing time: ' + athlete.timeTaken))
  .catch(console.error);
<div id="div"></div>

请注意,checkIsFinished返回的是运动员或一个promise。这在这里很好,因为.then函数会自动将你传递的函数的返回值提升为promise。如果您将在其他上下文中调用checkIsFinished,则可能希望自己进行提升,使用return Promise.resolve(athlete);而不是return athlete;根据Amit的评论进行编辑: 对于非递归答案,请使用以下辅助函数替换整个checkIsFinished函数:
var waitUntil = (func, ms) => new Promise((resolve, reject) => {
  var interval = setInterval(() => {
    try { func() && resolve(clearInterval(interval)); } catch (e) { reject(e); }
  }, ms);
});

然后执行以下操作:
var athlete;
startRunning()
  .then(result => (athlete = result))
  .then(() => waitUntil(() => athlete.distanceTravelled >= 100, 1000))
  .then(() => {
    console.log('finished. printing time: ' + athlete.timeTaken);
    clearInterval(athlete.intervalID);
  })
  .catch(console.error);

var console = { log: msg => div.innerHTML += msg + "<br>",
                error: e => console.log(e +", "+ e.lineNumber) };

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

var waitUntil = (func, ms) => new Promise((resolve, reject) => {
  var interval = setInterval(() => {
    try { func() && resolve(clearInterval(interval)); } catch (e) { reject(e); }
  }, ms);
});

var startRunning = () => {
  var athlete = {
    timeTaken: 0,
    distanceTravelled: 0,
    intervalID: setInterval(() => {
      athlete.distanceTravelled += 25;
      athlete.timeTaken += 2.5;
      console.log("updated athlete ");
    }, 2500)
  };
  return wait(2000).then(() => athlete);
};

var athlete;
startRunning()
  .then(result => (athlete = result))
  .then(() => waitUntil(() => athlete.distanceTravelled >= 100, 1000))
  .then(() => {
    console.log('finished. printing time: ' + athlete.timeTaken);
    clearInterval(athlete.intervalID);
  })
  .catch(console.error);
<div id="div"></div>


2
虽然你的“最佳实践”列表看起来很有能力,但它会让你陷入一种情况,即递归创建新的 Promise,而不是链接它们,从而构建一个更深层次的“Promise 堆栈”。每个 wait(1000).then(...) 都托管着一个新的 Promise - 类似于 wait(x).then(()=>wait(x).then(()=>wait(x).then(...)))。如果/当最终结果到达时,这将对内存和性能产生不利影响。更糟糕的是,所有这些细节都隐藏在花哨的语法和抽象中。 - Amit
不确定你指的是我的哪个问题,但由于我在先前的评论中解释过的原因,承诺无法被收集。每个承诺都会被解决,然后调用一个带有返回新承诺的回调函数的.then()。那么,该调用最终将解决为内部承诺所解决的任何内容,但在此之前,它仍然存在,并且正在等待。这种情况会一直持续下去,“递归地”。事情不提前发生的事实是无关紧要的,因为如果最终事件从未到来,您将创建承诺,直到内存用尽。 - Amit
1
@Amit 我相信 Promise 实现可以优化掉这个问题,就像在相关问题的答案中提到的那样,但是我没有足够的空间在这里分享我的分析。然而,我承认我不知道是否有任何实现正在这样做,但我会尝试找出来。- 尽管你的问题标题是“如何解决递归异步 Promise?”,但我编辑了我的答案,提供了一个非递归的答案,希望能满足你对内存使用的担忧。 - jib
1
抱歉 @Amit,我把您和 OP 搞混了,您可能是对的,关于现有的实现。感谢您的反馈,我已经编辑了 waitUntil 函数,使其更清晰明了。刚刚注意到了您的答案。 - jib
@Amit:这个“递归”解决方案完全没问题——易读易懂,是首选的方式。如果这段代码占用了过多内存并导致性能问题,那就是一个 Promise 实现上的 bug。 - Bergi
显示剩余12条评论

1
使用 setTimeout / setInterval 是其中一个与 Promise 不兼容的场景,会导致你使用不被推荐的 Promise 反模式。
话虽如此,如果您重构函数使其成为“等待完成”类型的函数(并相应地命名),则可以解决问题。 waitForFinish 函数仅被调用一次,并返回一个单一的 Promise(尽管是在 startRunning 中创建的原始 Promise 之上的新 Promise)。通过内部轮询函数使用适当的 try/catch 处理间歇性操作,以确保异常传播到 Promise。

var intervalID;
var startRunning = function () {
  var athlete = {
    timeTaken: 0,
    distanceTravelled: 0
  };
  var updateAthlete = function () {
    athlete.distanceTravelled += 25;
    athlete.timeTaken += 2.5;
    console.log("updated athlete", athlete)
  }

  intervalID = setInterval(updateAthlete, 2500);

  return new Promise(function (resolve, reject) {
    setTimeout(resolve.bind(null, athlete), 2000);
  })
};

var waitForFinish = function (athlete) {
  return new Promise(function(resolve, reject) {
    (function pollFinished() {
      try{
        if (athlete.distanceTravelled >= 100) {
          clearInterval(intervalID);
          console.log("finished");
          resolve(athlete);
        } else {
          if(Date.now()%1000 < 250) { // This is here to show errors are cought
            throw new Error('some error');
          }
          console.log("not finished yet, check again in a bit");
          setTimeout(pollFinished, 1000);
        }
      }
      catch(e) { // When an error is cought, the promise is properly rejected
        // (Athlete will keep running though)
        reject(e);
      }
    })();
  });
};

var printTime = function (athlete) {
  console.log('printing time', athlete.timeTaken);
};

var handleError = function (e) { console.log('Handling error:', e); };

startRunning()
  .then(waitForFinish)
  .then(printTime)
  .catch(handleError);

尽管所有这些代码都能正常运行,但在异步环境中不建议使用轮询解决方案,如果可能的话应该避免使用。在您的情况下,由于此示例模拟与服务器的通信,如果可能的话,我建议考虑使用Web套接字。


我同意基于套接字的解决方案更可取。不幸的是,目前这是不可能的。 - user5325596
您的解决方案似乎无法正常工作。当我运行代码片段时,在控制台中看到多个形式为“updated athlete Object { timeTaken: 2.5, distanceTravelled: 25 }”的消息,但没有“not finished yet”消息和“finished”消息。 - user5325596
@user5325596 - 运行它多次。我故意添加了一个随机抛出异常的代码,以展示该解决方案如何处理异常(这可能会在您的实际代码中发生)。当抛出这样的异常时,您将看不到finished。您应该为实际使用清理此代码部分。(相关行是if (Date.now()%1000 < 250) - Amit
@user5325596 - 顺便说一下,你应该知道,根据我的理解(我对此非常有信心),你当前接受的答案中的所有解决方案都缺乏异常处理(尝试在其中抛出异常)或者会创建内存/性能问题。你应该考虑使用它们所带来的后果。 - Amit
@Amit - 请注意,在.then函数和promise构造函数执行器函数内部抛出的异常会自动导致拒绝,因此您的异常处理是多余的。被接受的答案在s tartRunning同步部分内部任何编码错误将被JS抛出到控制台,并且后续的promise链将通过.catch正确终止并报告错误。被接受的答案还提供了一个非递归解决方案,没有性能争议。 - jib
显示剩余2条评论

0

由于您对承诺的使用有些偏差,很难确定您想要做什么或哪种实现方式最适合,但是这里有一个建议。

承诺是一次性状态机。因此,您返回一个承诺,并且在将来的某个时间,该承诺可以被拒绝并附带原因,或者被解决并附带值。鉴于承诺的设计,我认为有意义的是能够像这样使用:

startRunning(100).then(printTime, handleError);

你可以使用以下代码实现它:
function startRunning(limit) {
    return new Promise(function (resolve, reject) {
        var timeStart = Date.now();
        var athlete = {
            timeTaken: 0,
            distanceTravelled: 0
        };
        function updateAthlete() {
            athlete.distanceTravelled += 25;
            console.log("updated athlete", athlete)
            if (athlete.distanceTravelled >= limit) {
                clearInterval(intervalID);
                athlete.timeTaken = Date.now() - timeStart;
                resolve(athlete);
            }
        }
        var intervalID = setInterval(updateAthlete, 2500);
    });
}

function printTime(athlete) {
    console.log('printing time', athlete.timeTaken);
}

function handleError(e) { 
    console.log(e); 
}

startRunning(100).then(printTime, handleError);

工作演示:http://jsfiddle.net/jfriend00/fbmbrc8s/


顺便提一下,我的设计偏好可能是拥有一个公共的运动员对象,然后在该对象上具有开始跑步、停止跑步等方法...


下面是您在使用promise时犯的几个基本错误:

  1. 它们只能被解决或拒绝一次,不能反复使用。
  2. startRunning().then(checkIsFinished)结构在逻辑上没有意义。首先必须使startRunning()返回一个promise对象,并在有用的操作发生时解析或拒绝该promise对象。您只是在两秒后解析它,这似乎并没有实现任何有用的功能。
  3. 您的描述让人觉得您希望`checkIsFinished()`继续执行并等到运动员完成。虽然可以通过不断链接promises来实现,但这似乎是一种非常复杂的方式,在这里肯定没有必要这样做。此外,这也不是您的代码试图实现的内容。您的代码只是返回一个新的promise对象,除非运动员已经超过所需距离,否则永远不会被解析。如果没有超过,它将返回一个永远不会被解决或拒绝的promise对象。这是对promise概念的根本违反。如果函数返回一个promise对象,则负责最终解析或拒绝它,除非调用代码期望放弃promise对象,否则可能是错误的设计工具。

这里有另一种方法,它创建了一个公共的Athlete()对象,具有一些方法,并允许多个人观看进度:
var EventEmitter = require('events');

function Athlete() {
    // private instance variables
    var runInterval, startTime; 
    var watcher = new EventEmitter();

    // public instance variables
    this.timeTaken = 0;
    this.distanceTravelled = 0;
    this.startRunning = function() {
        startTime = Date.now();
        var self = this;
        if (runInterval) {clearInterval(runInterval);}
        runInterval = setInterval(function() {
            self.distanceTravelled += 25;
            self.timeTaken = Date.now() - startTime;
            console.log("distance = ", self.distanceTravelled);
            // notify watchers
            watcher.emit("distanceUpdate");
        },2500);
    }
    this.notify = function(limit) {
        var self = this;
        return new Promise(function(resolve, reject) {
            function update() {
                if (self.distanceTravelled >= limit) {
                    watcher.removeListener("distanceUpdate", update);
                    resolve(self);
                    // if no more watchers, then stop the running timer
                    if (watcher.listeners("distanceUpdate").length === 0) {
                        clearInterval(runInterval);
                    }
                }
            }
            watcher.on("distanceUpdate", update);
        });
    }
}

var a = new Athlete();
a.startRunning();
a.notify(100).then(function() {
    console.log("done");
});

谢谢回答。我已经编辑了我的问题,以使事情更清晰。代码只是试图模仿实际情况,其中运动员实际上在服务器上。 - user5325596
@user5325596 - 下次请描述您的实际情况,这样我们就不会浪费太多时间在错误的问题上了 - 这真是令人沮丧!那么,您是否希望checkIsFinished()返回一个Promise,当它实际完成并通过重复的ajax调用轮询您的服务器内部来确定何时完成时,该Promise将被解决? - jfriend00
@user5325596 - 很抱歉,但是你在这个问题中的参与太少了(回复之间的天数),让我无法继续进一步地工作。 - jfriend00

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接