简体   繁体   中英

How to resolve recursive asynchronous promises?

I'm playing around with promises and I'm having trouble with an asynchronous recursive promise.

The scenario is an athlete starts running the 100m, I need to periodically check to see if they have finished and once they have finished, print their time.

Edit to clarify :

In the real world the athlete is running on a server. startRunning involves making an ajax call to the server. checkIsFinished also involves making an ajax call to the server. The code below is an attempt to imitate that. The times and distances in the code are hardcoded in an attempt to keep things as simple as possible. Apologies for not being clearer.

End edit

I'd like to be able to write the following

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

where

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); };

I can see that the promise that is created the first time checkIsFinished is never resolved. How can I ensure that that promise is resolved so that printTime is called?

Instead of

resolve(athlete);

I could do

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

But I'd like to avoid that if possible, I'd really like to be able to write

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

The bug is that you are passing a function that returns a promise to setTimeout . That promise is lost into the ether. A band-aid fix might be to recurse on the executor function:

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);
    }    
  });
};

But meh. I think this is a great example of why one should avoid the promise-constructor anti-pattern (because mixing promise code and non-promise code inevitably leads to bugs like this).

Best practices I follow to avoid such bugs:

  1. Only deal with async functions that return promises.
  2. When one doesn't return a promise, wrap it with a promise constructor.
  3. Wrap it as narrowly (with as little code) as possible.
  4. Don't use the promise constructor for anything else.

After this, I find code easier to reason about and harder to bugger up, because everything follows the same pattern.

Applying this to your example got me here (I'm using es6 arrow functions for brevity. They work in Firefox and 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> 

Note that checkIsFinished returns either athlete or a promise. This is fine here because .then functions automatically promote return values from functions you pass in to promises. If you'll be calling checkIsFinished in other contexts, you might want to do the promotion yourself, using return Promise.resolve(athlete); instead of return athlete; .

Edit in response to comments from Amit :

For a non-recursive answer, replace the entire checkIsFinished function with this helper:

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

and then do this:

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> 

Using setTimeout / setInterval is one of the scenrios that doesn't play well with promises, and causes you to use the frowned promise anti-pattern.

Having said that, if you reconstruct your function make it a "wait for completion" type of function (and name it accordingly as well), you'd be able to solve your problem. The waitForFinish function is only called once, and returns a single promise (albeit a new one, on top of the original promise created in startRunning ). The handling of the recurrence through setTimeout is done in an internal polling function, where proper try/catch is used to ensure exceptions are propagated to the 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); 

While all this code is functioning properly, a polling solution is never advised in an asynchronous environment and should be avoided if possible. In your case, since this sample mocks communication with a server, I'd consider using web sockets if possible.

Since your use of promises is pretty off the mark, it's a little hard to tell exactly what you're trying to do or what implementation would best fit, but here's a recommendation.

Promises are a one-shot state machine. As such, you return a promise and exactly one time in the future, the promise can be either rejected with a reason or resolved with a value. Given that design of promises, I think what makes sense would be something that can be used like this:

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

You could implement that with code like this:

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);

Working demo: http://jsfiddle.net/jfriend00/fbmbrc8s/


FYI, my design preference would probably be to have a public athlete object and then methods on that object to start running, stop running, etc...


Here are some of the fundamental things you got wrong in a use of promises:

  1. They are one-shot objects. They are resolved or rejected only once.
  2. The structure startRunning().then(checkIsFinished) just doesn't make logical sense. For the first part of this to work, startRunning() has to return a promise, and it has to resolve ore reject that promise when something useful happens. Your are just resolving it after two seconds which doesn't really seem to accomplish anything useful.
  3. The words of your description make it sound like you want `checkIsFinished() to keep going an not resolve its promise until the athlete is finished. It is possible to do that by continually chaining promises, but that seems a very complicated way to do things and certainly not necessary here. Further, that isn't at all what your code attempts to do. Your code just returns a new promise that is never resolved unless the athelete has already passed the desired distance. If not, it returns a promise that is never resolved or rejected. This is a fundamental violation of promise concepts. A function that returns a promise is responsible for eventually resolving or rejecting it unless the calling code expects to just abandon the promise in which case it's probably the wrong design tool.

Here's another approach that creates a public Athlete() object that has some methods and allows multiple people to be watching the progress:

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");
});

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM