Promesas de JavaScript: introducción

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

Prepárense, desarrolladores, para un momento crucial en la historia del desarrollo web.

[Comienza el redoble de tambores]

¡Las promesas llegaron a JavaScript!

[Explotan fuegos artificiales, cae papel brillante desde arriba, el público se vuelve loco]

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

  • Las personas te vitorean, pero no sabes por qué. Quizás ni siquiera sepas qué es una "promesa". Te encogerías de hombros, pero el peso del papel brillante te oprime los hombros. Si es así, no te preocupes, me llevó mucho tiempo entender por qué debería preocuparme por estas cosas. Probablemente quieras comenzar por el principio.
  • ¡Golpeas el aire! Ya era hora, ¿verdad? Ya usaste estas cosas de Promise antes, pero te molesta que todas las implementaciones tengan una API ligeramente diferente. ¿Cuál es la API para la versión oficial de JavaScript? Probablemente quieras comenzar con la terminología.
  • Ya lo sabías y te burlas de quienes saltan de alegría como si fuera una novedad. Tómate un momento para disfrutar de tu propia superioridad y, luego, ve 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 bien para agregar promesas a otros navegadores y a Node.js, consulta el polyfill (2 KB comprimido con gzip).

¿De qué se trata todo este alboroto?

JavaScript es de un solo subproceso, lo que significa que dos fragmentos de secuencia de comandos no pueden ejecutarse al mismo tiempo, sino que deben ejecutarse uno después del otro. En los navegadores, JavaScript comparte un subproceso con una gran cantidad de otros elementos que difieren 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 la interacción con los controles de formularios). La actividad en uno de estos elementos retrasa los demás.

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

Probablemente hayas usado eventos y devoluciones de llamada para solucionar este problema. 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 me da alergia. Obtenemos la imagen, agregamos un par de objetos de escucha y, luego, JavaScript puede dejar de ejecutarse 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 situaciones que pueden ocurrir varias veces en el mismo objeto (keyup, touchstart, etcétera). Con esos eventos, no te importa lo que sucedió antes de que adjuntaras el objeto de escucha. Pero cuando se trata de éxito o falla asíncronos, lo ideal es que quieras algo como lo siguiente:

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 mejores nombres. Si los elementos de imagen HTML tuvieran un método "listo" 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 completarse correctamente o fallar una vez. No puede tener éxito o fallar dos veces, ni puede cambiar de éxito a falla o viceversa.
  • Si una promesa se completó correctamente o falló y, más adelante, agregas una devolución de llamada de éxito o error, se llamará a la devolución de llamada correcta, aunque el evento haya tenido lugar antes.

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

Terminología de promesas

Domenic Denicola corrigió la primera versión 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 preocupada a mis padres. A pesar de eso, todavía confundo muchos términos, pero aquí tienes los conceptos básicos:

Una promesa puede ser lo siguiente:

  • fulfilled: La acción relacionada con la promesa se completó correctamente.
  • rejected: La acción relacionada con la promesa falló.
  • pendiente: Aún no se cumplió o rechazó.
  • settled: Se cumplió o 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 exentrenador de la selección inglesa de fútbol Terry Venables, por lo que lo usaré lo menos posible.

¡Las promesas llegan 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 estandarizado común 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 ligeramente 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.

Si bien las implementaciones de promesas siguen un comportamiento estandarizado, sus APIs generales difieren. Las promesas de JavaScript son similares en la API a RSVP.js. Sigue estos pasos para crear 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 la promesa 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ó o, de lo contrario, llama a reject.

Al igual que 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.

Así es como usas 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 el caso de falla. Ambos son opcionales, por lo que puedes agregar una devolución de llamada solo para el caso de éxito o de falla.

Las promesas de JavaScript comenzaron en el DOM como "Futures", se renombraron como "Promises" y, finalmente, se trasladaron a JavaScript. Tenerlos en JavaScript en lugar del DOM es genial porque estarán disponibles en contextos de JS que no son 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 teme usarlos. De hecho, todas las APIs del DOM nuevas con métodos asíncronos de éxito o error usarán promesas. Esto ya sucede con Quota Management, Font Load Events, ServiceWorker, Web MIDI, Streams y muchos más.

Compatibilidad con otras bibliotecas

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

Aunque, como mencioné, los objetos Deferred de jQuery son un poco… inútiles. Afortunadamente, puedes convertir estos objetos 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 objeto Deferred. Como tiene un método then(), Promise.resolve() puede convertirlo en una promesa de JavaScript. Sin embargo, a veces, los objetos 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) {
  // ...
})

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

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

Afortunadamente, 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, codifiquemos algunas cosas. Supongamos que queremos hacer lo siguiente:

  1. Inicia un spinner para indicar la carga
  2. Recupera algo de JSON para un cuento, lo 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 spinner

… pero también infórmale al usuario si algo salió mal en el proceso. También deberemos detener el spinner en ese punto, ya que, de lo contrario, seguirá girando, se mareará y chocará con otra IU.

Por supuesto, no usarías JavaScript para entregar una historia, ya que servir como HTML es más rápido, 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 comenzar, veamos cómo recuperar datos de la red:

Cómo convertir XMLHttpRequest en una promesa

Las APIs antiguas se actualizarán para usar promesas, si es posible de forma retrocompatible. XMLHttpRequest es un candidato principal, 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émoslo:

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 cual es genial, ya que cuanto menos tenga que ver el exasperante uso de mayúsculas y minúsculas de XMLHttpRequest, más feliz será mi vida.

Encadenamiento

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

Transformación de valores

Puedes transformar 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