Trucos nuevos en XMLHttpRequest2

Introducción

Uno de los héroes olvidados del universo de HTML5 es XMLHttpRequest. En sentido estricto, XHR2 no es HTML5. Sin embargo, forma parte de las mejoras incrementales que realizan los proveedores de navegadores en la plataforma principal. Incluyo XHR2 en nuestra nueva bolsa de accesorios porque desempeña un papel fundamental en las aplicaciones web complejas de hoy en día.

Al parecer, nuestro viejo amigo tiene una gran renovación, pero muchos desconocen sus nuevas características. El nivel 2 de XMLHttpRequest presenta una gran variedad de funciones nuevas que ponen fin a los trucos complicados en nuestras apps web, como las solicitudes de origen cruzado, la carga de eventos de progreso y la compatibilidad con la carga y descarga de datos binarios. Permiten que AJAX funcione en conjunto con muchas de las APIs de HTML5 de vanguardia, como la API del sistema de archivos, la API de Web Audio y WebGL.

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

Obteniendo datos

Recuperar un archivo como un BLOB binario ha sido difícil con XHR. Técnicamente, ni siquiera era posible. Un truco bien documentado consiste en anular el tipo de MIME con un charset definido por el usuario, como se muestra a continuación.

La forma anterior de recuperar una imagen es la siguiente:

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 recibirás en responseText no es un BLOB binario. Es una cadena binaria que representa el archivo de imagen. Estamos engañando al servidor para que transfiera los datos sin procesar. Aunque esta pequeña gema funciona, voy a llamarla magia negra y aconsejarla. Cada vez que recurres a hackeos de código de caracteres y manipulación de cadenas para forzar los datos a un formato deseable, eso es un problema.

Especifica un formato de respuesta

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

xhr.responseType
Antes de enviar una solicitud, configura xhr.responseType como “text”, “array buffer”, “blob” o “document”, según tus necesidades de datos. Ten en cuenta que si configuras xhr.responseType = '' (o se omite), la respuesta se establecerá de forma predeterminada como “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 configurado para responseType).

Con esta genialidad nueva, 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 objeto 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 datos es que puedes crear "vistas" de los datos subyacentes mediante arrays escritos de JavaScript. De hecho, se pueden crear varias vistas a partir de una sola fuente ArrayBuffer. Por ejemplo, puedes crear un array de enteros de 8 bits que comparta el mismo ArrayBuffer que un array de enteros de 32 bits existente a partir de los mismos datos. Los datos subyacentes permanecen iguales, solo creamos diferentes representaciones de ellos.

A modo de ejemplo, el siguiente ejemplo recupera la misma imagen que un ArrayBuffer, pero esta vez crea un array de enteros de 8 bits sin firma 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();

Una Blob se puede usar en diferentes lugares, como guardarla en indexedDB, escribirla en el sistema de archivos HTML5 o crear una URL de BLOB, como se ve en este ejemplo.

Cómo enviar datos

Poder descargar datos en diferentes formatos es genial, pero no nos lleva a ninguna parte si no podemos enviar estos formatos enriquecidos de vuelta a la base de datos (el servidor). XMLHttpRequest nos limitó a enviar datos DOMString o Document (XML) durante un 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 demuestra el envío de datos con cada tipo.

Enviando 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 de la derecha es ligeramente diferente. Establece responseType='text' para comparar. De nuevo, omitir esa línea genera los mismos resultados.

Enviando formularios: xhr.send(FormData)

Es probable que muchas personas estén acostumbradas a usar complementos de jQuery y 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 HTML <form> sobre la marcha, en JavaScript. Luego, puedes enviar el formulario usando 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 creamos un elemento <form> de forma dinámica y le agregamos valores de <input> mediante una llamada al método de anexo.

Por supuesto, no necesitas crear un <form> desde cero. FormData los objetos se pueden inicializar desde y existentes HTMLFormElement 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 agrega los archivos y el navegador creará 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 BLOB: xhr.send(Blob)

También podemos enviar datos de File o Blob con XHR. Ten en cuenta que todos los elementos File son Blob, por lo que cualquiera de los dos 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'}));

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

Por último, 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 único encabezado de respuesta.

Habilita solicitudes de CORS

Supongamos que tu aplicación está en example.com y que deseas extraer datos de www.example2.com. Por lo general, si intentas realizar este tipo de llamada AJAX, la solicitud fallará y el navegador mostrará un error de discrepancia de origen. Con CORS, www.example2.com puede optar por permitir las solicitudes de example.com con solo agregar 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 haga una solicitud, configura lo siguiente:

Access-Control-Allow-Origin: *

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

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

Habilitar solicitudes entre dominios es sencillo. Si tus datos son públicos, debes habilitar CORS.

Cómo realizar una solicitud multidominio

Si el extremo del servidor habilitó CORS, la solicitud de origen cruzado no es diferente de una solicitud XMLHttpRequest normal. Por ejemplo, a continuación, se muestra 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

Descargar y guardar archivos en el sistema de archivos HTML5

Supongamos que tienes una galería de imágenes y quieres recuperar varias 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();

Cómo dividir un archivo y subir cada parte

Con las APIs de File, podemos minimizar el trabajo de subir un archivo grande. La técnica consiste en dividir la carga en varios fragmentos, generar una XHR para cada parte y colocar el archivo en el servidor. Esto es similar a cómo Gmail sube archivos adjuntos grandes con tanta rapidez. Esta técnica también se puede usar para eludir 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