Mergulhe nas águas turvas do carregamento de scripts

Jake Archibald
Jake Archibald

Introdução

Neste artigo, ensinarei como carregar código JavaScript no navegador e executá-lo.

Não, espera, volta! Sei que parece mundano e simples, mas lembre-se de que isso está acontecendo no navegador, onde o teoricamente simples se torna um buraco de peculiaridade causado pelo legado. Conhecendo essas peculiaridades, é possível escolher a maneira mais rápida e menos prejudicial de carregar scripts. Se você estiver com um cronograma apertado, pule para a referência rápida.

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

WhatWG no carregamento do script
WhatWG no carregamento do script

Como todas as especificações do WhatWG, inicialmente parece as consequências de uma bomba de clustering em uma fábrica de rachaduras, mas depois de ler o documento pela quinta vez e limpar o sangue dos olhos, é bem interessante:

Meu primeiro script inclui

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

Ah, simplicidade maravilhosa. Aqui o navegador fará o download dos dois scripts em paralelo e os executará o mais rápido possível, mantendo a ordem. "2.js" não será executado até que "1.js" tenha sido executado (ou falhou), "1.js" não será executado até que o script ou a folha de estilo anterior tenha sido executado etc.

Infelizmente, o navegador bloqueia a renderização adicional da página enquanto tudo isso acontece. Isso se deve às APIs DOM da “primeira era da Web” que permitem que strings sejam anexadas ao conteúdo que o analisador está mastigando, como document.write. Os navegadores mais recentes continuam verificando ou analisando o documento em segundo plano e acionando downloads de conteúdo externo necessário (js, imagens, css etc.), mas a renderização ainda está bloqueada.

É por isso que o bom e o bom do desempenho recomendam colocar elementos de script no final do documento, já que ele bloqueia o mínimo de conteúdo possível. Infelizmente, isso significa que seu script não é visto pelo navegador até que faça o download de todo o HTML e, a partir desse momento, ele começa a fazer o download de outros conteúdos, como CSS, imagens e iframes. Os navegadores mais recentes são inteligentes o bastante para priorizar o JavaScript em vez das imagens, mas podemos fazer melhor.

Obrigado, IE! (não estou sendo sarcástica)

<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 recurso de "adiar" no Internet Explorer 4. Isso diz "Prometo não injetar coisas no analisador usando coisas como document.write. Se eu quebrar essa promessa, você pode me punir da maneira que achar melhor". Esse atributo ficou em HTML4 e apareceu em outros navegadores.

No exemplo acima, o navegador fará o download dos dois scripts em paralelo e os executará pouco antes do DOMContentLoaded ser acionado, mantendo a ordem.

Como um bombardeio em uma fábrica de ovelhas, o "adiar" virou uma bagunça. Entre os atributos “src” e “defer” e as tags de script versus scripts adicionados dinamicamente, temos seis padrões de adição de um script. É claro que os navegadores não concordaram com a ordem em que devem ser executados. O Mozilla escreveu muito bem sobre o problema (em inglês) em 2009.

O WhatWG tornou o comportamento explícito, declarando "adiar" para não ter efeito sobre scripts que foram adicionados dinamicamente ou que não tinham "src". Caso contrário, os scripts adiados serão executados após a análise do documento, na ordem em que foram adicionados.

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

Isso dá, conquista. 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 haja [1, 3, 2]. Operações específicas do DOM fazem com que o IE pause a execução do script atual e execute outros scripts pendentes antes de continuar.

No entanto, mesmo em implementações sem bugs, como no IE10 e outros navegadores, a execução do script é adiada até que todo o documento seja transferido por download e análise. Isso pode ser conveniente se você for esperar por DOMContentLoaded, mas se quiser ser realmente agressivo com o desempenho, pode começar a adicionar listeners e inicializar mais cedo...

HTML5 ao resgate

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

O HTML5 nos forneceu um novo atributo, "async", que pressupõe que você não usará document.write, mas não espera até que o documento tenha sido analisado para ser executado. O navegador fará o download dos dois scripts em paralelo e os executará o mais rápido possível.

Infelizmente, como eles serão executados o mais rápido possível, "2.js" pode ser executado antes de "1.js". Isso não será um problema se forem independentes, talvez "1.js" seja um script de acompanhamento que não tenha nada a ver com "2.js". Mas se seu "1.js" for uma cópia CDN do jQuery da qual o "2.js" depende, sua página vai ser recoberta de erros, como um cluster.

Eu sei do que precisamos: uma biblioteca JavaScript!

O objetivo é fazer o download imediato de um conjunto de scripts sem bloquear a renderização e executá-los o mais rápido possível na ordem em que foram adicionados. Infelizmente, o HTML odeia você e não permite que você faça isso.

O problema foi resolvido pelo JavaScript em alguns aspectos. Alguns exigiam que você fizesse alterações em seu JavaScript, encapsulando-o em um callback que a biblioteca chamava na ordem correta (por exemplo, RequireJS). Outros usam o XHR para fazer o download em paralelo e depois eval() na ordem correta, o que não funciona para scripts em outro domínio, a menos que eles tivessem um cabeçalho CORS e o navegador fosse compatível. Alguns até usaram truques supermágicos, como o LabJS.

As invasões envolveram enganar o navegador para que baixasse o recurso de uma forma que acionaria um evento na conclusão, mas evitavam a execução. No LabJS, o script seria adicionado com um tipo MIME incorreto, por exemplo, <script type="script/cache" src="...">. Após o download de todos os scripts, eles são adicionados novamente com um tipo correto, na esperança de que o navegador os receba diretamente do cache e os execute imediatamente, em ordem. Isso dependia de um comportamento conveniente, mas não especificado, e era corrompido quando os navegadores declarados HTML5 não podiam baixar scripts com um tipo não reconhecido. Vale ressaltar que o LabJS se adaptou a essas mudanças e agora usa uma combinação dos métodos neste artigo.

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

Basicamente, se você tem que fazer o download de um arquivo de script extra antes mesmo de pensar em fazer o download de outros scripts, perdeu a batalha do desempenho ali mesmo.

O DOM ao resgate

Na verdade, a resposta está na especificação do HTML5, embora ela esteja oculta na parte inferior da seção de carregamento de script.

Vamos traduzi-lo para “Terrestre”:

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

Scripts 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 o download é feito, o que significa que eles 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á a nossos scripts uma mistura de comportamento que não pode ser alcançado com HTML simples. Como não são assíncronos explicitamente, os scripts são adicionados a uma fila de execução, a mesma fila em que foram adicionados no primeiro exemplo em HTML simples. No entanto, por serem criados dinamicamente, eles são executados fora da análise do documento, de modo que a renderização não seja bloqueada durante o download. Não confunda o carregamento não assíncrono de scripts com o XHR sincronizado, o que nunca é uma coisa boa.

O script acima deve ser incluído inline no cabeçalho das páginas, enfileirando os downloads de scripts o mais rápido possível sem interromper a renderização progressiva e sendo executado assim que possível na ordem especificada. O download de "2.js" é sem custo financeiro antes de "1.js", mas ele não será executado até que "1.js" tenha sido transferido e executado ou não realize qualquer uma das tarefas. Boa! async-download, mas execução ordenada!

O carregamento de scripts dessa forma é compatível com tudo o que dá suporte ao atributo "async", exceto o Safari 5.0 (5.1 pode ser usado). Além disso, todas as versões do Firefox e do Opera são compatíveis como versões que não suportam o atributo async executam de maneira conveniente 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. Com o exemplo acima, o navegador precisa analisar e executar o script para descobrir de quais scripts é preciso fazer o download. Isso oculta seus scripts dos verificadores de pré-carregamento. Os navegadores usam esses scanners para descobrir recursos em páginas que você provavelmente irá visitar ou para descobrir recursos de páginas enquanto o analisador está bloqueado por outro recurso.

Podemos adicionar a capacidade de descoberta novamente colocando a informação no cabeçalho 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 das versões 1.js e 2.js. link[rel=subresource] é semelhante a link[rel=prefetch], mas com semântica diferente. No momento, ele só é compatível com o Chrome, e você precisa declarar quais scripts devem ser carregados duas vezes: uma vez por elementos do link e outra no seu script.

Correção:originalmente eu disse que elas foram detectadas pelo scanner de pré-carregamento, que não são, pelo analisador normal. No entanto, o scanner de pré-carregamento poderia detectá-los, mas isso ainda não aconteceu. Os scripts incluídos por código executável nunca podem ser pré-carregados. Agradecemos a Yoav Weiss, que me corrigiu nos comentários.

Acho este artigo desanimador

A situação é depressiva e você deve se sentir deprimido. Não há uma maneira não repetitiva, mas declarativa, de fazer o download de scripts de maneira rápida e assíncrona enquanto controla a ordem de execução. Com o HTTP2/SPDY, é possível reduzir a sobrecarga de solicitações ao ponto em que a entrega de scripts em vários arquivos pequenos que podem ser armazenados em cache individualmente 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 aprimoramento lida com um componente de página específico, mas exige funções utilitárias em plugins.js. O ideal é fazer o download de tudo de forma assíncrona e executar os scripts de aprimoramento o mais rápido possível em qualquer ordem, mas após o dependência.js. É um aprimoramento progressivo e progressivo! Infelizmente, não há uma maneira declarativa de fazer isso, a menos que os próprios scripts sejam modificados para acompanhar o estado de carregamento deDependencies.js. Mesmo async=false não resolve esse problema, já que a execução deimprove-10.js será bloqueada em 1-9. Na verdade, há apenas um navegador que torna isso possível sem invasões...

O IE tem uma ideia!

O IE carrega scripts de maneira diferente de 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 iniciam o download até que o script tenha sido adicionado ao documento. O IE também tem um evento “readystatechange” e a propriedade “readystate”, que nos dizem o progresso do carregamento. Isso é realmente útil, pois 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 é compatível com esse modelo desde a versão 6. Isso é muito interessante, mas ainda apresenta o mesmo problema de descoberta do pré-carregador que o async=false.

Chega! Como devo carregar scripts?

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

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

Aí. No final do elemento do corpo. Sim, ser um desenvolvedor web é muito parecido com ser o Rei Sísfo (boom! 100 pontos de hipster como 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 economizem oferecendo uma maneira declarativa e sem bloqueio de carregar scripts e dar controle sobre a ordem de execução, embora isso exija que os scripts sejam escritos como módulos.

Deve haver algo melhor para usar agora?

Para ganhar pontos de bônus, se você quiser ser realmente agressivo sobre o desempenho e não se importar com um pouco de complexidade e repetição, poderá combinar alguns dos truques acima.

Primeiro, adicionamos a declaração de sub-recurso, para pré-carregadores:

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

Em seguida, inline no cabeçalho do documento, carregamos nossos scripts com JavaScript usando async=false, retornando ao carregamento do script baseado em Readystate do IE e voltando para 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>');
  }
}

Alguns truques e minificação depois são 362 bytes + seus URLs de 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 os bytes extras em comparação com um simples inclusão de script? Se você já usa JavaScript para carregar scripts condicionalmente, como a BBC faz, é possível acionar esses downloads mais cedo. Caso contrário, continue com o método simples de extremidade do corpo.

Ufa, agora sei por que a seção de carregamento do script WHWG é tão grande. Preciso beber.

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 com os outros usuários, execute-os em ordem após qualquer CSS pendente e bloqueie a renderização até que o processo seja concluído. Os navegadores dizem: Sim, senhor.

Adiar

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

Especificação:faça o download junto e execute em ordem antes de DOMContentLoaded. Ignora o "adiar" em scripts sem "src". 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 tenho ideia do que é esse recurso de "adiar". Vou carregar os scripts como se não estivessem lá. Outros navegadores dizem: tudo bem, mas não posso ignorar "adiar" em scripts sem "src".

Assíncrono

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

A especificação diz:faça o download com os outros usuários e execute-os na ordem em que eles forem baixados. Os navegadores em vermelho dizem: o que é "assíncrono"? Vou carregar os scripts como se não estivessem lá. Outros navegadores dizem: "Sim, ok.

Async false

[
  '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 com os outros e execute em ordem assim que terminar o download. Firefox < 3.6, Opera diz: não tenho ideia do que é esse recurso "assíncrono", mas acontece que eu executo scripts adicionados via JS na ordem em que foram adicionados. O Safari 5.0 diz: Eu entendo "assíncrono", mas não o entendo como "falso" com o JS. Vou executar seus scripts assim que eles chegarem, em qualquer ordem. IE < 10 diz: Não há ideia sobre "async", mas há uma solução alternativa usando "onreadystatechange". Outros navegadores em vermelho dizem: não entendo esse recurso "assíncrono", vou executar seus scripts assim que eles forem concluídos, em qualquer ordem. Todo o restante diz: eu sou seu amigo, vamos fazer isso por conta própria.