Node JS Promise.all e forEach

120

Eu tenho uma estrutura parecida com uma matriz que expõe métodos assíncronos. As chamadas de método assíncrono retornam estruturas de matriz que, por sua vez, expõem mais métodos assíncronos. Estou criando outro objeto JSON para armazenar valores obtidos dessa estrutura e, portanto, preciso ter cuidado ao manter o controle de referências em retornos de chamada.

Codifiquei uma solução de força bruta, mas gostaria de aprender uma solução mais idiomática ou limpa.

  1. O padrão deve ser repetido para n níveis de aninhamento.
  2. Preciso usar a promessa.all ou alguma técnica semelhante para determinar quando resolver a rotina de fechamento.
  3. Nem todo elemento envolverá necessariamente fazer uma chamada assíncrona. Portanto, em uma promessa aninhada. Tudo que eu não posso simplesmente fazer atribuições aos meus elementos do array JSON com base no índice. No entanto, preciso usar algo como promessa.all no forEach aninhado para garantir que todas as atribuições de propriedade tenham sido feitas antes de resolver a rotina de fechamento.
  4. Estou usando a lib de promessa do bluebird, mas isso não é um requisito

Aqui está um código parcial -

var jsonItems = [];

items.forEach(function(item){

  var jsonItem = {};
  jsonItem.name = item.name;
  item.getThings().then(function(things){
  // or Promise.all(allItemGetThingCalls, function(things){

    things.forEach(function(thing, index){

      jsonItems[index].thingName = thing.name;
      if(thing.type === 'file'){

        thing.getFile().then(function(file){ //or promise.all?

          jsonItems[index].filesize = file.getSize();
user3205931
fonte
Este é o link para a fonte de trabalho que desejo melhorar. github.com/pebanfield/change-view-service/blob/master/src/…
user3205931
1
Vejo no exemplo que você está usando bluebird, bluebird na verdade torna sua vida ainda mais fácil com Promise.map(simultâneo) e Promise.each(sequencial) neste caso, também observe que Promise.deferestá obsoleto - o código em minha resposta mostra como evitá-lo retornando promessas. As promessas têm tudo a ver com valores de retorno.
Benjamin Gruenbaum

Respostas:

368

É bastante direto com algumas regras simples:

  • Sempre que você criar uma promessa em um then, devolva-a - qualquer promessa que você não retornar não será esperada do lado de fora.
  • Sempre que você cria várias promessas, .allelas - dessa forma, espera por todas as promessas e nenhum erro de qualquer uma delas é silenciado.
  • Sempre que você aninha thens, normalmente pode retornar no meio - as thencadeias geralmente têm no máximo 1 nível de profundidade.
  • Sempre que você executa IO, deve ser com uma promessa - ou deve ser uma promessa ou deve usar uma promessa para sinalizar sua conclusão.

E algumas dicas:

  • O mapeamento é melhor feito com do .mapque comfor/push - se você estiver mapeando valores com uma função,map permite expressar concisamente a noção de aplicar ações uma a uma e agregar os resultados.
  • A simultaneidade é melhor do que a execução sequencial se for gratuita - é melhor executar as coisas simultaneamente e esperar por elas Promise.alldo que executar as coisas uma após a outra - cada uma esperando antes da próxima.

Ok, então vamos começar:

var items = [1, 2, 3, 4, 5];
var fn = function asyncMultiplyBy2(v){ // sample async action
    return new Promise(resolve => setTimeout(() => resolve(v * 2), 100));
};
// map over forEach since it returns

var actions = items.map(fn); // run the function over all items

// we now have a promises array and we want to wait for it

var results = Promise.all(actions); // pass array of promises

results.then(data => // or just .then(console.log)
    console.log(data) // [2, 4, 6, 8, 10]
);

// we can nest this of course, as I said, `then` chains:

var res2 = Promise.all([1, 2, 3, 4, 5].map(fn)).then(
    data => Promise.all(data.map(fn))
).then(function(data){
    // the next `then` is executed after the promise has returned from the previous
    // `then` fulfilled, in this case it's an aggregate promise because of 
    // the `.all` 
    return Promise.all(data.map(fn));
}).then(function(data){
    // just for good measure
    return Promise.all(data.map(fn));
});

// now to get the results:

res2.then(function(data){
    console.log(data); // [16, 32, 48, 64, 80]
});
Benjamin Gruenbaum
fonte
5
Ah, algumas regras de sua perspectiva :-)
Bergi
1
@Bergi, alguém realmente deveria fazer uma lista dessas regras e um breve histórico sobre as promessas. Podemos hospedá-lo em bluebirdjs.com provavelmente.
Benjamin Gruenbaum
já que não devo apenas dizer obrigado - este exemplo parece bom e eu gosto da sugestão do mapa, no entanto, o que fazer com uma coleção de objetos em que apenas alguns têm métodos assíncronos? (Meu ponto 3 acima) Tive a ideia de abstrair a lógica de análise de cada elemento em uma função e, em seguida, resolver na resposta da chamada assíncrona ou, se não houvesse chamada assíncrona, simplesmente resolver. Isso faz sentido?
user3205931
Também preciso que a função map retorne o objeto json que estou construindo e o resultado da chamada assíncrona. Também não tenho certeza de como fazer isso - finalmente, a coisa toda precisa ser recursiva, já que estou percorrendo um diretório estrutura - Ainda estou
pensando
2
As promessas @ user3205931 são simples, ao invés de fáceis , isto é - elas não são tão familiares quanto outras coisas, mas uma vez que você as grava, elas são muito melhores de usar. Aguente firme, você vai entender :)
Benjamin Gruenbaum
42

Aqui está um exemplo simples usando reduzir. Ele é executado em série, mantém a ordem de inserção e não requer Bluebird.

/**
 * 
 * @param items An array of items.
 * @param fn A function that accepts an item from the array and returns a promise.
 * @returns {Promise}
 */
function forEachPromise(items, fn) {
    return items.reduce(function (promise, item) {
        return promise.then(function () {
            return fn(item);
        });
    }, Promise.resolve());
}

E use-o assim:

var items = ['a', 'b', 'c'];

function logItem(item) {
    return new Promise((resolve, reject) => {
        process.nextTick(() => {
            console.log(item);
            resolve();
        })
    });
}

forEachPromise(items, logItem).then(() => {
    console.log('done');
});

Achamos útil enviar um contexto opcional para o loop. O contexto é opcional e compartilhado por todas as iterações.

function forEachPromise(items, fn, context) {
    return items.reduce(function (promise, item) {
        return promise.then(function () {
            return fn(item, context);
        });
    }, Promise.resolve());
}

Sua função de promessa seria semelhante a esta:

function logItem(item, context) {
    return new Promise((resolve, reject) => {
        process.nextTick(() => {
            console.log(item);
            context.itemCount++;
            resolve();
        })
    });
}
Steven Spungin
fonte
Obrigado por isso - sua solução funcionou para mim onde outras (incluindo várias libs npm) não. Você publicou isso no npm?
SamF
Obrigado. A função assume que todas as promessas foram resolvidas. Como lidamos com promessas rejeitadas? Além disso, como lidamos com promessas de sucesso com um valor?
oyalhi
@oyalhi, eu sugeriria usar o 'contexto' e adicionar uma matriz de parâmetros de entrada rejeitados mapeados para o erro. Isso ocorre de acordo com o caso de uso, pois alguns desejarão ignorar todas as promessas restantes e outros não. Para o valor retornado, você também pode usar uma abordagem semelhante.
Steven Spungin de
1

Eu passei pela mesma situação. Resolvi usando dois Promise.All ().

Acho que foi uma solução muito boa, então publiquei no npm: https://www.npmjs.com/package/promise-foreach

Acho que seu código será mais ou menos assim

var promiseForeach = require('promise-foreach')
var jsonItems = [];
promiseForeach.each(jsonItems,
    [function (jsonItems){
        return new Promise(function(resolve, reject){
            if(jsonItems.type === 'file'){
                jsonItems.getFile().then(function(file){ //or promise.all?
                    resolve(file.getSize())
                })
            }
        })
    }],
    function (result, current) {
        return {
            type: current.type,
            size: jsonItems.result[0]
        }
    },
    function (err, newList) {
        if (err) {
            console.error(err)
            return;
        }
        console.log('new jsonItems : ', newList)
    })
Saulsluz
fonte
0

Apenas para complementar a solução apresentada, no meu caso eu queria buscar vários dados do Firebase para uma lista de produtos. Aqui está como eu fiz:

useEffect(() => {
  const fn = p => firebase.firestore().doc(`products/${p.id}`).get();
  const actions = data.occasion.products.map(fn);
  const results = Promise.all(actions);
  results.then(data => {
    const newProducts = [];
    data.forEach(p => {
      newProducts.push({ id: p.id, ...p.data() });
    });
    setProducts(newProducts);
  });
}, [data]);
Charles de Dreuille
fonte