Promesas de JavaScript: Una introducción

Las promesas simplifican los cálculos diferidos y asíncronos. Una promesa representa una operación que aún no se completó.

Desarrolladores, prepárense para un momento crucial en la historia del desarrollo web.

[Comienza el redoble de tambores]

Las promesas llegaron a JavaScript.

[Fireworks explode, glittery paper rains from above, the crowd goes wild]

En este punto, te encuentras en una de estas categorías:

  • Las personas te aclaman, pero no sabes por qué tanto alboroto. Tal vez ni siquiera sepas qué es una "promesa". Te encogerías de hombros, pero el peso del papel brillante te lo impide. Si es así, no te preocupes, me llevó mucho tiempo entender por qué debería importarme esto. Es probable que quieras empezar por el principio.
  • ¡Golpeas el aire! Ya era hora, ¿no? Ya usaste estas promesas antes, pero te molesta que todas las implementaciones tengan una API un poco diferente. ¿Cuál es la API de la versión oficial de JavaScript? Es probable que quieras comenzar con la terminología.
  • Ya sabías esto y te burlas de quienes se sorprenden como si fuera una novedad. Tómate un momento para disfrutar de tu propia superioridad y, luego, dirígete directamente a la referencia de la API.

Compatibilidad con navegadores y polyfill

Browser Support

  • Chrome: 32.
  • Edge: 12.
  • Firefox: 29.
  • Safari: 8.

Source

Para que los navegadores que no tienen una implementación completa de promesas cumplan con las especificaciones, o para agregar promesas a otros navegadores y Node.js, consulta el polyfill (2k comprimido con gzip).

¿Por qué tanto alboroto?

JavaScript es de un solo subproceso, lo que significa que dos bits de secuencia de comandos no se pueden ejecutar al mismo tiempo; deben ejecutarse uno después del otro. En los navegadores, JavaScript comparte un subproceso con muchas otras cosas que varían de un navegador a otro. Sin embargo, por lo general, JavaScript se encuentra en la misma cola que la pintura, la actualización de estilos y el control de las acciones del usuario (como destacar texto y, además, interactuar con los controles de formulario). La actividad en uno de estos elementos retrasa los demás.

Como ser humano, eres multiproceso. Puedes escribir con varios dedos, puedes conducir y mantener una conversación al mismo tiempo. La única función de bloqueo con la que debemos lidiar es el estornudo, en el que se debe suspender toda la actividad actual durante el estornudo. Eso es bastante molesto, sobre todo cuando estás conduciendo y tratas de mantener una conversación. No querrás escribir código que sea estornudo.

Es probable que hayas usado eventos y devoluciones de llamada para solucionar este problema. Aquí tienes los eventos:

var img1 = document.querySelector('.img-1');

img1.addEventListener('load', function() {
  // woo yey image loaded
});

img1.addEventListener('error', function() {
  // argh everything's broken
});

Esto no es para nada estornudable. Obtenemos la imagen, agregamos un par de objetos de escucha y, luego, JavaScript puede detener la ejecución hasta que se llame a uno de esos objetos de escucha.

Lamentablemente, en el ejemplo anterior, es posible que los eventos hayan ocurrido antes de que comenzáramos a escucharlos, por lo que debemos solucionar ese problema con la propiedad "complete" de las imágenes:

var img1 = document.querySelector('.img-1');

function loaded() {
  // woo yey image loaded
}

if (img1.complete) {
  loaded();
}
else {
  img1.addEventListener('load', loaded);
}

img1.addEventListener('error', function() {
  // argh everything's broken
});

Esto no detecta las imágenes que generaron un error antes de que tuviéramos la oportunidad de escucharlas; lamentablemente, el DOM no nos brinda una forma de hacerlo. Además, esto carga una imagen. Las cosas se complican aún más si queremos saber cuándo se cargó un conjunto de imágenes.

Los eventos no siempre son la mejor manera

Los eventos son ideales para acciones que pueden ocurrir varias veces en el mismo objeto, como keyup, touchstart, etc. Con esos eventos, no importa lo que haya sucedido antes de que adjuntaras el objeto de escucha. Sin embargo, cuando se trata de un éxito o un error asíncrono, lo ideal es que tengas algo como esto:

img1.callThisIfLoadedOrWhenLoaded(function() {
  // loaded
}).orIfFailedCallThis(function() {
  // failed
});

// and…
whenAllTheseHaveLoaded([img1, img2]).callThis(function() {
  // all loaded
}).orIfSomeFailedCallThis(function() {
  // one or more failed
});

Esto es lo que hacen las promesas, pero con una mejor asignación de nombres. Si los elementos de imagen HTML tuvieran un método "ready" que devolviera una promesa, podríamos hacer lo siguiente:

img1.ready()
.then(function() {
  // loaded
}, function() {
  // failed
});

// and…
Promise.all([img1.ready(), img2.ready()])
.then(function() {
  // all loaded
}, function() {
  // one or more failed
});

En su forma más básica, las promesas son un poco como los objetos de escucha de eventos, excepto por lo siguiente:

  • Una promesa solo puede tener éxito o fallar una vez. No puede tener éxito ni fallar dos veces, ni puede cambiar de éxito a falla o viceversa.
  • Si una promesa se completó correctamente o falló y, luego, agregas una devolución de llamada de éxito o error, se llamará a la devolución de llamada correcta, incluso si el evento se produjo antes.

Esto es muy útil para el éxito o el fracaso asíncronos, ya que te interesa menos el momento exacto en que algo estuvo disponible y más reaccionar al resultado.

Terminología de las promesas

Domenic Denicola corrigió el primer borrador de este artículo y me calificó con una "F" por la terminología. Me castigó, me obligó a copiar Estados y destinos 100 veces y les escribió una carta de preocupación a mis padres. A pesar de eso, todavía confundo mucha de la terminología, pero estos son los conceptos básicos:

Una promesa puede ser:

  • fulfilled: La acción relacionada con la promesa se completó correctamente.
  • rejected: La acción relacionada con la promesa falló.
  • pending: Aún no se completó ni se rechazó.
  • settled: Se completó o se rechazó.

La especificación también usa el término thenable para describir un objeto similar a una promesa, ya que tiene un método then. Este término me recuerda al exdirector técnico de la selección de fútbol de Inglaterra, Terry Venables, así que lo usaré lo menos posible.

¡Llegan las promesas a JavaScript!

Las promesas existen desde hace un tiempo en forma de bibliotecas, como las siguientes:

Las promesas anteriores y las de JavaScript comparten un comportamiento común y estandarizado llamado Promises/A+. Si eres usuario de jQuery, tienen algo similar llamado Deferreds. Sin embargo, los objetos Deferred no cumplen con Promise/A+, lo que los hace sutilmente diferentes y menos útiles, así que ten cuidado. jQuery también tiene un tipo Promise, pero este es solo un subconjunto de Deferred y tiene los mismos problemas.

Aunque las implementaciones de promesas siguen un comportamiento estandarizado, sus APIs generales difieren. Las promesas de JavaScript son similares en la API a RSVP.js. Así es como se crea una promesa:

var promise = new Promise(function(resolve, reject) {
  // do a thing, possibly async, then…

  if (/* everything turned out fine */) {
    resolve("Stuff worked!");
  }
  else {
    reject(Error("It broke"));
  }
});

El constructor de promesas toma un argumento, una devolución de llamada con dos parámetros, resolve y reject. Haz algo dentro de la devolución de llamada, tal vez de forma asíncrona, y, luego, llama a resolve si todo funcionó; de lo contrario, llama a reject.

Al igual que con throw en JavaScript simple, es habitual, pero no obligatorio, rechazar con un objeto Error. El beneficio de los objetos Error es que capturan un seguimiento de pila, lo que hace que las herramientas de depuración sean más útiles.

A continuación, se muestra cómo usar esa promesa:

promise.then(function(result) {
  console.log(result); // "Stuff worked!"
}, function(err) {
  console.log(err); // Error: "It broke"
});

then() toma dos argumentos: una devolución de llamada para un caso de éxito y otra para un caso de error. Ambos son opcionales, por lo que puedes agregar una devolución de llamada solo para el caso de éxito o de error.

Las promesas de JavaScript comenzaron en el DOM como "Futures", se les cambió el nombre a "Promises" y, finalmente, se trasladaron a JavaScript. Tenerlos en JavaScript en lugar del DOM es excelente, ya que estarán disponibles en contextos de JS que no sean del navegador, como Node.js (si los usan en sus APIs principales, es otra cuestión).

Aunque son una función de JavaScript, el DOM no tiene miedo de usarlos. De hecho, todas las APIs de DOM nuevas con métodos asíncronos de éxito o error usarán promesas. Esto ya está sucediendo con la administración de cuotas, los eventos de carga de fuentes, el ServiceWorker, Web MIDI, Streams y mucho más.

Compatibilidad con otras bibliotecas

La API de promesas de JavaScript tratará todo lo que tenga un método then() como similar a una promesa (o thenable en el lenguaje de las promesas suspiro), por lo que, si usas una biblioteca que devuelve una promesa Q, no hay problema, funcionará bien con las nuevas promesas de JavaScript.

Aunque, como mencioné, los Deferreds de jQuery son un poco… inútiles. Por suerte, puedes convertirlas en promesas estándar, lo que vale la pena hacer lo antes posible:

var jsPromise = Promise.resolve($.ajax('/whatever.json'))

Aquí, $.ajax de jQuery devuelve un Deferred. Como tiene un método then(), Promise.resolve() puede convertirlo en una promesa de JavaScript. Sin embargo, a veces los objetos diferidos pasan varios argumentos a sus devoluciones de llamada, por ejemplo:

var jqDeferred = $.ajax('/whatever.json');

jqDeferred.then(function(response, statusText, xhrObj) {
  // ...
}, function(xhrObj, textStatus, err) {
  // ...
})

Mientras que las promesas de JS ignoran todas las llamadas, excepto la primera:

jsPromise.then(function(response) {
  // ...
}, function(xhrObj) {
  // ...
})

Por suerte, esto suele ser lo que quieres o, al menos, te da acceso a lo que quieres. Además, ten en cuenta que jQuery no sigue la convención de pasar objetos Error a los rechazos.

Código asíncrono complejo simplificado

Bien, escribamos algo de código. Supongamos que queremos hacer lo siguiente:

  1. Inicia un ícono giratorio para indicar que se está cargando.
  2. Recuperar un JSON para una historia, que nos da el título y las URLs de cada capítulo
  3. Agrega un título a la página
  4. Recupera cada capítulo
  5. Agrega la historia a la página
  6. Detén el ícono giratorio

… pero también informarle al usuario si algo salió mal en el proceso. También querremos detener el ícono giratorio en ese punto; de lo contrario, seguirá girando, se mareará y se bloqueará en alguna otra IU.

Por supuesto, no usarías JavaScript para entregar una historia, ya que la entrega como HTML es más rápida, pero este patrón es bastante común cuando se trabaja con APIs: se recuperan varios datos y, luego, se hace algo cuando todo está listo.

Para empezar, veamos cómo recuperar datos de la red:

Cómo convertir XMLHttpRequest en una promesa

Las APIs anteriores se actualizarán para usar promesas, si es posible, de forma retrocompatible. XMLHttpRequest es un candidato ideal, pero, mientras tanto, escribamos una función simple para realizar una solicitud GET:

function get(url) {
  // Return a new promise.
  return new Promise(function(resolve, reject) {
    // Do the usual XHR stuff
    var req = new XMLHttpRequest();
    req.open('GET', url);

    req.onload = function() {
      // This is called even on 404 etc
      // so check the status
      if (req.status == 200) {
        // Resolve the promise with the response text
        resolve(req.response);
      }
      else {
        // Otherwise reject with the status text
        // which will hopefully be a meaningful error
        reject(Error(req.statusText));
      }
    };

    // Handle network errors
    req.onerror = function() {
      reject(Error("Network Error"));
    };

    // Make the request
    req.send();
  });
}

Ahora, usémosla:

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.error("Failed!", error);
})

Ahora podemos realizar solicitudes HTTP sin escribir XMLHttpRequest de forma manual, lo que es genial, porque cuanto menos tenga que ver las mayúsculas mediales de XMLHttpRequest, más feliz seré.

Encadenamiento

then() no es el final de la historia, puedes encadenar then para transformar valores o ejecutar acciones asíncronas adicionales una tras otra.

Transformación de valores

Puedes transformar los valores simplemente devolviendo el valor nuevo:

var promise = new Promise(function(resolve, reject) {
  resolve(1);
});

promise.then(function(val) {
  console.log(val); // 1
  return val + 2;
}).then(function(val) {
  console.log(val); // 3
})

Como ejemplo práctico, volvamos a lo siguiente:

get('story.json').then(function(response) {
  console.log("Success!", response);
})

La respuesta es JSON, pero actualmente la recibimos como texto sin formato. Podríamos modificar nuestra función get para usar el JSON responseType, pero también podríamos resolverlo en el terreno de las promesas:

get('story.json').then(function(response) {
  return JSON.parse(response);
}).then(function(response) {
  console.log("Yey JSON!", response);
})

Como JSON.parse() toma un solo argumento y devuelve un valor transformado, podemos crear un atajo:

get('story.json').then(JSON.parse).then(function(response) {
  console.log("Yey JSON!", response);
})

De hecho, podríamos crear una función getJSON() con mucha facilidad:

function getJSON(url) {
  return get(url).then(JSON.parse);
}

getJSON() sigue devolviendo una promesa, una que recupera una URL y, luego, analiza la respuesta como JSON.

Cómo poner en cola acciones asíncronas

También puedes encadenar then para ejecutar acciones asíncronas en secuencia.

Cuando devuelves algo de una devolución de llamada then(), es un poco mágico. Si devuelves un valor, se llama al siguiente then() con ese valor. Sin embargo, si devuelves algo similar a una promesa, el siguiente then() la espera y solo se llama cuando esa promesa se resuelve (se completa o falla). Por ejemplo:

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  console.log("Got chapter 1!", chapter1);
})

Aquí, realizamos una solicitud asíncrona a story.json, que nos proporciona un conjunto de URLs para solicitar y, luego, solicitamos la primera de ellas. Aquí es cuando las promesas realmente comienzan a destacarse de los patrones de devolución de llamada simples.

Incluso puedes crear un método de acceso directo para obtener capítulos:

var storyPromise;

function getChapter(i) {
  storyPromise = storyPromise || getJSON('story.json');

  return storyPromise.then(function(story) {
    return getJSON(story.chapterUrls[i]);
  })
}

// and using it is simple:
getChapter(0).then(function(chapter) {
  console.log(chapter);
  return getChapter(1);
}).then(function(chapter) {
  console.log(chapter);
})

No descargamos story.json hasta que se llama a getChapter, pero las próximas veces que se llama a getChapter, reutilizamos la promesa de la historia, por lo que story.json solo se recupera una vez. ¡Viva las promesas!

Manejo de errores

Como vimos antes, then() toma dos argumentos, uno para el éxito y otro para el error (o cumplir y rechazar, en el lenguaje de las promesas):

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.log("Failed!", error);
})

También puedes usar catch():

get('story.json').then(function(response) {
  console.log("Success!", response);
}).catch(function(error) {
  console.log("Failed!", error);
})

No hay nada especial en catch(), es solo una forma más legible de escribir then(undefined, func). Ten en cuenta que los dos ejemplos de código anteriores no se comportan de la misma manera, el último es equivalente a lo siguiente:

get('story.json').then(function(response) {
  console.log("Success!", response);
}).then(undefined, function(error) {
  console.log("Failed!", error);
})

La diferencia es sutil, pero muy útil. Los rechazos de promesas avanzan hasta el siguiente then() con una devolución de llamada de rechazo (o catch(), ya que es equivalente). Con then(func1, func2), se llamará a func1 o func2, pero nunca a ambos. Sin embargo, con then(func1).catch(func2), se llamará a ambas si func1 rechaza, ya que son pasos independientes en la cadena. Toma lo siguiente:

asyncThing1().then(function() {
  return asyncThing2();
}).then(function() {
  return asyncThing3();
}).catch(function(err) {
  return asyncRecovery1();
}).then(function() {
  return asyncThing4();
}, function(err) {
  return asyncRecovery2();
}).catch(function(err) {
  console.log("Don't worry about it");
}).then(function() {
  console.log("All done!");
})

El flujo anterior es muy similar al try/catch normal de JavaScript, los errores que ocurren dentro de un "try" van de inmediato al bloque catch(). Aquí tienes lo anterior como un diagrama de flujo (porque me encantan los diagramas de flujo):

Sigue las líneas azules para las promesas que se cumplen o las rojas para las que se rechazan.

Excepciones y promesas de JavaScript

Los rechazos ocurren cuando una promesa se rechaza de forma explícita, pero también de forma implícita si se genera un error en la devolución de llamada del constructor:

var jsonPromise = new Promise(function(resolve, reject) {
  // JSON.parse throws an error if you feed it some
  // invalid JSON, so this implicitly rejects:
  resolve(JSON.parse("This ain't JSON"));
});

jsonPromise.then(function(data) {
  // This never happens:
  console.log("It worked!", data);
}).catch(function(err) {
  // Instead, this happens:
  console.log("It failed!", err);
})

Esto significa que es útil realizar todo el trabajo relacionado con las promesas dentro de la devolución de llamada del constructor de promesas, de modo que los errores se detecten automáticamente y se conviertan en rechazos.

Lo mismo ocurre con los errores que se producen en las devoluciones de llamada de then().

get('/').then(JSON.parse).then(function() {
  // This never happens, '/' is an HTML page, not JSON
  // so JSON.parse throws
  console.log("It worked!", data);
}).catch(function(err) {
  // Instead, this happens:
  console.log("It failed!", err);
})

Manejo de errores en la práctica

Con nuestra historia y nuestros capítulos, podemos usar catch para mostrar un error al usuario:

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  addHtmlToPage(chapter1.html);
}).catch(function() {
  addTextToPage("Failed to show chapter");
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

Si falla la recuperación de story.chapterUrls[0] (p.ej., http 500 o el usuario está sin conexión), se omitirán todas las devoluciones de llamada de éxito siguientes, incluida la de getJSON(), que intenta analizar la respuesta como JSON, y también se omitirá la devolución de llamada que agrega chapter1.html a la página. En su lugar, pasa a la devolución de llamada catch. Como resultado, se agregará "No se pudo mostrar el capítulo" a la página si falla alguna de las acciones anteriores.

Al igual que con try/catch de JavaScript, se detecta el error y el código posterior continúa, por lo que el ícono giratorio siempre se oculta, que es lo que queremos. Lo anterior se convierte en una versión asíncrona no bloqueante de lo siguiente:

try {
  var story = getJSONSync('story.json');
  var chapter1 = getJSONSync(story.chapterUrls[0]);
  addHtmlToPage(chapter1.html);
}
catch (e) {
  addTextToPage("Failed to show chapter");
}
document.querySelector('.spinner').style.display = 'none'

Es posible que quieras catch() solo con fines de registro, sin recuperarte del error. Para ello, simplemente vuelve a arrojar el error. Podríamos hacerlo en nuestro método getJSON():

function getJSON(url) {
  return get(url).then(JSON.parse).catch(function(err) {
    console.log("getJSON failed for", url, err);
    throw err;
  });
}

Logramos recuperar un capítulo, pero queremos todos. Hagamos que eso suceda.

Paralelismo y secuenciación: Obtén lo mejor de ambos

Pensar de forma asíncrona no es fácil. Si tienes dificultades para empezar, intenta escribir el código como si fuera síncrono. En este caso, ocurre lo siguiente:

try {
  var story = getJSONSync('story.json');
  addHtmlToPage(story.heading);

  story.chapterUrls.forEach(function(chapterUrl) {
    var chapter = getJSONSync(chapterUrl);
    addHtmlToPage(chapter.html);
  });

  addTextToPage("All done");
}
catch (err) {
  addTextToPage("Argh, broken: " + err.message);
}

document.querySelector('.spinner').style.display = 'none'

¡Gran idea! Sin embargo, es síncrono y bloquea el navegador mientras se descargan los elementos. Para que esto funcione de forma asíncrona, usamos then() para que las cosas sucedan una tras otra.

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // TODO: for each url in story.chapterUrls, fetch & display
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

Pero ¿cómo podemos recorrer las URLs de los capítulos y recuperarlas en orden? Esto no funciona:

story.chapterUrls.forEach(function(chapterUrl) {
  // Fetch chapter
  getJSON(chapterUrl).then(function(chapter) {
    // and add it to the page
    addHtmlToPage(chapter.html);
  });
})

forEach no reconoce la asincronía, por lo que nuestros capítulos aparecerían en el orden en que se descarguen, que es básicamente como se escribió Pulp Fiction. Esto no es Pulp Fiction, así que vamos a corregirlo.

Cómo crear una secuencia

Queremos convertir nuestro array chapterUrls en una secuencia de promesas. Podemos hacerlo con then():

// Start off with a promise that always resolves
var sequence = Promise.resolve();

// Loop through our chapter urls
story.chapterUrls.forEach(function(chapterUrl) {
  // Add these actions to the end of the sequence
  sequence = sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
})

Esta es la primera vez que vemos Promise.resolve(), que crea una promesa que se resuelve con el valor que le des. Si le pasas una instancia de Promise, simplemente la devolverá (nota: este es un cambio en la especificación que algunas implementaciones aún no siguen). Si le pasas algo similar a una promesa (tiene un método then()), crea una Promise genuina que se cumple o se rechaza de la misma manera. Si pasas cualquier otro valor, p.ej., Promise.resolve('Hello'), se crea una promesa que se cumple con ese valor. Si la llamas sin un valor, como se muestra arriba, se cumple con "undefined".

También existe Promise.reject(val), que crea una promesa que se rechaza con el valor que le asignas (o no definido).

Podemos ordenar el código anterior con array.reduce:

// Loop through our chapter urls
story.chapterUrls.reduce(function(sequence, chapterUrl) {
  // Add these actions to the end of the sequence
  return sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
}, Promise.resolve())

Esto hace lo mismo que el ejemplo anterior, pero no necesita la variable "sequence" separada. Se llama a nuestra devolución de llamada de reducción para cada elemento del array. "sequence" es Promise.resolve() la primera vez, pero para el resto de las llamadas, "sequence" es lo que se devolvió de la llamada anterior. array.reduce es muy útil para reducir un array a un solo valor, que en este caso es una promesa.

Veamos un resumen de todo lo que aprendimos:

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  return story.chapterUrls.reduce(function(sequence, chapterUrl) {
    // Once the last chapter's promise is done…
    return sequence.then(function() {
      // …fetch the next chapter
      return getJSON(chapterUrl);
    }).then(function(chapter) {
      // and add it to the page
      addHtmlToPage(chapter.html);
    });
  }, Promise.resolve());
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

Y ahí lo tenemos, una versión completamente asíncrona de la versión síncrona. Pero podemos hacerlo mejor. En este momento, nuestra página se descarga de la siguiente manera:

Los navegadores son bastante buenos para descargar varios elementos a la vez, por lo que perdemos rendimiento si descargamos los capítulos uno tras otro. Lo que queremos hacer es descargarlas todas al mismo tiempo y, luego, procesarlas cuando hayan llegado todas. Afortunadamente, hay una API para esto:

Promise.all(arrayOfPromises).then(function(arrayOfResults) {
  //...
})

Promise.all toma un array de promesas y crea una promesa que se cumple cuando todas se completan correctamente. Obtienes un array de resultados (independientemente de lo que hayan cumplido las promesas) en el mismo orden que las promesas que pasaste.

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // Take an array of promises and wait on them all
  return Promise.all(
    // Map our array of chapter urls to
    // an array of chapter json promises
    story.chapterUrls.map(getJSON)
  );
}).then(function(chapters) {
  // Now we have the chapters jsons in order! Loop through…
  chapters.forEach(function(chapter) {
    // …and add to the page
    addHtmlToPage(chapter.html);
  });
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened so far
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

Según la conexión, esto puede ser segundos más rápido que cargar uno por uno, y es menos código que nuestro primer intento. Los capítulos se pueden descargar en cualquier orden, pero aparecen en la pantalla en el orden correcto.

Sin embargo, aún podemos mejorar el rendimiento percibido. Cuando llegue el capítulo uno, debemos agregarlo a la página. Esto permite que el usuario comience a leer antes de que lleguen los demás capítulos. Cuando llegue el capítulo tres, no lo agregaremos a la página porque es posible que el usuario no se dé cuenta de que falta el capítulo dos. Cuando llegue el capítulo dos, podremos agregar los capítulos dos y tres, etcétera.

Para ello, recuperamos el JSON de todos nuestros capítulos al mismo tiempo y, luego, creamos una secuencia para agregarlos al documento:

getJSON('story.json')
.then(function(story) {
  addHtmlToPage(story.heading);

  // Map our array of chapter urls to
  // an array of chapter json promises.
  // This makes sure they all download in parallel.
  return story.chapterUrls.map(getJSON)
    .reduce(function(sequence, chapterPromise) {
      // Use reduce to chain the promises together,
      // adding content to the page for each chapter
      return sequence
      .then(function() {
        // Wait for everything in the sequence so far,
        // then wait for this chapter to arrive.
        return chapterPromise;
      }).then(function(chapter) {
        addHtmlToPage(chapter.html);
      });
    }, Promise.resolve());
}).then(function() {
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

Y listo, lo mejor de ambos mundos. Se tarda la misma cantidad de tiempo en entregar todo el contenido, pero el usuario obtiene el primer fragmento de contenido antes.

En este ejemplo trivial, todos los capítulos llegan casi al mismo tiempo, pero el beneficio de mostrar uno a la vez se exagerará con más capítulos y más grandes.

Hacer lo anterior con devoluciones de llamada o eventos de estilo Node.js implica alrededor del doble de código, pero, lo que es más importante, no es tan fácil de seguir. Sin embargo, esta no es la historia completa de las promesas, ya que, cuando se combinan con otras funciones de ES6, se vuelven aún más fáciles.

Ronda adicional: Capacidades ampliadas

Desde que escribí este artículo, la capacidad de usar promesas se expandió enormemente. Desde Chrome 55, las funciones asíncronas permiten que el código basado en promesas se escriba como si fuera síncrono, pero sin bloquear el subproceso principal. Puedes obtener más información al respecto en mi artículo sobre funciones asíncronas. Existe una compatibilidad generalizada con las promesas y las funciones asíncronas en los navegadores principales. Puedes encontrar los detalles en la referencia de Promise y async function de MDN.

Muchas gracias a Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans y Yutaka Hirano, quienes revisaron este artículo y realizaron correcciones y recomendaciones.

Además, agradecemos a Mathias Bynens por actualizar varias partes del artículo.