Novos truques em XMLHttpRequest2

Introdução

Um dos heróis desconhecidos do universo HTML5 é o XMLHttpRequest. O XHR2 não é HTML5. No entanto, isso faz parte das melhorias incrementais que os fornecedores de navegadores estão fazendo na plataforma principal. Estou incluindo o XHR2 na nossa nova sacola de brindes porque ele desempenha uma parte importante nos apps da Web complexos de hoje.

Nosso velho amigo passou por uma grande transformação, mas muitas pessoas não sabem dos novos recursos. O XMLHttpRequest Level 2 apresenta vários novos recursos que acabam com hacks complicados nos nossos apps da Web, como solicitações entre origens, eventos de upload de progresso e suporte para upload/download de dados binários. Elas permitem que o AJAX funcione em conjunto com muitas das APIs HTML5 mais recentes, como a API File System, a API Web Audio e o WebGL.

Este tutorial destaca alguns dos novos recursos do XMLHttpRequest, principalmente aqueles que podem ser usados para trabalhar com arquivos.

Buscando dados

Buscar um arquivo como um blob binário foi difícil com o XHR. Tecnicamente, isso nem era possível. Um truque bem documentado envolve substituir o tipo MIME por um charset definido pelo usuário, conforme mostrado abaixo.

A maneira antiga de buscar uma imagem:

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

Embora isso funcione, o que você recebe de volta no responseText não é um blob binário. É uma string binária que representa o arquivo de imagem. Estamos fazendo com que o servidor transmita os dados de volta sem processá-los. Embora essa pequena joia funcione, vou chamá-la de magia negra e aconselhar contra ela. Sempre que você recorrer a hacks de código de caracteres e manipulação de strings para forçar dados em um formato desejável, isso será um problema.

Como especificar um formato de resposta

No exemplo anterior, fizemos o download da imagem como um "arquivo" binário substituindo o tipo mime do servidor e processando o texto de resposta como uma string binária. Em vez disso, vamos aproveitar as novas propriedades responseType e response de XMLHttpRequest para informar ao navegador em qual formato queremos que os dados sejam retornados.

xhr.responseType
Antes de enviar uma solicitação, defina o xhr.responseType como "text", "arraybuffer", "blob" ou "document", dependendo das suas necessidades de dados. A configuração xhr.responseType = '' (ou a omissão dela) vai definir a resposta como "text".
xhr.response
Depois de uma solicitação bem-sucedida, a propriedade de resposta do XHR vai conter os dados solicitados como DOMString, ArrayBuffer, Blob ou Document (dependendo do que foi definido para responseType).

Com essa nova funcionalidade, podemos retrabalhar o exemplo anterior, mas dessa vez, buscar a imagem como um Blob em vez de uma string:

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

Muito melhor!

Respostas de ArrayBuffer

Um ArrayBuffer é um contêiner genérico de comprimento fixo para dados binários. Eles são muito úteis se você precisar de um buffer generalizado de dados brutos, mas o verdadeiro poder por trás deles é que é possível criar "visualizações" dos dados subjacentes usando matrizes tipadas do JavaScript. Na verdade, é possível criar várias visualizações com uma única origem ArrayBuffer. Por exemplo, é possível criar uma matriz de números inteiros de 8 bits que compartilhe o mesmo ArrayBuffer que uma matriz de números inteiros de 32 bits dos mesmos dados. Os dados subjacentes continuam os mesmos, mas criamos representações diferentes deles.

Como exemplo, o código a seguir busca a mesma imagem como ArrayBuffer, mas dessa vez, cria uma matriz de números inteiros de 8 bits não assinados desse buffer de dados:

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

Respostas de blobs

Se você quiser trabalhar diretamente com um Blob e/ou não precisar manipular nenhum dos bytes do arquivo, use 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();

Um Blob pode ser usado em vários lugares, incluindo o armazenamento em indexedDB, a gravação no sistema de arquivos do HTML5 ou a criação de um URL de blob, como mostrado neste exemplo.

Como enviar dados

É ótimo poder fazer o download de dados em formatos diferentes, mas não vamos chegar a lugar nenhum se não pudermos enviar esses formatos avançados de volta à base (o servidor). O XMLHttpRequest limitou o envio de dados DOMString ou Document (XML) por algum tempo. Isso já não é mais necessário. Um método send() reformulado foi substituído para aceitar qualquer um dos seguintes tipos: DOMString, Document, FormData, Blob, File e ArrayBuffer. Os exemplos no restante desta seção demonstram o envio de dados usando cada tipo.

Como enviar dados de string: 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');

Não há nada de novo aqui, mas o snippet à direita é um pouco diferente. Ele define responseType='text' para comparação. Novamente, omitir essa linha produz os mesmos resultados.

Como enviar formulários: xhr.send(FormData)

Muitas pessoas provavelmente estão acostumadas a usar plug-ins do jQuery ou outras bibliotecas para processar envios de formulários AJAX. Em vez disso, podemos usar FormData, outro novo tipo de dados concebido para XHR2. FormData é conveniente para criar um <form> HTML em tempo real em JavaScript. Esse formulário pode ser enviado 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);
}

Basicamente, estamos criando dinamicamente um <form> e anexando valores <input> a ele chamando o método de anexar.

Claro, você não precisa criar um <form> do zero. Os objetos FormData podem ser inicializados e HTMLFormElement existentes na página. Exemplo:

<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.
}

Um formulário HTML pode incluir uploads de arquivos (por exemplo, <input type="file">), e o FormData também pode processar isso. Basta anexar os arquivos, e o navegador vai criar uma solicitação multipart/form-data quando send() for chamado:

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

Como fazer upload de um arquivo ou blob: xhr.send(Blob)

Também é possível enviar dados File ou Blob usando XHR. Lembre-se de que todos os Files são Blobs, então qualquer um deles funciona aqui.

Este exemplo cria um novo arquivo de texto do zero usando o construtor Blob() e faz o upload desse Blob para o servidor. O código também configura um manipulador para informar o usuário sobre o progresso do upload:

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

Fazer upload de um bloco de bytes: xhr.send(ArrayBuffer)

Por último, mas não menos importante, podemos enviar ArrayBuffers como o payload do 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);
}

Compartilhamento de recursos de origem cruzada (CORS)

O CORS permite que aplicativos da Web em um domínio façam solicitações AJAX entre domínios para outro domínio. É muito simples ativar, exigindo apenas um único cabeçalho de resposta enviado pelo servidor.

Como ativar as solicitações do CORS

Digamos que seu aplicativo esteja em example.com e que você queira extrair dados de www.example2.com. Normalmente, se você tentar fazer esse tipo de chamada AJAX, a solicitação falhará e o navegador gerará um erro de correspondência de origem. Com o CORS, www.example2.com pode permitir solicitações de example.com simplesmente adicionando um cabeçalho:

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

O Access-Control-Allow-Origin pode ser adicionado a um único recurso em um site ou em todo o domínio. Para permitir que qualquer domínio faça uma solicitação para você, defina:

Access-Control-Allow-Origin: *

Na verdade, este site (html5rocks.com) ativou o CORS em todas as páginas. Ative as Ferramentas para Desenvolvedores e você vai encontrar o Access-Control-Allow-Origin na nossa resposta:

Cabeçalho Access-Control-Allow-Origin em html5rocks.com
Cabeçalho "Access-Control-Allow-Origin" em html5rocks.com

Ativar solicitações entre origens é fácil. Então, ative o CORS se os dados forem públicos.

Como fazer uma solicitação entre domínios

Se o endpoint do servidor tiver ativado o CORS, fazer a solicitação entre origens não será diferente de uma solicitação XMLHttpRequest normal. Por exemplo, esta é uma solicitação que example.com pode fazer para 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();

Exemplos práticos

Fazer o download e salvar arquivos no sistema de arquivos HTML5

Digamos que você tenha uma galeria de imagens e queira buscar várias imagens e salvá-las localmente usando o sistema de arquivos HTML5. Uma maneira de fazer isso é solicitar imagens como Blobs e gravá-las usando 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();

Como dividir um arquivo e fazer upload de cada parte

Usando as APIs de arquivos, podemos minimizar o trabalho para fazer o upload de um arquivo grande. A técnica é dividir o upload em vários pedaços, gerar um XHR para cada parte e juntar o arquivo no servidor. Isso é semelhante à forma como o Gmail faz upload de anexos grandes com rapidez. Essa técnica também pode ser usada para contornar o limite de 32 MB de solicitações HTTP do 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);

})();

O código para reconstruir o arquivo no servidor não é mostrado aqui.

Referências