Novos truques em XMLHttpRequest2

Introdução

Um dos heróis anônimos do universo HTML5 é XMLHttpRequest. Estritamente falando, XHR2 não é HTML5. No entanto, ele faz parte das melhorias incrementais que os fornecedores de navegadores estão fazendo na plataforma principal.

Nosso velho amigo fez uma grande reforma, mas muitas pessoas não sabiam dos novos recursos. O XMLHttpRequest nível 2 introduz uma série de novos recursos que colocam o fim de invasões complicadas em nossos apps da Web, como solicitações de origem cruzada, upload de eventos de progresso e suporte para upload/download de dados binários. Isso permite que o AJAX funcione em conjunto com muitas APIs HTML5 de última geração, como API File System, API Web Audio e WebGL.

Neste tutorial, destacamos alguns dos novos recursos do XMLHttpRequest, especialmente os que podem ser usados para trabalhar com arquivos.

Buscando dados

Tem sido difícil buscar um arquivo como um blob binário com XHR. Tecnicamente, isso nem era possível. Um truque que tem sido bem documentado envolve a substituição do tipo MIME por um conjunto de caracteres definido pelo usuário, conforme mostrado abaixo.

O método antigo 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ê realmente recebe no responseText não é um blob binário. É uma string binária que representa o arquivo de imagem. Estamos enganando o servidor para retornar os dados não processados. Mesmo que esta pequena preciosidade funcione, vou chamá-la de magia negra e aconselhar sobre o contrário. Sempre que você recorre a hacks de código de caracteres e manipulação de string para coagir os dados a um formato desejável, isso é 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 que formato queremos que os dados sejam retornados.

xhr.responseType
Antes de enviar uma solicitação, defina xhr.responseType como "text", "arraybuffer", "blob" ou "document", dependendo das necessidades dos dados. Ao definir xhr.responseType = '' (ou sua omissão), a resposta será padronizada como "texto".
xhr.response
Após a solicitação, a propriedade de resposta do xhr conterá os dados solicitados como DOMString, ArrayBuffer, Blob ou Document (dependendo do que foi definido para responseType).

Com essa nova novidade, podemos refazer o exemplo anterior, mas, desta vez, buscamos a imagem como 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 a vantagem real por trás deles é que é possível criar "visualizações" dos dados subjacentes usando matrizes tipadas em JavaScript. Na verdade, várias visualizações podem ser criadas a partir de uma única origem de ArrayBuffer. Por exemplo, você pode criar uma matriz de números inteiros de 8 bits que compartilhe o mesmo ArrayBuffer de uma matriz de números inteiros de 32 bits com os mesmos dados. Os dados subjacentes permanecem os mesmos, apenas criamos representações diferentes deles.

Por exemplo, o comando a seguir busca a mesma imagem que um ArrayBuffer, mas cria uma matriz de números inteiros de 8 bits não assinado a partir 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 Blob

Se você quiser trabalhar diretamente com um Blob e/ou não precisar manipular os 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 salvá-lo no indexedDB, gravá-lo no sistema de arquivos HTML5 ou criar um URL do Blob, como mostrado neste exemplo.

Enviar dados

A capacidade de fazer o download de dados em diferentes formatos é ótima, mas não nos leva a lugar nenhum se não pudermos enviar esses formatos avançados de volta para a base inicial (o servidor). XMLHttpRequest nos limitou a enviar dados DOMString ou Document (XML) por algum tempo. Não teria. Um método send() reformulado foi substituído para aceitar qualquer um destes tipos: DOMString, Document, FormData, Blob, File ou ArrayBuffer. Os exemplos no restante desta seção demonstram o envio de dados usando cada tipo.

Envio de 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, embora o snippet à direita seja um pouco diferente. Ele define responseType='text' para comparação. Mais uma vez, omitir essa linha gera os mesmos resultados.

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

Muitas pessoas provavelmente estão acostumadas a usar plug-ins jQuery ou outras bibliotecas para processar envios de formulários AJAX. Em vez disso, podemos usar FormData, outro novo tipo de dados desenvolvido para XHR2. O FormData é prático para criar um <form> HTML rapidamente, 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);
}

Essencialmente, estamos apenas criando dinamicamente um <form> e adicionando os valores <input> a ele, chamando o método de anexação.

Obviamente, você não precisa criar um <form> do zero. Objetos FormData podem ser inicializados de um HTMLFormElement atual 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 FormData também pode lidar com isso. Basta anexar os arquivos, e o navegador 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 podemos enviar dados File ou Blob usando XHR. Lembre-se de que todos os Files são Blobs, então qualquer um funciona aqui.

Este exemplo cria um novo arquivo de texto do zero usando o construtor Blob() e faz upload desse Blob para o servidor. O código também configura um gerenciador 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'}));

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. É muito simples ativar, exigindo apenas que um único cabeçalho de resposta seja enviado pelo servidor.

Como ativar solicitações CORS

Digamos que seu aplicativo esteja armazenado em example.com e você quer extrair dados de www.example2.com. Normalmente, se você tentasse fazer esse tipo de chamada AJAX, a solicitação falharia e o navegador geraria um erro de incompatibilidade 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

A Access-Control-Allow-Origin pode ser adicionada 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: *

De fato, este site (html5rocks.com) ativou o CORS em todas as suas páginas. Acione as Ferramentas para desenvolvedores e verá 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

É fácil ativar solicitações de origem cruzada. Ative o CORS se seus 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 de origem cruzada será igual a uma solicitação XMLHttpRequest normal. Por exemplo, veja aqui uma solicitação que example.com agora 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 seria 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();

Dividir um arquivo e fazer upload de cada parte

Com as APIs File, podemos minimizar o trabalho de upload de um arquivo grande. A técnica é dividir o upload em vários fragmentos, gerar um XHR para cada parte e reunir o arquivo no servidor. O processo é semelhante ao modo como o Gmail faz upload de anexos grandes tão rapidamente. Essa técnica também pode ser usada para contornar o limite de solicitação http de 32 MB 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 que não é mostrado aqui é o código para reconstruir o arquivo no servidor.

Referências