Promesas de JavaScript: introducción

Las promesas simplifican los procesamientos diferidos y asíncronos. Una promesa representa una operación que aún no se ha completado.

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

[Redoble de tambores]

¡Las promesas llegaron a JavaScript!

[Fuegos artificiales, lluvia de papeles y exaltación de la multitud]

En este momento, perteneces a una de estas categorías:

  • Las personas celebran a tu alrededor, pero no sabes con certeza por qué se trata todo el alboroto. Tal vez ni siquiera sepas qué es una "promesa". Te encogerías de hombros, pero el peso del papel resplandeciente te pesa. Si es así, no te preocupes, me tomó años descubrir por qué todo esto debería importarme. Recomendamos que empieces por el principio.
  • Le das un puño en el aire. Era hora, ¿verdad? Ya usaste estas promesas, pero te molesta que todas las implementaciones tengan una API levemente diferente. ¿Cuál es la API de la versión oficial de JavaScript? Te recomendamos comenzar por la terminología.
  • Ya sabías todo esto y te burlas de los que están saltando de felicidad porque recién se enteran. Tómate un momento para disfrutar de tu propia superioridad y, luego, dirígete a la referencia de la API.

Compatibilidad con navegadores y polyfill

Navegadores compatibles

  • 32
  • 12
  • 29
  • 8

Origen

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

¿Por qué tanto escándalo?

JavaScript es de un solo subproceso, lo que significa que dos bits de secuencia de comandos no se pueden ejecutar al mismo tiempo, sino que se deben ejecutar uno después del otro. En los navegadores, JavaScript comparte un subproceso con una carga de otros elementos que difieren de un navegador a otro. Sin embargo, por lo general, JavaScript está en la misma cola que la pintura, la actualización de diseños y el control de las acciones del usuario (como destacar texto o interactuar con controles de formularios). La actividad en uno de estos elementos retrasa los otros.

Como ser humano, tienes varios subprocesos. Puedes escribir con varios dedos o conducir y mantener una conversación al mismo tiempo. La única función que nos bloquea es el estornudo, en el que toda la actividad en curso debe suspenderse durante un estornudo. Eso es bastante molesto, en especial cuando conduces y tratas de llevar una conversación. No debes escribir código que produzca estornudos.

Probablemente hayas utilizado eventos y devoluciones de llamadas para evitarlo. Estos son los eventos:

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

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

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

No hay estornudos. Obtenemos la imagen, agregamos algunos objetos de escucha y JavaScript puede dejar de ejecutarse hasta que se llame a uno de ellos.

Lamentablemente, en el ejemplo anterior, es posible que los eventos hayan ocurrido antes de que comencemos a escucharlos, por lo que debemos solucionar ese problema usando 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
});

No se detectan las imágenes que se produjeron un error antes de que pudiéramos escucharlas. Lamentablemente, el DOM no nos brinda una manera de hacerlo. Además, es cargar una imagen. La complejidad aumenta 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 excelentes para lo que puede suceder varias veces en el mismo objeto: keyup, touchstart, etc. Con esos eventos, no es importante saber lo que sucedió antes de adjuntar el objeto de escucha. Sin embargo, si se trata de éxito o fracaso de procesos asíncronos, idealmente, querrás algo así:

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 nomenclatura. Si los elementos de imagen HTML tuvieran un método "listo" que mostrara 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
});

Básicamente, las promesas se parecen un poco a los objetos de escucha de eventos, excepto por lo siguiente:

  • Una promesa solo puede completarse con éxito o fallar una vez. No puede completarse con éxito o fallar dos veces, ni puede pasar de exitosa a fallida ni 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, aunque el evento haya ocurrido antes.

Esto es extremadamente útil para el éxito o el fracaso de procesos asíncronos, ya que no te interesa tanto el momento exacto en el que algo estuvo disponible, sino más bien reaccionar al resultado.

Terminología relacionada con las promesas

Domenic Denicola leyó el primer borrador de este artículo y obtuve un “Desaprobado” en terminología. Me puso en prisión, me obligó a copiar States y Fates 100 veces y escribió una carta preocupada a mis padres. A pesar de eso, sigo confundiendo la terminología, pero estos son los conceptos básicos:

Una promesa puede ser de las siguientes maneras:

  • fulfill: la acción relacionada con la promesa que se completó correctamente
  • rejected: la acción relacionada con la promesa no aprobada.
  • pending: Aún no se completa ni se rechaza.
  • settled: Se completó o se rechazó.

En la especificación, también se usa el término thenable para describir un objeto parecido a una promesa porque tiene un método then. Este término me recuerda a Terry Venables, exentrenador de fútbol inglés, así que lo usaré lo menos posible.

Las promesas llegan a JavaScript.

Hace tiempo que las promesas existen en forma de bibliotecas, por ejemplo:

Estas bibliotecas y las promesas de JavaScript comparten un comportamiento común y estandarizado llamado Promises/A+. Si eres usuario de jQuery, tendrán algo similar llamado Deferreds. Sin embargo, Deferred no cumple con Promise/A+, por lo que es sutilmente diferente y menos útil, 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 las promesas siguen un comportamiento estandarizado, las APIs generales son diferentes. Las APIs de las promesas de JavaScript son similares a las de Confirm.js. Se crea una promesa de la siguiente manera:

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 la promesa recibe un argumento: una devolución de llamada con dos parámetros, resolución y rechazo. Haz algo dentro de la devolución de llamada (tal vez un proceso asíncrono) y, luego, llama a resolve si todo funcionó. De lo contrario, llama a remember.

Al igual que con throw en el JavaScript que todos conocemos, es habitual, aunque no es obligatorio, rechazarlo con un objeto Error. La ventaja de los objetos Error es que capturan un seguimiento de pila, lo que hace que las herramientas de depuración sean más útiles.

Así es como se usa 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 cuando se tiene éxito y otro para cuando se produce una falla. Ambos son opcionales, por lo que puedes agregar una devolución de llamada solo para los casos de éxito o falla.

Las promesas de JavaScript comenzaron en el DOM como "Futures", se les cambió el nombre a "Promise" y, finalmente, se trasladaron a JavaScript. Es excelente tenerlas en JavaScript en lugar del DOM porque estarán disponibles en contextos de JS sin navegador, como Node.js (si las usan en sus APIs principales, es otra cuestión).

Si bien son funciones de JavaScript, el DOM las usa sin problemas. De hecho, todas las nuevas APIs de DOM con métodos de éxito o falla asíncrono usan promesas. Esto ya sucede con la Administración de cuotas, los Eventos de carga de fuentes, los ServiceWorker, el MIDI web, las Transmisiones 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 suspiro de una promesa), por lo que, si usas una biblioteca que muestre una promesa Q, no habrá problemas con las nuevas promesas de JavaScript.

Sin embargo, como mencioné, los Deferreds de jQuery son un poco... inútiles. Por suerte, puedes transmitirlos a las promesas estándar. Vale la pena hacerlo lo antes posible:

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

Aquí, $.ajax de jQuery muestra un elemento Deferred. Dado que tiene un método then(), Promise.resolve() puede convertirlo en una promesa de JavaScript. Sin embargo, a veces los deferred 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) {
  // ...
})

En cambio, las promesas de JS ignoran todos menos el primero:

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

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

Código asíncrono complejo más fácil

Vamos a programar algunas cosas. Supongamos que queremos hacer lo siguiente:

  1. Iniciar un ícono giratorio para indicar que se está cargando
  2. Obtener algunos JSON de una historia, que nos proporciona el título y las URLs de cada capítulo
  3. Agregar título a la página
  4. Obtener cada capítulo
  5. Agregar la historia a la página
  6. Detener el ícono giratorio

... pero también decirle al usuario si algo salió mal en el camino. También queremos detener el ícono giratorio en ese momento; de lo contrario, seguirá girando, se mareará y fallará en otra IU.

Desde luego, no deberías usar JavaScript para publicar una historia, ya que el HTML es más rápido, pero este patrón es bastante común cuando se trabaja con APIs: varias recuperaciones de datos y, luego, haz una acción cuando todo esté listo.

Para comenzar, analicemos la recuperación de datos desde la red:

Promesas en XMLHttpRequest

Las APIs anteriores se actualizarán para usar promesas y si es posible, se hará de una manera retrocompatible. XMLHttpRequest es una gran candidata. 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 hacer solicitudes HTTP sin escribir XMLHttpRequest manualmente, lo cual es genial, ya que cuanto menos tenga que ver la exasperante tipografía de XMLHttpRequest, más feliz será mi vida.

Encadenamiento

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

Transforma valores

Puedes transformar valores con solo mostrar 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 al siguiente:

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

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

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

Dado que JSON.parse() toma un solo argumento y muestra 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 hacer una función getJSON() muy fácilmente:

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

getJSON() sigue mostrando 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 elementos then para ejecutar acciones asíncronas en secuencia.

Cuando muestras algo de una devolución de llamada de then(), es algo mágico. Si muestras un valor, se llama al siguiente then() con ese valor. Sin embargo, si muestras algo similar a una promesa, el siguiente then() lo esperará y solo se lo llamará cuando esa promesa se detenga (se completa correctamente o falla). Por ejemplo:

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

En este caso, hacemos una solicitud asíncrona a story.json, que nos proporciona un conjunto de URLs para solicitar. Luego, solicitamos la primera de ellas. En este momento, las promesas comienzan a destacarse frente a los patrones de devolución de llamada simples.

Incluso puedes crear un método abreviado 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 descargaremos story.json hasta que se llame a getChapter, pero las próximas veces que se llame a getChapter, reutilizaremos la promesa de la historia, por lo que story.json solo se recuperará una vez. ¡Buenas promesas!

Manejo de errores

Como vimos antes, then() toma dos argumentos: uno por éxito y otro por falla (o completado y rechazado, hablando de 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);
})

catch() no tiene nada de especial, es azúcar para then(undefined, func), pero es más legible. Ten en cuenta que los dos ejemplos de códigos anteriores no se comportan de la misma manera; el último equivale 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 extremadamente útil. Los rechazos de promesas avanzan al 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, nunca a ambos. Sin embargo, con then(func1).catch(func2), se llamará a ambos si se rechaza func1, ya que son pasos separados de la cadena. Supongamos 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 en un "try" van inmediatamente al bloque catch(). He aquí lo anterior en un diagrama de flujo (porque me encantan estos diagramas):

Sigue las líneas azules para las promesas que se cumplan o las rojas para las que se rechacen.

Excepciones y promesas de JavaScript

Los rechazos ocurren cuando se rechaza una promesa de forma explícita, pero también de forma implícita si se produce 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 la promesa, para que los errores se detecten automáticamente y se conviertan en rechazos.

Lo mismo sucede con los errores arrojados en las devoluciones de llamada 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 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 siguientes devoluciones de llamada exitosas, incluida la de getJSON(), que intenta analizar la respuesta como JSON, y también omite la devolución de llamada que agrega capítulos1.html a la página. En cambio, se trasladará a la devolución de llamada de catch. Como resultado, se agregará "No se pudo mostrar el capítulo" a la página si falla alguna de las acciones anteriores.

Como try/catch de JavaScript, el error se detecta y el código posterior continúa, por lo que el ícono giratorio siempre está oculto, que es lo que queremos. Lo anterior se convierte en una versión asíncrona y sin bloqueo 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 desees catch() solo con fines de registro, sin recuperarte del error. Para hacer esto, solo vuelve a arrojar el error. Podríamos hacer esto 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;
  });
}

Hemos logrado obtener un capítulo, pero queremos verlos todos. Hagamos que eso suceda.

Paralelismo y secuencia: sacar lo mejor de ambos

No es fácil pensar en asíncrono. Si tienes problemas para dar el primer paso, 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'

Sí, funciona. Pero es sincronizado y bloquea el navegador mientras se descargan. A fin de que este trabajo sea asíncrono, usamos then() para que las acciones 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 explorar las URLs de los capítulos y obtenerlas 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 es asíncrono, por lo que nuestros capítulos aparecerían en el orden en que se descargan, que es, en esencia, la forma en que se redactó Pulp Investigation. Esto no es de Pulp desees, así que solucionémoslo.

Crea 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 en el valor que le proporciones. Si le pasas una instancia de Promise, simplemente se mostrará (Nota: Se trata de 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 un Promise genuino que se cumple/rechaza de la misma manera. Si pasas cualquier otro valor, p.ej., Promise.resolve('Hello'), crea una promesa que se cumple con ese valor. Si la llamas sin ningún valor, como lo hicimos antes, se completa con "undefined".

También está Promise.reject(val), que crea una promesa que se rechaza con el valor que le otorgas (o "indefinido).

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

Cumple la misma función que el ejemplo anterior, pero no necesita la variable "secuencia" separada. Se llama a nuestra devolución de llamada “reduce” para cada elemento del array. "secuencia" es Promise.resolve() la primera vez, pero para el resto de las llamadas, "secuencia" es lo que hayamos devuelto de la llamada anterior. array.reduce es muy útil para reducir un array a un solo valor, lo que, en este caso, es una promesa.

Reunámoslo todo:

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í la tenemos, una versión completamente asíncrona de la versión síncrona. Pero podemos hacerlo mejor. En este momento, nuestra página realiza descargas de la siguiente forma:

Los navegadores son bastante buenos para descargar varias cosas a la vez, por lo que si descargamos capítulos uno tras otro, el rendimiento se pierde. Lo que queremos hacer es descargarlos todos al mismo tiempo y procesarlos cuando todos hayan terminado de descargarse. Afortunadamente, existe 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 con éxito. Se obtiene un array de resultados (independientemente de lo que se cumpla la promesa) 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 llega el capítulo uno, debemos agregarlo a la página. Esto permite que el usuario comience a leer antes de que llegue el resto de los capítulos. Cuando llega el capítulo tres, no lo agregamos a la página porque es posible que el usuario no se dé cuenta de que falta el capítulo dos. Cuando llega el capítulo dos, podemos agregar el capítulo dos, tres, etc.

Para hacerlo, recuperamos JSON para 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';
})

Listo, ¡lo mejor de ambos! Se tarda lo mismo en entregar todo el contenido, pero el usuario obtiene la primera parte 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 capítulos más grandes.

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

Ronda adicional: Capacidades expandidas

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

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

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