Trucos nuevos en XMLHttpRequest2

Introducción

Uno de los héroes anónimos del universo HTML5 es XMLHttpRequest. Estrictamente hablando, XHR2 no es HTML5. Sin embargo, forma parte de las mejoras incrementales que los proveedores de navegadores realizan en la plataforma principal. Incluimos XHR2 en nuestro nuevo paquete de funciones porque es una parte integral de las apps web complejas de hoy en día.

Resulta que nuestro viejo amigo se sometió a un gran cambio de imagen, pero muchas personas no conocen sus nuevas funciones. XMLHttpRequest de nivel 2 introduce una gran cantidad de funciones nuevas que ponen fin a los hacks complicados en nuestras apps web, como solicitudes entre orígenes, carga de eventos de progreso y compatibilidad con la carga o descarga de datos binarios. Estos permiten que AJAX trabaje en conjunto con muchas de las APIs de HTML5 de vanguardia, como la API de File System, la API de Web Audio y WebGL.

En este instructivo, se destacan algunas de las funciones nuevas de XMLHttpRequest, especialmente las que se pueden usar para trabajar con archivos.

Obteniendo datos

Recuperar un archivo como un blob binario ha sido un proceso doloroso con XHR. Técnicamente, ni siquiera era posible. Un truco que está bien documentado implica anular el tipo mime con un conjunto de caracteres definido por el usuario, como se ve a continuación.

La forma anterior de recuperar una imagen:

var xhr = new XMLHttpRequest();
xhr.open('GET', '/path/to/image.png', true);

// Hack to pass bytes through unprocessed.
xhr.overrideMimeType('text/plain; charset=x-user-defined');

xhr.onreadystatechange = function(e) {
  if (this.readyState == 4 && this.status == 200) {
    var binStr = this.responseText;
    for (var i = 0, len = binStr.length; i < len; ++i) {
      var c = binStr.charCodeAt(i);
      //String.fromCharCode(c & 0xff);
      var byte = c & 0xff;  // byte at offset i
    }
  }
};

xhr.send();

Si bien esto funciona, lo que realmente obtienes en responseText no es un blob binario. Es una cadena binaria que representa el archivo de imagen. Estamos engañando al servidor para que devuelva los datos sin procesar. Aunque esta pequeña joya funciona, la llamaré magia negra y no la recomiendo. Cada vez que recurras a hacks de códigos de caracteres y manipulación de cadenas para forzar los datos a un formato deseado, será un problema.

Especifica un formato de respuesta

En el ejemplo anterior, descargamos la imagen como un “archivo” binario anulando el tipo MIME del servidor y procesando el texto de la respuesta como una cadena binaria. En su lugar, aprovechemos las nuevas propiedades responseType y response de XMLHttpRequest para informarle al navegador en qué formato queremos que se muestren los datos.

xhr.responseType
Antes de enviar una solicitud, establece xhr.responseType en "text", "arraybuffer", "blob" o "document", según tus necesidades de datos. Ten en cuenta que, si configuras xhr.responseType = '' (o lo omites), la respuesta se establecerá de forma predeterminada en "text".
xhr.response
Después de una solicitud correcta, la propiedad de respuesta de xhr contendrá los datos solicitados como DOMString, ArrayBuffer, Blob o Document (según lo que se haya establecido para responseType).

Con esta nueva función, podemos volver a trabajar en el ejemplo anterior, pero esta vez, recuperar la imagen como un Blob en lugar de una cadena:

var xhr = new XMLHttpRequest();
xhr.open('GET', '/path/to/image.png', true);
xhr.responseType = 'blob';

xhr.onload = function(e) {
  if (this.status == 200) {
    // Note: .response instead of .responseText
    var blob = new Blob([this.response], {type: 'image/png'});
    ...
  }
};

xhr.send();

¡Mucho mejor!

Respuestas de ArrayBuffer

Un ArrayBuffer es un contenedor genérico de longitud fija para datos binarios. Son muy útiles si necesitas un búfer generalizado de datos sin procesar, pero el verdadero poder detrás de estos tipos es que puedes crear "vistas" de los datos subyacentes con arrays tipados de JavaScript. De hecho, se pueden crear varias vistas a partir de una sola fuente de ArrayBuffer. Por ejemplo, puedes crear un array de números enteros de 8 bits que comparta el mismo ArrayBuffer que un array de números enteros de 32 bits existente a partir de los mismos datos. Los datos subyacentes siguen siendo los mismos, solo creamos diferentes representaciones de ellos.

A modo de ejemplo, el siguiente comando recupera nuestra misma imagen como un ArrayBuffer, pero esta vez, crea un array de números enteros de 8 bits sin firmar a partir de ese búfer de datos:

var xhr = new XMLHttpRequest();
xhr.open('GET', '/path/to/image.png', true);
xhr.responseType = 'arraybuffer';

xhr.onload = function(e) {
  var uInt8Array = new Uint8Array(this.response); // this.response == uInt8Array.buffer
  // var byte3 = uInt8Array[4]; // byte at offset 4
  ...
};

xhr.send();

Respuestas de BLOB

Si quieres trabajar directamente con un Blob o no necesitas manipular ninguno de los bytes del archivo, usa xhr.responseType='blob':

window.URL = window.URL || window.webkitURL;  // Take care of vendor prefixes.

var xhr = new XMLHttpRequest();
xhr.open('GET', '/path/to/image.png', true);
xhr.responseType = 'blob';

xhr.onload = function(e) {
  if (this.status == 200) {
    var blob = this.response;

    var img = document.createElement('img');
    img.onload = function(e) {
      window.URL.revokeObjectURL(img.src); // Clean up after yourself.
    };
    img.src = window.URL.createObjectURL(blob);
    document.body.appendChild(img);
    ...
  }
};

xhr.send();

Se puede usar un Blob en varios lugares, como guardarlo en indexedDB, escribirlo en el sistema de archivos de HTML5 o crear una URL de Blob, como se ve en este ejemplo.

Envío de datos

Poder descargar datos en diferentes formatos es excelente, pero no nos lleva a ninguna parte si no podemos enviar estos formatos enriquecidos a la base (el servidor). XMLHttpRequest nos limitó a enviar datos DOMString o Document (XML) durante algún tiempo. Ya no. Se anuló un método send() renovado para aceptar cualquiera de los siguientes tipos: DOMString, Document, FormData, Blob, File y ArrayBuffer. En los ejemplos del resto de esta sección, se muestra cómo enviar datos con cada tipo.

Envío de datos de cadena: xhr.send(DOMString)

function sendText(txt) {
  var xhr = new XMLHttpRequest();
  xhr.open('POST', '/server', true);
  xhr.onload = function(e) {
    if (this.status == 200) {
      console.log(this.responseText);
    }
  };

  xhr.send(txt);
}

sendText('test string');
function sendTextNew(txt) {
  var xhr = new XMLHttpRequest();
  xhr.open('POST', '/server', true);
  xhr.responseType = 'text';
  xhr.onload = function(e) {
    if (this.status == 200) {
      console.log(this.response);
    }
  };
  xhr.send(txt);
}

sendTextNew('test string');

No hay nada nuevo aquí, aunque el fragmento correcto es ligeramente diferente. Establece responseType='text' para la comparación. Una vez más, omitir esa línea genera los mismos resultados.

Envío de formularios: xhr.send(FormData)

Es probable que muchas personas estén acostumbradas a usar complementos de jQuery o de otras bibliotecas para controlar los envíos de formularios AJAX. En su lugar, podemos usar FormData, otro tipo de datos nuevo concebido para XHR2. FormData es conveniente para crear un <form> HTML sobre la marcha en JavaScript. Luego, se puede enviar ese formulario con AJAX:

function sendForm() {
  var formData = new FormData();
  formData.append('username', 'johndoe');
  formData.append('id', 123456);

  var xhr = new XMLHttpRequest();
  xhr.open('POST', '/server', true);
  xhr.onload = function(e) { ... };

  xhr.send(formData);
}

En esencia, solo estamos creando un <form> de forma dinámica y agregándole valores <input> llamando al método append.

Por supuesto, no es necesario que crees un <form> desde cero. Los objetos FormData se pueden inicializar desde un HTMLFormElement existente en la página. Por ejemplo:

<form id="myform" name="myform" action="/server">
  <input type="text" name="username" value="johndoe">
  <input type="number" name="id" value="123456">
  <input type="submit" onclick="return sendForm(this.form);">
</form>
function sendForm(form) {
  var formData = new FormData(form);

  formData.append('secret_token', '1234567890'); // Append extra data before send.

  var xhr = new XMLHttpRequest();
  xhr.open('POST', form.action, true);
  xhr.onload = function(e) { ... };

  xhr.send(formData);

  return false; // Prevent page from submitting.
}

Un formulario HTML puede incluir cargas de archivos (p.ej., <input type="file">) y FormData también puede controlar eso. Simplemente adjúntalos y el navegador compilará una solicitud multipart/form-data cuando se llame a send():

function uploadFiles(url, files) {
  var formData = new FormData();

  for (var i = 0, file; file = files[i]; ++i) {
    formData.append(file.name, file);
  }

  var xhr = new XMLHttpRequest();
  xhr.open('POST', url, true);
  xhr.onload = function(e) { ... };

  xhr.send(formData);  // multipart/form-data
}

document.querySelector('input[type="file"]').addEventListener('change', function(e) {
  uploadFiles('/server', this.files);
}, false);

Sube un archivo o un BLOB: xhr.send(Blob)

También podemos enviar datos de File o Blob con XHR. Ten en cuenta que todos los File son Blob, por lo que cualquiera funciona aquí.

En este ejemplo, se crea un archivo de texto nuevo desde cero con el constructor Blob() y se sube ese Blob al servidor. El código también configura un controlador para informar al usuario sobre el progreso de la carga:

<progress min="0" max="100" value="0">0% complete</progress>
function upload(blobOrFile) {
  var xhr = new XMLHttpRequest();
  xhr.open('POST', '/server', true);
  xhr.onload = function(e) { ... };

  // Listen to the upload progress.
  var progressBar = document.querySelector('progress');
  xhr.upload.onprogress = function(e) {
    if (e.lengthComputable) {
      progressBar.value = (e.loaded / e.total) * 100;
      progressBar.textContent = progressBar.value; // Fallback for unsupported browsers.
    }
  };

  xhr.send(blobOrFile);
}

upload(new Blob(['hello world'], {type: 'text/plain'}));

Carga de un fragmento de bytes: xhr.send(ArrayBuffer)

Por último, pero no menos importante, podemos enviar ArrayBuffer como la carga útil de XHR.

function sendArrayBuffer() {
  var xhr = new XMLHttpRequest();
  xhr.open('POST', '/server', true);
  xhr.onload = function(e) { ... };

  var uInt8Array = new Uint8Array([1, 2, 3]);

  xhr.send(uInt8Array.buffer);
}

Uso compartido de recursos entre dominios (CORS)

CORS permite que las aplicaciones web de un dominio realicen solicitudes AJAX entre dominios a otro dominio. Es muy fácil de habilitar, ya que solo requiere que el servidor envíe un solo encabezado de respuesta.

Habilita las solicitudes de CORS

Supongamos que tu aplicación se encuentra en example.com y quieres extraer datos de www.example2.com. Por lo general, si intentaras realizar este tipo de llamada AJAX, la solicitud fallaría y el navegador arrojaría un error de discrepancia de origen. Con CORS, www.example2.com puede permitir solicitudes de example.com simplemente agregando un encabezado:

Access-Control-Allow-Origin: http://example.com

Access-Control-Allow-Origin se puede agregar a un solo recurso en un sitio o en todo el dominio. Para permitir que cualquier dominio te realice una solicitud, configura lo siguiente:

Access-Control-Allow-Origin: *

De hecho, este sitio (html5rocks.com) habilitó CORS en todas sus páginas. Inicia las herramientas para desarrolladores y verás el Access-Control-Allow-Origin en nuestra respuesta:

Encabezado Access-Control-Allow-Origin en html5rocks.com
Encabezado "Access-Control-Allow-Origin" en html5rocks.com

Habilitar las solicitudes de origen cruzado es fácil, así que habilita CORS si tus datos son públicos.

Cómo realizar una solicitud entre dominios

Si el extremo del servidor habilitó el CORS, realizar la solicitud de origen cruzado no es diferente de una solicitud XMLHttpRequest normal. Por ejemplo, esta es una solicitud que example.com ahora puede realizar a www.example2.com:

var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://www.example2.com/hello.json');
xhr.onload = function(e) {
  var data = JSON.parse(this.response);
  ...
}
xhr.send();

Ejemplos prácticos

Descarga y guarda archivos en el sistema de archivos HTML5

Supongamos que tienes una galería de imágenes y quieres recuperar un montón de imágenes y, luego, guardarlas de forma local con el sistema de archivos HTML5. Una forma de lograrlo sería solicitar imágenes como Blob y escribirlas con FileWriter:

window.requestFileSystem  = window.requestFileSystem || window.webkitRequestFileSystem;

function onError(e) {
  console.log('Error', e);
}

var xhr = new XMLHttpRequest();
xhr.open('GET', '/path/to/image.png', true);
xhr.responseType = 'blob';

xhr.onload = function(e) {

  window.requestFileSystem(TEMPORARY, 1024 * 1024, function(fs) {
    fs.root.getFile('image.png', {create: true}, function(fileEntry) {
      fileEntry.createWriter(function(writer) {

        writer.onwrite = function(e) { ... };
        writer.onerror = function(e) { ... };

        var blob = new Blob([xhr.response], {type: 'image/png'});

        writer.write(blob);

      }, onError);
    }, onError);
  }, onError);
};

xhr.send();

Dividir un archivo y subir cada parte

Con las APIs de archivos, podemos minimizar el trabajo para subir un archivo grande. La técnica consiste en dividir la carga en varios fragmentos, generar un XHR para cada parte y unir el archivo en el servidor. Esto es similar a la forma en que Gmail sube archivos adjuntos grandes con tanta rapidez. Esta técnica también se podría usar para evitar el límite de solicitudes HTTP de 32 MB de Google App Engine.

function upload(blobOrFile) {
  var xhr = new XMLHttpRequest();
  xhr.open('POST', '/server', true);
  xhr.onload = function(e) { ... };
  xhr.send(blobOrFile);
}

document.querySelector('input[type="file"]').addEventListener('change', function(e) {
  var blob = this.files[0];

  const BYTES_PER_CHUNK = 1024 * 1024; // 1MB chunk sizes.
  const SIZE = blob.size;

  var start = 0;
  var end = BYTES_PER_CHUNK;

  while(start < SIZE) {
    upload(blob.slice(start, end));

    start = end;
    end = start + BYTES_PER_CHUNK;
  }
}, false);

})();

Lo que no se muestra aquí es el código para reconstruir el archivo en el servidor.

Referencias