Mergulhe nas águas turvas do carregamento de scripts

Jake Archibald
Jake Archibald

Introdução

Neste artigo, vou ensinar como carregar e executar JavaScript no navegador.

Não, espera, volte! Sei que parece mundano e simples, mas lembre-se de que isso está acontecendo no navegador, onde o teoricamente simples se torna um problema legado. Conhecer essas peculiaridades permite que você escolha a maneira mais rápida e menos perturbadora de carregar scripts. Se você estiver com uma agenda apertada, pule para a referência rápida.

Para começar, veja como a especificação define as várias maneiras de fazer o download e a execução de um script:

O WHATWG no carregamento de script
O WHATWG sobre o carregamento de scripts

Como todas as especificações do WHATWG, ela parece inicialmente o resultado de uma bomba de fragmentação em uma fábrica de scrabble, mas depois que você lê pela quinta vez e limpa o sangue dos seus olhos, ela é bem interessante:

Meu primeiro include de script

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

Ah, a simplicidade maravilhosa. Aqui, o navegador faz o download dos dois scripts em paralelo e os executa assim que possível, mantendo a ordem. O arquivo "2.js" não será executado até que o arquivo "1.js" seja executado (ou falhe em fazer isso). O arquivo "1.js" não será executado até que o script ou a folha de estilos anterior seja executado etc.

Infelizmente, o navegador bloqueia a renderização da página enquanto isso acontece. Isso ocorre devido a APIs DOM da "primeira era da Web" que permitem que strings sejam anexadas ao conteúdo que o analisador está processando, como document.write. Os navegadores mais recentes vão continuar verificando ou analisando o documento em segundo plano e acionar downloads para conteúdo externo que possa ser necessário (js, imagens, css etc.), mas a renderização ainda está bloqueada.

É por isso que os especialistas em performance recomendam colocar os elementos de script no final do documento, porque eles bloqueiam o menor conteúdo possível. Isso significa que o script não é visto pelo navegador até que todo o HTML seja transferido. Nesse ponto, ele começa a fazer o download de outros conteúdos, como CSS, imagens e iframes. Os navegadores modernos são inteligentes o suficiente para priorizar o JavaScript em vez de imagens, mas podemos fazer melhor.

Valeu, IE! Não, não estou sendo sarcástico.

<script src="//other-domain.com/1.js" defer></script>
<script src="2.js" defer></script>

A Microsoft reconheceu esses problemas de desempenho e introduziu o "adiamento" no Internet Explorer 4. Isso basicamente diz: "Prometo não injetar coisas no analisador usando coisas como document.write. Se eu quebrar essa promessa, você pode me punir da forma que achar melhor". Esse atributo chegou ao HTML4 e apareceu em outros navegadores.

No exemplo acima, o navegador faz o download dos dois scripts em paralelo e os executa pouco antes de DOMContentLoaded ser acionado, mantendo a ordem.

Como uma bomba de fragmentação em uma fábrica de ovelhas, "adiar" se tornou uma bagunça confusa. Entre os atributos "src" e "defer" e as tags de script em comparação com os scripts adicionados dinamicamente, temos seis padrões de adição de um script. Obviamente, os navegadores não concordam com a ordem em que devem ser executados. A Mozilla escreveu um ótimo artigo sobre o problema em 2009.

O WHATWG tornou o comportamento explícito, declarando que "adiar" não tem efeito nos scripts que foram adicionados dinamicamente ou não tinham "src". Caso contrário, os scripts adiados serão executados depois que o documento for analisado, na ordem em que foram adicionados.

Valeu, IE! (Ok, agora estou sendo sarcástico)

Ele dá e tira. Infelizmente, há um bug no IE4-9 que pode fazer com que os scripts sejam executados em uma ordem inesperada. Veja o que acontece:

1.js

console.log('1');
document.getElementsByTagName('p')[0].innerHTML = 'Changing some content';
console.log('2');

2.js

console.log('3');

Supondo que haja um parágrafo na página, a ordem esperada dos registros é [1, 2, 3], embora no IE9 e versões anteriores, você receba [1, 3, 2]. Operações específicas do DOM fazem com que o IE interrompa a execução do script atual e execute outros scripts pendentes antes de continuar.

No entanto, mesmo em implementações sem bugs, como o IE10 e outros navegadores, a execução do script é adiada até que todo o documento seja transferido por download e analisado. Isso pode ser conveniente se você vai esperar por DOMContentLoaded de qualquer maneira, mas se quiser ser muito agressivo com a performance, comece a adicionar listeners e inicialização mais cedo.

HTML5 para ajudar

<script src="//other-domain.com/1.js" async></script>
<script src="2.js" async></script>

O HTML5 nos deu um novo atributo, "async", que pressupõe que você não vai usar document.write, mas não espera até que o documento seja analisado para ser executado. O navegador vai fazer o download dos dois scripts em paralelo e executá-los assim que possível.

Infelizmente, como eles vão ser executados o mais rápido possível, o "2.js" pode ser executado antes do "1.js". Isso é aceitável se eles forem independentes, talvez o "1.js" seja um script de rastreamento que não tem nada a ver com o "2.js". Mas se o "1.js" for uma cópia CDN do jQuery da qual o "2.js" depende, sua página vai ficar cheia de erros, como uma bomba de fragmentação em um… não sei… não tenho nada para isso.

Sei o que precisamos: uma biblioteca JavaScript.

O ideal é fazer o download de um conjunto de scripts imediatamente, sem bloquear a renderização, e executar o mais rápido possível na ordem em que foram adicionados. Infelizmente, o HTML não permite isso.

O problema foi resolvido pelo JavaScript em algumas versões. Algumas exigiam que você fizesse mudanças no JavaScript, envolvendo-o em um callback que a biblioteca chama na ordem correta (por exemplo, RequireJS). Outros usavam o XHR para fazer o download em paralelo e depois eval() na ordem correta, o que não funcionava para scripts em outro domínio, a menos que eles tivessem um cabeçalho CORS e o navegador oferecesse suporte a ele. Alguns até usaram hacks supermágicos, como o LabJS.

Os hacks envolviam enganar o navegador para que ele fizesse o download do recurso de uma maneira que acionasse um evento na conclusão, mas evitasse a execução. No LabJS, o script seria adicionado com um tipo MIME incorreto, por exemplo, <script type="script/cache" src="...">. Depois que todos os scripts fossem transferidos, eles seriam adicionados novamente com um tipo correto, esperando que o navegador os recebesse diretamente do cache e os executasse imediatamente, em ordem. Isso dependia de um comportamento conveniente, mas não especificado, e que quebrou quando os navegadores declarados pelo HTML5 não puderam fazer o download de scripts com um tipo não reconhecido. Vale a pena notar que o LabJS se adaptou a essas mudanças e agora usa uma combinação dos métodos deste artigo.

No entanto, os carregadores de script têm um problema de desempenho próprio. Você precisa esperar o download e a análise do JavaScript da biblioteca antes que qualquer um dos scripts gerenciados possa começar o download. Além disso, como vamos carregar o carregador de script? Como vamos carregar o script que informa ao carregador o que carregar? Quem vigia os vigilantes? Por que estou nu? Todas essas perguntas são difíceis.

Basicamente, se você precisa fazer o download de um arquivo de script extra antes mesmo de pensar em fazer o download de outros scripts, você já perdeu a batalha de performance.

O DOM ao resgate

A resposta está na especificação HTML5, embora esteja oculta na parte de baixo da seção de carregamento de script.

Vamos traduzir isso para "Humano":

[
  '//other-domain.com/1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  document.head.appendChild(script);
});

Os scripts que são criados e adicionados dinamicamente ao documento são assíncronos por padrão. Eles não bloqueiam a renderização e são executados assim que são transferidos por download, o que significa que podem aparecer na ordem errada. No entanto, podemos marcá-los explicitamente como não assíncronos:

[
  '//other-domain.com/1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  script.async = false;
  document.head.appendChild(script);
});

Isso dá aos nossos scripts uma combinação de comportamento que não pode ser alcançada com HTML simples. Por serem explicitamente assíncronos, os scripts são adicionados a uma fila de execução, a mesma em que são adicionados no nosso primeiro exemplo de HTML simples. No entanto, por serem criados dinamicamente, eles são executados fora da análise de documentos. Assim, a renderização não é bloqueada durante o download. Não confunda o carregamento de script não assíncrono com o XHR síncrono, que nunca é uma boa ideia.

O script acima precisa ser incluído inline no cabeçalho das páginas, enfileirando os downloads de script o mais rápido possível sem interromper a renderização progressiva e executando o mais rápido possível na ordem especificada. O arquivo "2.js" pode ser baixado antes do "1.js", mas não será executado até que o arquivo "1.js" seja baixado e executado ou falhe. Eba! Execução ordenada, mas com download assíncrono.

O carregamento de scripts dessa maneira é compatível com tudo o que oferece suporte ao atributo assíncrono, com exceção do Safari 5.0 (o 5.1 é compatível). Além disso, todas as versões do Firefox e do Opera são compatíveis, já que as versões que não têm suporte ao atributo async executam scripts adicionados dinamicamente na ordem em que são adicionados ao documento.

Essa é a maneira mais rápida de carregar scripts, certo? Certo?

Se você estiver decidindo dinamicamente quais scripts carregar, sim. Caso contrário, talvez não. No exemplo acima, o navegador precisa analisar e executar o script para descobrir quais scripts serão transferidos por download. Isso oculta seus scripts dos scanners de pré-carregamento. Os navegadores usam esses scanners para descobrir recursos em páginas que você provavelmente vai visitar em seguida ou descobrir recursos de página enquanto o analisador está bloqueado por outro recurso.

Podemos adicionar a capacidade de descoberta novamente colocando o seguinte na parte de cima do documento:

<link rel="subresource" href="//other-domain.com/1.js">
<link rel="subresource" href="2.js">

Isso informa ao navegador que a página precisa de 1.js e 2.js. link[rel=subresource] é semelhante a link[rel=prefetch], mas com semântica diferente. No momento, o recurso só é compatível com o Chrome, e você precisa declarar quais scripts serão carregados duas vezes, uma vez por elementos de link e outra no script.

Correção:eu disse originalmente que eles eram detectados pelo scanner de pré-carregamento, mas não são. Eles são detectados pelo analisador regular. No entanto, o scanner de pré-carregamento pode detectar esses scripts, mas ainda não faz isso. Já os scripts incluídos por código executável nunca podem ser pré-carregados. Agradeço a Yoav Weiss, que me corrigiu nos comentários.

Acho este artigo deprimente

A situação é deprimente e você deve se sentir deprimido. Não há uma maneira não repetitiva, mas declarativa, de fazer o download de scripts de forma rápida e assíncrona, controlando a ordem de execução. Com o HTTP2/SPDY, é possível reduzir a sobrecarga de solicitações a ponto de que a entrega de scripts em vários arquivos pequenos e individualmente em cache pode ser a maneira mais rápida. Imagine só:

<script src="dependencies.js"></script>
<script src="enhancement-1.js"></script>
<script src="enhancement-2.js"></script>
<script src="enhancement-3.js"></script>
…
<script src="enhancement-10.js"></script>

Cada script de melhoria lida com um componente de página específico, mas requer funções de utilitário em dependencies.js. O ideal é fazer o download de tudo de forma assíncrona e executar os scripts de melhoria o mais rápido possível, em qualquer ordem, mas depois de dependencies.js. É o aprimoramento progressivo! Infelizmente, não há uma maneira declarativa de fazer isso, a menos que os scripts sejam modificados para rastrear o estado de carregamento de dependencies.js. Mesmo async=false não resolve esse problema, porque a execução de enhancement-10.js é bloqueada de 1 a 9. Na verdade, há apenas um navegador que permite isso sem hacks…

O IE tem uma ideia!

O IE carrega scripts de maneira diferente dos outros navegadores.

var script = document.createElement('script');
script.src = 'whatever.js';

O IE começa a fazer o download de "whatever.js" agora, outros navegadores não começam a fazer o download até que o script seja adicionado ao documento. O IE também tem um evento, "readystatechange", e uma propriedade, "readystate", que informam o progresso do carregamento. Isso é muito útil, porque permite controlar o carregamento e a execução de scripts de forma independente.

var script = document.createElement('script');

script.onreadystatechange = function() {
  if (script.readyState == 'loaded') {
    // Our script has download, but hasn't executed.
    // It won't execute until we do:
    document.body.appendChild(script);
  }
};

script.src = 'whatever.js';

Podemos criar modelos de dependência complexos escolhendo quando adicionar scripts ao documento. O IE oferece suporte a esse modelo desde a versão 6. Muito interessante, mas ainda sofre com o mesmo problema de detecção de pré-carregador que async=false.

Chega! Como devo carregar scripts?

Ok, ok. Se você quiser carregar scripts de uma maneira que não bloqueie a renderização, não envolva repetição e tenha um excelente suporte ao navegador, aqui está o que eu proponho:

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

Isso. No final do elemento body. Sim, ser um desenvolvedor da Web é muito parecido com ser o Rei Sísifo (bum! 100 pontos hipster para a referência da mitologia grega!). As limitações no HTML e nos navegadores nos impedem de fazer muito melhor.

Espero que os módulos JavaScript nos salvem, fornecendo uma maneira declarativa e não bloqueante de carregar scripts e controlar a ordem de execução, embora isso exija que os scripts sejam escritos como módulos.

Deve haver algo melhor que possamos usar agora?

Ok, como bônus, se você quiser melhorar muito a performance e não se importar com um pouco de complexidade e repetição, combine alguns dos truques acima.

Primeiro, adicionamos a declaração de subrecurso para os pré-carregadores:

<link rel="subresource" href="//other-domain.com/1.js">
<link rel="subresource" href="2.js">

Em seguida, inline na cabeça do documento, carregamos nossos scripts com JavaScript, usando async=false, voltando ao carregamento de script baseado em readystate do IE, voltando a adiar.

var scripts = [
  '1.js',
  '2.js'
];
var src;
var script;
var pendingScripts = [];
var firstScript = document.scripts[0];

// Watch scripts load in IE
function stateChange() {
  // Execute as many scripts in order as we can
  var pendingScript;
  while (pendingScripts[0] && pendingScripts[0].readyState == 'loaded') {
    pendingScript = pendingScripts.shift();
    // avoid future loading events from this script (eg, if src changes)
    pendingScript.onreadystatechange = null;
    // can't just appendChild, old IE bug if element isn't closed
    firstScript.parentNode.insertBefore(pendingScript, firstScript);
  }
}

// loop through our script urls
while (src = scripts.shift()) {
  if ('async' in firstScript) { // modern browsers
    script = document.createElement('script');
    script.async = false;
    script.src = src;
    document.head.appendChild(script);
  }
  else if (firstScript.readyState) { // IE<10
    // create a script and add it to our todo pile
    script = document.createElement('script');
    pendingScripts.push(script);
    // listen for state changes
    script.onreadystatechange = stateChange;
    // must set src AFTER adding onreadystatechange listener
    // else we'll miss the loaded event for cached scripts
    script.src = src;
  }
  else { // fall back to defer
    document.write('<script src="' + src + '" defer></'+'script>');
  }
}

Depois de alguns truques e minificação, o tamanho é de 362 bytes + os URLs do script:

!function(e,t,r){function n(){for(;d[0]&&"loaded"==d[0][f];)c=d.shift(),c[o]=!i.parentNode.insertBefore(c,i)}for(var s,a,c,d=[],i=e.scripts[0],o="onreadystatechange",f="readyState";s=r.shift();)a=e.createElement(t),"async"in i?(a.async=!1,e.head.appendChild(a)):i[f]?(d.push(a),a[o]=n):e.write("<"+t+' src="'+s+'" defer></'+t+">"),a.src=s}(document,"script",[
  "//other-domain.com/1.js",
  "2.js"
])

Vale a pena usar os bytes extras em comparação com um script simples? Se você já usa JavaScript para carregar scripts condicionalmente, como a BBC, pode acionar esses downloads mais cedo. Caso contrário, talvez não, use o método simples de fim de corpo.

Ufa, agora sei por que a seção de carregamento de script do WHATWG é tão vasta. Preciso de uma bebida.

Referência rápida

Elementos de script simples

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

A especificação diz: faça o download em conjunto, execute em ordem após qualquer CSS pendente e bloqueie a renderização até a conclusão. Os navegadores dizem: Sim, senhor!

Adiar

<script src="//other-domain.com/1.js" defer></script>
<script src="2.js" defer></script>

A especificação diz: faça o download em conjunto e execute na ordem logo antes de DOMContentLoaded. Ignorar "adiar" em scripts sem "src". O IE < 10 diz: posso executar 2.js na metade da execução de 1.js. Não é divertido? Os navegadores em vermelho dizem: Não faço ideia do que se trata essa coisa de "adiar". Vou carregar os scripts como se não estivessem lá. Outros navegadores dizem: Ok, mas talvez eu não ignore "adiar" em scripts sem "src".

Assíncrona

<script src="//other-domain.com/1.js" async></script>
<script src="2.js" async></script>

A especificação diz: faça o download juntos e execute na ordem em que foram feitos. Os navegadores em vermelho dizem: O que é "assíncrono"? Vou carregar os scripts como se ele não estivesse lá. Outros navegadores dizem: Sim, ok.

Assíncrono falso

[
  '1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  script.async = false;
  document.head.appendChild(script);
});

A especificação diz: faça o download de tudo junto e execute na ordem assim que todos os downloads forem concluídos. Firefox < 3.6, Opera:não tenho ideia do que é essa coisa “assíncrona”, mas acontece que eu executo os scripts adicionados por JS na ordem em que eles são adicionados. O Safari 5.0 diz: entendo "async", mas não entendo como definir como "false" com JS. Vamos executar seus scripts assim que eles forem recebidos, em qualquer ordem. O IE < 10 diz: Não sei o que é "async", mas há uma solução alternativa usando "onreadystatechange". Outros navegadores em vermelho dizem: Não entendo essa coisa de "async", vou executar seus scripts assim que eles chegarem, em qualquer ordem. O resto diz: sou seu amigo, vamos fazer isso corretamente.