Nuevos trucos para XMLHttpRequest2

HTML5 Rocks

Introducción

Uno de los héroes olvidados del universo HTML5 es XMLHttpRequest. Estrictamente hablando, XHR2 no es HTML5. Sin embargo, forma parte de las mejoras incrementales que los creadores de navegadores están implementando a la plataforma base. Voy a incluir a XHR2 entre los recursos destacables porque juega un papel integral de gran importancia en las complejas aplicaciones web actuales.

Resulta que nuestro viejo amigo sufrió una gran transformación, pero muchos aún desconocen sus nuevas funciones. XMLHttpRequest de nivel 2 introduce una gran cantidad de nuevas funciones que ponen fin a los disparatados problemas de nuestras aplicaciones web, como solicitudes de origen cruzado, eventos de progreso de subidas y compatibilidad con subida/bajada de datos binarios. Esto permite a AJAX trabajar en coordinación con muchas de las API HTML5 más punteras, como API de FileSystem, el API de Web Audio y WebGL.

En este tutorial se explican algunas de las nuevas funciones de XMLHttpRequest, especialmente las que se pueden utilizar para trabajar con archivos.

Recuperación de archivos

Recuperar archivos como blob binario era muy complicado con XHR. Técnicamente, no era ni siquiera posible. Un truco que se ha documentado mucho implicaba anular el tipo mime con un conjunto de caracteres definido por el usuario, como se muestra a continuación.

La antigua forma 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();

Aunque funciona, lo que se obtiene realmente en responseText no es un blob binario, sino 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, su uso no es muy aconsejable. Cada vez que recurres a trucos de código de caracteres y manipulación de cadenas para convertir datos de forma forzada a otro formato, siempre surge algún problema.

Especificación de 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 respuesta como una cadena binaria. En lugar de eso, vamos a aprovechar las nuevas propiedades de XMLHttpRequest (responseType y response) para indicar al navegador el formato en el que queremos que nos devuelva los datos.

xhr.responseType
Antes de enviar una solicitud, establece xhr.responseType en "text", "arraybuffer", "blob" o "document", en función de los datos que necesites. Ten en cuenta que si se establece xhr.responseType = '' (o si se omite), se utilizará la respuesta predeterminada "text".
xhr.response
Después de una solicitud correcta, la propiedad response de xhr contendrá los datos solicitados como DOMString, ArrayBuffer, Blob o Document (en función del valor establecido para responseType).

Con esta maravilla podemos recrear el ejemplo anterior, pero esta vez podemos recuperar la imagen como ArrayBuffer en lugar de como una cadena. Al transferir el búfer al API BlobBuilder se crea un Blob:

BlobBuilder = window.MozBlobBuilder || window.WebKitBlobBuilder || window.BlobBuilder;

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

xhr.onload = function(e) {
  if (this.status == 200) {
    var bb = new BlobBuilder();
    bb.append(this.response); // Note: not xhr.responseText

    var blob = bb.getBlob('image/png');
    ...
  }
};

xhr.send();

¡Mucho mejor!

Respuestas de ArrayBuffer

ArrayBuffer es un contenedor genérico de longitud fija para datos binarios. Es muy práctico si necesitas un búfer generalizado de datos sin procesar, pero el verdadero potencial radica en que puedes crear "vistas" de los datos subyacentes mediante matrices JavaScript. De hecho, se pueden crear varias vistas desde un único ArrayBuffer. Por ejemplo, puedes crear una matriz de enteros de 8 bits que comparta el mismo ArrayBuffer con una matriz de enteros de 32 bits a partir de los mismos datos. Los datos subyacentes siguen siendo los mismos, simplemente creamos representaciones diferentes de ellos.

El siguiente ejemplo recupera la misma imagen como ArrayBuffer, pero esta vez crea una matriz de 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 ni un solo byte del archivo, utiliza 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();

Un Blob tiene distintos usos. Por ejemplo, se puede guardar en indexedDB, se puede escribir en el sistema de archivos de HTML5 o se puede usar para crear una URL Blob, como se muestra en este ejemplo.

Envío de datos

Poder descargar datos en distintos formatos está muy bien, pero no nos lleva a ninguna parte si no podemos enviar estos formatos enriquecidos otra vez al servidor. Durante algún tiempo, XMLHttpRequest nos ha limitado a enviar datos DOMString o Document (XML). Pero eso se acabó. Se ha anulado un método send() rediseñado para aceptar todos estos 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 la cadena de datos: 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);
}

sendText2('test string');

Aquí no hay nada nuevo, aunque el fragmento de la derecha es ligeramente diferente. Se establece responseType='text' para hacer una comparación. De nuevo, si se omite esa línea se obtiene el mismo resultado.

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

Muchos están probablemente acostumbrados a utilizar complementos jQuery u otras bibliotecas para enviar formularios AJAX. En su lugar, podemos utilizar FormData, otro nuevo tipo de datos creado para XHR2. FormData permite crear cómodamente un objeto <form> HTML al vuelo en JavaScript. A continuación, ese formulario se puede enviar mediante 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);
}

Básicamente, estamos creando de forma dinámica un objeto <form> y realizando un seguimiento de sus valores <input> mediante el método append.

Por supuesto, no es necesario crear un objeto <form> desde cero. Los objetos FormData se pueden inicializar a partir de un elemento HTMLFormElement de 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 subidas de archivos (como <input type="file">) y FormData también. Simplemente añade el archivo o los archivos y el navegador construirá una solicitud multipart/form-data cuando se ejecute 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);

Subida de archivos 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 nos sirve aquí.

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

<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);
}

// Take care of vendor prefixes.
BlobBuilder = window.MozBlobBuilder || window.WebKitBlobBuilder || window.BlobBuilder;

var bb = new BlobBuilder();
bb.append('hello world');

upload(bb.getBlob('text/plain'));

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

Y por último, pero no por eso menos importante, podemos enviar ArrayBuffer como la carga 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 de origen cruzado (CORS)

CORS permite a las aplicaciones web de un dominio realizar solicitudes AJAX de dominio cruzado a otro dominio. Es muy fácil de habilitar. Solo es necesario que el servidor envíe un solo encabezado de respuesta.

Cómo habilitar solicitudes de CORS

Supongamos que tu aplicación reside en example.com y quieres extraer datos de www.example2.com. Normalmente, si intentas ejecutar este tipo de llamada AJAX, la solicitud fallará y el navegador lanzará un error por la falta de correspondencia de los orígenes. Con CORS, www.example2.com solo tiene que añadir un encabezado para permitir solicitudes de example.com:

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

Se puede añadir Access-Control-Allow-Origin a un solo recurso de un sitio o a todo el dominio. Para permitir que cualquier dominio pueda hacer una solicitud, establece:

Access-Control-Allow-Origin: *

De hecho, este sitio (html5rocks.com) ha habilitado CORS en todas sus páginas. Activa las herramientas para desarrolladores y verás el encabezado 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 muy fácil, así que te suplico que habilites CORS si tus datos son públicos.

Solicitud de dominio cruzado

Si el punto final del servidor ha habilitado CORS, realizar una solicitud de origen cruzado será igual que realizar una solicitud XMLHttpRequest normal. Por ejemplo, esta es una solicitud que example.com puede realizar ahora 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

Cómo descargar y guardar archivos en el sistema de archivos HTML5

Supongamos que tienes una galería de imágenes y quieres recuperar un grupo de imágenes para, a continuación, guardarlas localmente con el sistema de archivos HTML5. Una forma de conseguir esto sería solicitar imágenes como ArrayBuffer, crear un Blob a partir de los datos y escribir el blob 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 = 'arraybuffer';

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 bb = new BlobBuilder();
        bb.append(xhr.response);

        writer.write(bb.getBlob('image/png'));

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

xhr.send();

Nota: para utilizar este código, consulta la sección sobre compatibilidad con el navegador y límites de almacenamiento del tutorial sobre cómo explorar las API de FileSystem.

Cómo dividir un archivo y subir cada fragmento

Con las API de archivo, podemos minimizar el trabajo necesario para subir un archivo de gran tamaño. La técnica es dividir el archivo que se va a subir en varios fragmentos, crear un XHR para cada parte y unir los fragmentos en el servidor. Es similar a la forma en que Gmail sube archivos adjuntos tan grandes en tan poco tiempo. Esta técnica también se puede utilizar para eludir el límite de solicitud http de 32 MB de Google App Engine.

window.BlobBuilder = window.MozBlobBuilder || window.WebKitBlobBuilder ||
                     window.BlobBuilder;

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

    // Note: blob.slice has changed semantics and been prefixed. See http://goo.gl/U9mE5.
    if ('mozSlice' in blob) {
      var chunk = blob.mozSlice(start, end);
    } else {
      var chunk = blob.webkitSlice(start, end);
    }

    upload(chunk);

    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.

¡Pruébalo!

#bytes/chunk:

Referencias

Comments

0