按顺序解决承诺(即依次)?

420
考虑以下代码,它以串行/顺序方式读取文件数组。 readFiles 返回一个 Promise,该 Promise 只有在顺序读取所有文件后才被解决。
var readFile = function(file) {
  ... // Returns a promise.
};

var readFiles = function(files) {
  return new Promise((resolve, reject) => {
    var readSequential = function(index) {
      if (index >= files.length) {
        resolve();
      } else {
        readFile(files[index]).then(function() {
          readSequential(index + 1);
        }).catch(reject);
      }
    };

    readSequential(0); // Start with the first file!
  });
};

上述代码可以工作,但我不喜欢必须使用递归来使事情按顺序发生。有没有更简单的方法可以重写这个代码,这样我就不用使用奇怪的readSequential函数了?

最初我尝试使用Promise.all,但那会导致所有的readFile调用同时发生,这不是我想要的:

var readFiles = function(files) {
  return Promise.all(files.map(function(file) {
    return readFile(file);
  }));
};

3
任何需要等待先前异步操作完成的任务都必须在回调函数中完成。使用 Promise 并不能改变这一点。因此,您需要使用递归。 - Barmar
2
FYI,这并不是严格意义上的递归,因为没有堆栈帧的建立。在调用下一个函数之前,先前的readFileSequential()已经返回了(因为它是异步的,它会在原始函数调用已经返回很久之后才完成)。 - jfriend00
1
@jfriend00 递归不需要堆栈帧的累积 - 只需自我引用。但这只是一个技术上的细节。 - Benjamin Gruenbaum
4
我的观点是,在函数调用自身以启动下一次迭代方面并没有任何问题。这种方式完全没有缺点,事实上,它是一种有效的顺序执行异步操作的方法。因此,没有理由避免看起来像递归的东西。有些问题的递归解决方案效率低下,但这不是其中之一。 - jfriend00
1
嘿,根据JavaScript聊天室中的讨论和请求,我已编辑了此答案,以便我们可以将其作为规范指向其他人。如果您不同意,请告诉我,我会恢复它并打开一个单独的讨论。 - Benjamin Gruenbaum
显示剩余3条评论
36个回答

494

更新于2017年: 如果环境支持的话,我会使用异步函数:

async function readFiles(files) {
  for(const file of files) {
    await readFile(file);
  }
};

如果您愿意的话,您可以使用异步生成器(如果您的环境支持)暂缓阅读文件,直到需要它们为止:

async function* readFiles(files) {
  for(const file of files) {
    yield await readFile(file);
  }
};

更新:经过再次思考 - 我可能会使用for循环代替:


var readFiles = function(files) {
  var p = Promise.resolve(); // Q() in q

  files.forEach(file =>
      p = p.then(() => readFile(file)); 
  );
  return p;
};

更紧凑的写法,使用reduce函数:

var readFiles = function(files) {
  return files.reduce((p, file) => {
     return p.then(() => readFile(file));
  }, Promise.resolve()); // initial
};

在其他的 Promise 库(例如 when 和 Bluebird)中,您可以使用实用方法来解决这个问题。

例如,在 Bluebird 中可以这样写:

var Promise = require("bluebird");
var fs = Promise.promisifyAll(require("fs"));

var readAll = Promise.resolve(files).map(fs.readFileAsync,{concurrency: 1 });
// if the order matters, you can use Promise.each instead and omit concurrency param

readAll.then(function(allFileContents){
    // do stuff to read files.
});

虽然今天没有理由使用async await。


2
@EmreTapcı,不需要。箭头函数的“=>”已经意味着返回。 - Max Donchenko
如果你使用TypeScript,我认为“for in”循环解决方案是最好的。Reduce返回递归Promise,例如第一次调用返回类型是Promise<void>,然后第二次是Promise<Promise<void>>等等——除非使用任何类型,否则不可能进行类型转换。 - user10706046
2
@ArturTagisow TypeScript(至少新版本)具有递归类型并且应该在此处正确解析类型。不存在Promise<Promise<T>>这样的东西,因为promises会“递归同化”。Promise.resolve(Promise.resolve(15))Promise.resolve(15)相同。 - Benjamin Gruenbaum
1
@ArturTagisow https://www.typescriptlang.org/play/#code/MYewdgzgLgBADjAvDACgJxAWwJYQKYB0aeEIANgG54AUAjAKwCUA3AFCiSxwBMSqGOfERLkq1RgSgALPGGrikAPngsYAejWpeuGBBhRsZMjACG-LLjwAeMAFdMAIzxpF7cNHgBmPugtDipJQ0voKEAaLBApbCgWI8jAnM6poo3jp6Bkam5qE29k4urG6c8AAsfADatAA0MNy1nrWltfQAusIAJrbANNRwtcCMSvCSMnLUJkOIymYA1DCDtSHR4UHUpYnJZTA6y-h5js6KQA - Benjamin Gruenbaum
2
files.forEach([arrayOfPromises]) 不是顺序执行的,因为 forEach 不会等待 Promise 解决后再执行下一个。Array.map 也是并发的。 - carpeliam

91

这个问题很旧,但我们生活在ES6和函数式JavaScript的世界中,所以让我们看看如何改进。

由于承诺会立即执行,我们不能只创建一个承诺数组,因为它们都会并行触发。

相反,我们需要创建一个返回承诺的函数数组。然后每个函数将被顺序执行,然后启动承诺。

我们可以通过几种方式来解决这个问题,但我最喜欢的方法是使用reduce

在使用reduce与承诺结合时,会有些棘手,因此我已将一行代码拆分成了下面一些较小且易于理解的部分。

这个函数的精髓是使用reduce,从Promise.resolve([])开始作为初始值,或者一个包含空数组的承诺。

然后将该承诺作为promise传递给reduce方法。这是将每个承诺链接在一起按顺序执行的关键。接下来要执行的承诺是func,当then触发时,结果将被连接起来,然后返回该承诺,执行下一个承诺函数的reduce周期。

一旦所有承诺都执行完毕,返回的承诺将包含每个承诺的所有结果数组。

ES6示例(一行代码):

/*
 * serial executes Promises sequentially.
 * @param {funcs} An array of funcs that return promises.
 * @example
 * const urls = ['/url1', '/url2', '/url3']
 * serial(urls.map(url => () => $.ajax(url)))
 *     .then(console.log.bind(console))
 */
const serial = funcs =>
    funcs.reduce((promise, func) =>
        promise.then(result => func().then(Array.prototype.concat.bind(result))), Promise.resolve([]))

ES6 示例(分解)

// broken down to for easier understanding

const concat = list => Array.prototype.concat.bind(list)
const promiseConcat = f => x => f().then(concat(x))
const promiseReduce = (acc, x) => acc.then(promiseConcat(x))
/*
 * serial executes Promises sequentially.
 * @param {funcs} An array of funcs that return promises.
 * @example
 * const urls = ['/url1', '/url2', '/url3']
 * serial(urls.map(url => () => $.ajax(url)))
 *     .then(console.log.bind(console))
 */
const serial = funcs => funcs.reduce(promiseReduce, Promise.resolve([]))

使用方法:

// first take your work
const urls = ['/url1', '/url2', '/url3', '/url4']

// next convert each item to a function that returns a promise
const funcs = urls.map(url => () => $.ajax(url))

// execute them serially
serial(funcs)
    .then(console.log.bind(console))

1
非常好,谢谢。Array.prototype.concat.bind(result)是我缺少的部分,之前必须手动推入结果,虽然可行但不够优雅。 - zavr
由于我们都在使用现代的JS,我认为你最后一个例子中的 console.log.bind(console) 语句通常是不必要的。现在你可以直接传递 console.log。例如:serial(funcs).then(console.log)。经过当前Nodejs和Chrome测试。 - Molomby
这有点难以理解,但 reduce 基本上是在做正确的事情吗? const data = mockApi('/data/1'); return Promise.resolve(x.concat(data)) }).then((x) => { const data = mockApi('/data/2'); return Promise.resolve(x.concat(data)); });``` - danecando
@danecando,是的,这看起来是正确的。您还可以在返回中省略Promise.resolve,除非您在其上调用Promise.reject,否则任何返回的值都将自动解析。 - joelnet
@joelnet,针对danecando的评论,我认为reduce所做的应该更正确地表达为以下表达式,你同意吗? Promise.resolve([]).then(x => someApiCall('url1').then(r => x.concat(r))).then(x => someApiCall('url2').then(r => x.concat(r))) 以此类推 - bufferoverflow76

89

以下是我更喜欢逐个运行任务的方法。

function runSerial() {
    var that = this;
    // task1 is a function that returns a promise (and immediately starts executing)
    // task2 is a function that returns a promise (and immediately starts executing)
    return Promise.resolve()
        .then(function() {
            return that.task1();
        })
        .then(function() {
            return that.task2();
        })
        .then(function() {
            console.log(" ---- done ----");
        });
}

那么对于任务更多的情况呢?比如10个任务?

function runSerial(tasks) {
  var result = Promise.resolve();
  tasks.forEach(task => {
    result = result.then(() => task());
  });
  return result;
}

12
对于您不知道确切任务数量的情况怎么办? - damd
1
那么当您在运行时才知道任务数量时呢? - joeytwiddle
18
根据 Promise 规范,一旦 Promise 被创建,它就会立即开始执行。因此,您并不想在 Promise 数组上执行操作。实际上,您需要的是一个 Promise 工厂数组。请参见此处的高级错误#3:https://pouchdb.com/2015/05/18/we-have-a-problem-with-promises.html。 - edelans
7
如果你想减少代码中的噪音,你也可以写成 result = result.then(task); - Daniel Buckmaster
4
@DanielBuckmaster 是的,但要小心,因为如果 task() 返回一个值,它将传递给下一次调用。如果您的 task 有可选参数,这可能会导致副作用。当前的代码忽略结果并显式地使用无参数调用下一个 task。 - JHH
显示剩余4条评论

53

在ES6中,可以简单地实现如下:

function(files) {
  // Create a new empty promise (don't do that with real people ;)
  var sequence = Promise.resolve();

  // Loop over each file, and add on a promise to the
  // end of the 'sequence' promise.
  files.forEach(file => {

    // Chain one computation onto the sequence
    sequence = 
      sequence
        .then(() => performComputation(file))
        .then(result => doSomething(result)); 
        // Resolves for each file, one at a time.

  })

  // This will resolve after the entire chain is resolved
  return sequence;
}

1
似乎它正在使用下划线。如果files是一个数组,您可以简化为files.forEach - Gustavo Rodrigues
2
好吧,这是ES5的写法。ES6的写法应该是 for (file of files) {...} - Gustavo Rodrigues
1
你说在实际开发中不应该使用Promise.resolve()来创建已经解决的Promise。为什么?Promise.resolve()似乎比new Promise(success => success())更简洁。 - canac
18
@canac 对不起,那只是一个文字游戏的玩笑(“空洞的承诺”..)。在你的代码中一定要使用Promise.resolve(); - Shridhar Gupta
1
不错的解决方案,易于理解。我没有将我的代码封装在一个函数中,所以为了在结尾处解决问题,我使用了sequence.then(() => { do stuff });代替return sequence; - Joe Coyle
显示剩余3条评论

34

加法例子

const addTwo = async () => 2;

const addThree = async (inValue) => new Promise((resolve) => setTimeout(resolve(inValue + 3), 2000));

const addFour = (inValue) => new Promise((res) => setTimeout(res(inValue + 4), 1000)); 

const addFive = async (inValue) => inValue + 5;

// Function which handles promises from above
async function sequenceAddition() {
  let sum = await [addTwo, addThree, addFour, addFive].reduce(
    (promise, currPromise) => promise.then((val) => currPromise(val)),
    Promise.resolve()
  );
  console.log('sum:', sum); // 2 + 3 + 4 + 5 =  14
}

// Run function. See console for result.
sequenceAddition();

使用reduce()的通用语法

function sequence(tasks, fn) {
    return tasks.reduce((promise, task) => promise.then(() => fn(task)), Promise.resolve());
}

更新

items-promise是一个可直接使用的NPM包,功能与此相同。


8
我很愿意看到更详细的解释。 - Tyguy7
我在下面提供了带有解释的答案变体。谢谢。 - Sarsaparilla
这正是我在没有访问async/await的Node 7之前的环境中所做的。漂亮而干净。 - JHH

13

我不得不运行许多连续的任务,并使用这些答案来构建一个函数,以处理任何连续的任务...

function one_by_one(objects_array, iterator, callback) {
    var start_promise = objects_array.reduce(function (prom, object) {
        return prom.then(function () {
            return iterator(object);
        });
    }, Promise.resolve()); // initial
    if(callback){
        start_promise.then(callback);
    }else{
        return start_promise;
    }
}

该函数需要2个必选参数和1个可选参数。第一个参数是我们要操作的数组。第二个参数是任务本身,它是一个返回promise的函数,只有当这个promise解决时,下一个任务才会开始。第三个参数是在所有任务完成后运行的回调函数。如果没有传递回调函数,则该函数将返回它创建的promise,以便我们处理结束。

以下是使用示例:

var filenames = ['1.jpg','2.jpg','3.jpg'];
var resize_task = function(filename){
    //return promise of async resizing with filename
};
one_by_one(filenames,resize_task );

希望它能为某些人节省时间...


令人难以置信的解决方案,这是我在近一个星期的挣扎中找到的最好的解决方案... 它非常清晰易懂,具有逻辑内部名称,有一个很好的示例(可能还可以更好),我可以安全地多次调用它,并且它包括设置回调选项。 简单而美好!(只是将名称更改为更有意义的内容).... 推荐给其他人... 您可以使用“Object.keys(myObject)”作为您的“objects_array”来迭代对象。 - DavidTaubmann
谢谢您的评论!我也没有使用那个名称,但我想在这里让它更明显/简单。 - Salketer

12

使用 Async/Await(如果您有 ES7 的支持)

function downloadFile(fileUrl) { ... } // This function return a Promise

async function main()
{
  var filesList = [...];

  for (const file of filesList) {
    await downloadFile(file);
  }
}

(必须使用 for 循环,而非 forEach 循环,因为 async/await 在 forEach 循环中存在运行问题)

无 Async/Await(使用 Promise)

function downloadFile(fileUrl) { ... } // This function return a Promise

function downloadRecursion(filesList, index)
{
  index = index || 0;
  if (index < filesList.length)
  {
    downloadFile(filesList[index]).then(function()
    {
      index++;
      downloadRecursion(filesList, index); // self invocation - recursion!
    });
  }
  else
  {
    return Promise.resolve();
  }
}

function main()
{
  var filesList = [...];
  downloadRecursion(filesList);
}

5
不建议在forEach内使用await。 - Marcelo Agimóvel
1
@MarceloAgimóvel - 我已经更新了解决方案,不再使用forEach(根据这个)。 - Gil Epshtain

8

我的首选解决方案:

function processArray(arr, fn) {
    return arr.reduce(
        (p, v) => p.then((a) => fn(v).then(r => a.concat([r]))),
        Promise.resolve([])
    );
}

这并不是与其他发布在此处的内容根本不同,但:

  • 将该函数应用于系列项目
  • 解析为结果数组
  • 不需要async/await(支持仍然非常有限,大约在2017年左右)
  • 使用箭头函数;简洁明了

示例用法:

const numbers = [0, 4, 20, 100];
const multiplyBy3 = (x) => new Promise(res => res(x * 3));

// Prints [ 0, 12, 60, 300 ]
processArray(numbers, multiplyBy3).then(console.log);

在当前合理的Chrome浏览器(v59)和NodeJS(v8.1.2)上进行了测试。


我更喜欢这种方法,而不是改变数组原型的解决方案;这是一种清晰的功能性方法,采用了应用模式 - undefined

8

首先,您需要了解承诺(Promise)是在创建时执行的。
例如,如果您有以下代码:

["a","b","c"].map(x => returnsPromise(x))

你需要将其更改为:
["a","b","c"].map(x => () => returnsPromise(x))

然后我们需要按顺序链接承诺:

["a", "b", "c"].map(x => () => returnsPromise(x))
    .reduce(
        (before, after) => before.then(_ => after()),
        Promise.resolve()
    )

执行 after() ,将确保Promise只在其时间到来时创建(和执行)。

6
我能提供的最好解决方案是使用bluebird承诺。您只需要执行Promise.resolve(files).each(fs.readFileAsync);,这可以确保承诺按顺序依次解决。

1
更好的写法是:Promise.each(filtes, fs.readFileAsync)。顺便问一下,你需要执行.bind(fs)吗? - Bergi
这里似乎没有人理解数组和序列之间的区别,后者意味着无限/动态大小。 - vitaly-t
请注意,JavaScript 中的数组与 C 风格语言中的固定大小数组毫无关系。它们只是带有数字键管理的对象,并且没有预定的大小或限制(特别是在使用 new Array(int) 时)。所有这些都只是预先设置了 length 键值对,影响基于长度迭代期间使用的索引数量。它对实际数组的索引或索引边界没有任何影响。 - Mike 'Pomax' Kamermans

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