如何解决递归异步承诺?

How to resolve recursive asynchronous promises?

我正在玩弄 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 从未解决。我如何确保该承诺得到解决,以便调用 printTime

而不是

resolve(athlete);

我可以

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

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

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

由于您对 promises 的使用非常不合时宜,因此很难准确判断您正在尝试做什么或哪种实施最适合,但这里有一个建议。

Promises 是一次性状态机。因此,您 return 一个承诺,并且恰好在未来的某个时间,该承诺可以被拒绝并提供价值。鉴于承诺的设计,我认为有意义的是可以像这样使用的东西:

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/


仅供参考,我的设计偏好可能是有一个 public 运动员对象,然后在该对象上启动 运行、停止 运行 等方法...


以下是您在使用 promises 时犯的一些基本错误:

  1. 它们是一次性对象。他们只被解决或拒绝一次。
  2. 结构startRunning().then(checkIsFinished) 只是没有逻辑意义。为了使第一部分起作用,startRunning() 必须 return 一个承诺,并且它必须在有用的事情发生时解决或拒绝该承诺。你只是在两秒钟后解决它,这似乎并没有真正完成任何有用的事情。
  3. 您的描述听起来像是您希望 `checkIsFinished() 继续执行,直到运动员完成后才兑现其承诺。可以通过不断地链接承诺来做到这一点,但这似乎是一种非常复杂的做事方式,当然在这里没有必要。此外,这根本不是您的代码试图做的。您的代码只是 return 一个新的承诺,除非运动员已经超过所需的距离,否则永远不会解决。如果不是,它 return 是一个永远不会被解决或拒绝的承诺。这是对承诺概念的根本违反。 return 承诺的函数负责最终解决或拒绝它,除非调用代码期望放弃承诺,在这种情况下它可能是错误的设计工具。

这是另一种创建 public 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");
});

错误在于您正在传递一个 return 对 setTimeout 的承诺的函数。这个承诺消失在以太中。创可贴修复可能是递归执行函数:

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(因为混合承诺代码和非承诺代码不可避免地会导致这样的错误)。

我遵循的避免此类错误的最佳做法:

  1. 只处理 return 承诺的异步函数。
  2. 当没有 return 承诺时,用承诺构造函数包装它。
  3. 尽可能窄地(用尽可能少的代码)包装它。
  4. 不要将 promise 构造函数用于其他任何事情。

在此之后,我发现代码更容易推理,更难出错,因为一切都遵循相同的模式。

将此应用于您的示例让我来到这里(为简洁起见,我使用 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 return 要么是运动员,要么是诺言。这在这里很好,因为 .then 函数会自动从您传递给 promise 的函数中提升 return 值。如果您将在其他情况下调用 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>

使用 setTimeout / setInterval 是不能很好地处理 promise 的场景之一,会导致您使用皱眉的 promise 反模式。

话虽如此,如果您重构您的函数,使其成为 "wait for completion" 类型的函数(并相应地命名),您将能够解决您的问题。 waitForFinish 函数只被调用一次,并且 returns 一个单一的承诺(尽管是一个新的,在 startRunning 中创建的原始承诺之上)。通过 setTimeout 的重复处理是在内部轮询函数中完成的,其中使用适当的 try/catch 来确保将异常传播到承诺中。

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

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