Immergiti nelle acque torbide del caricamento degli script

Jake Archibald
Jake Archibald

Introduzione

In questo articolo ti insegnerò come caricare codice JavaScript nel browser ed eseguirlo.

No, aspetta, torna! So che può sembrare banale e semplice, ma ricorda, questo sta accadendo nel browser, dove ciò che teoricamente semplice diventa un buco del passato. Conoscere queste peculiarità ti permette di scegliere il modo più veloce e meno invasivo di caricare gli script. Se hai poco tempo, passa al riferimento rapido.

Per iniziare, ecco in che modo la specifica definisce i vari modi in cui uno script può scaricare ed eseguire:

Caricamento dello script whatWG in corso...
WhatWG sul caricamento dello script

Come tutte le specifiche di WHATWG, all'inizio sembra essere l'esito di una bomba a grappolo in una fabbrica di scarabei, ma dopo averlo letto per la quinta volta e aver cancellato il sangue dagli occhi, è in realtà piuttosto interessante:

Il mio primo script include

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

Ahh, una deliziosa semplicità. In questa pagina il browser scarica entrambi gli script in parallelo e li esegue il prima possibile, mantenendo l'ordine di esecuzione. "2.js" non verrà eseguito fino a quando "1.js" non sarà stato eseguito (o non è stato eseguito), "1.js" non verrà eseguito fino all'esecuzione dello script o del foglio di stile precedente e così via.

Purtroppo, mentre avviene tutto questo, il browser blocca l'ulteriore rendering della pagina. Ciò è dovuto alle API DOM della "prima era del web", che consentono di aggiungere stringhe ai contenuti che il parser sta masticando, ad esempio document.write. I browser più recenti continueranno a scansionare o analizzare il documento in background e attiveranno download per contenuti esterni di cui potrebbe avere bisogno (js, immagini, CSS e così via), ma il rendering viene comunque bloccato.

È per questo che i grandi e il buono del mondo delle prestazioni consigliano di inserire elementi di script alla fine del documento, in quanto blocca il minor numero possibile di contenuti. Purtroppo significa che il tuo script non viene visto dal browser finché non scarica tutto il codice HTML e a quel punto ha iniziato a scaricare altri contenuti, ad esempio CSS, immagini e iframe. I browser moderni sono sufficientemente intelligenti da dare la priorità a JavaScript rispetto alle immagini, ma possiamo fare di meglio.

Grazie, IE! (no, non sono sarcastica)

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

Microsoft ha riconosciuto questi problemi di prestazioni e ha introdotto la funzione di differimento in Internet Explorer 4. In sostanza, questo dice: "Prometto di non inserire dati nel parser usando elementi come document.write. Se infrango questa promessa, puoi punirmi nel modo che ritieni più adatto". Questo attributo è convertito in HTML4 ed è apparso in altri browser.

Nell'esempio riportato sopra, il browser scarica entrambi gli script in parallelo e li esegue poco prima dell'attivazione di DOMContentLoaded, mantenendo il loro ordine.

Come una bomba a grappolo in una fabbrica di pecore, il termine "rimandare" è diventato un disastro di lanosità. Tra gli attributi "src" e "defer" e i tag di script rispetto agli script aggiunti dinamicamente, abbiamo sei pattern per l'aggiunta di uno script. Naturalmente, i browser non erano d’accordo nell’ordine di esecuzione. Mozilla ha scritto un articolo molto interessante sul problema, risalente al 2009.

Il WHATWG ha reso esplicito il comportamento, dichiarando che "differisci" non ha alcun effetto sugli script aggiunti dinamicamente o privi di "src". In caso contrario, gli script differiti dovrebbero essere eseguiti dopo l'analisi del documento, nell'ordine in cui sono stati aggiunti.

Grazie, IE! (Ok, ora sono sarcastica)

Dà, prende via. Sfortunatamente, IE4-9 contiene un bug dannoso che può causare l'esecuzione degli script in un ordine imprevisto. Ecco cosa accade:

1.js

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

2.js

console.log('3');

Supponendo che ci sia un paragrafo sulla pagina, l'ordine previsto dei registri è [1, 2, 3], anche se in IE9 e sotto si ottiene [1, 3, 2]. Determinate operazioni DOM causano la messa in pausa dell'esecuzione dello script in corso e l'esecuzione di altri script in sospeso prima di continuare.

Tuttavia, anche nelle implementazioni senza bug, come IE10 e altri browser, l'esecuzione dello script viene ritardata fino al download e all'analisi dell'intero documento. Questa soluzione può essere comoda se devi comunque aspettare DOMContentLoaded, ma se vuoi essere molto aggressivo con prestazioni, puoi iniziare ad aggiungere listener e bootstrap più rapidamente...

HTML5 in soccorso

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

L'HTML5 ci ha fornito un nuovo attributo "asinc", che presuppone che tu non utilizzerai document.write, ma non attende che il documento venga analizzato per essere eseguito. Il browser scarica entrambi gli script in parallelo e li esegue il prima possibile.

Sfortunatamente, poiché verranno eseguiti il prima possibile, "2.js" potrebbe essere eseguito prima di "1.js". Questo va bene se sono indipendenti, forse "1.js" è uno script di monitoraggio che non ha nulla a che fare con "2.js". Ma se "1.js" è una copia CDN di jQuery da cui "2.js" dipende, la tua pagina otterrà un errore di cluster...

So cosa ci serve, una libreria JavaScript.

Il Santo Graal scarica immediatamente una serie di script senza bloccare il rendering ed eseguiti il prima possibile nell'ordine in cui sono stati aggiunti. Purtroppo il linguaggio HTML ti detesta e non ti consente di farlo.

Il problema è stato risolto da JavaScript in diverse versioni. Alcuni hanno richiesto di apportare modifiche al tuo codice JavaScript, inserendolo in un callback che la libreria chiama nell'ordine corretto (ad esempio RequireJS). Altri utilizzavano XHR per scaricare in parallelo, poi eval() nell'ordine corretto, cosa che non funzionava per gli script su un altro dominio a meno che non avessero un'intestazione CORS e il browser non la supportasse. Alcuni hanno persino usato trucchi super magici, come LabJS.

Gli hacker prevedevano l'inganno al browser di scaricare la risorsa in modo tale da attivare un evento al completamento, ma evitando di eseguirlo. In LabJS, lo script verrebbe aggiunto con un tipo MIME errato, ad esempio <script type="script/cache" src="...">. Una volta scaricati tutti gli script, venivano aggiunti di nuovo con il tipo corretto, sperando che il browser li trovasse direttamente dalla cache e li avesse eseguiti immediatamente, in ordine. Questo dipendeva da un comportamento comodo ma non specificato e si interrompeva quando i browser dichiarati HTML5 non dovevano scaricare script di tipo non riconosciuto. Vale la pena notare che LabJS si è adattato a questi cambiamenti e ora utilizza una combinazione dei metodi descritti in questo articolo.

Tuttavia, i caricatori di script hanno un problema di prestazioni. Devi attendere il download e l'analisi del codice JavaScript della libreria prima che gli script gestiti possano iniziare il download. Inoltre, come caricheremo il caricatore di script? Come caricheremo lo script che indica al caricatore di script cosa caricare? Chi guarda i Watchmen? Perché sono nudo? Queste sono tutte domande difficili.

Essenzialmente, se devi scaricare un file di script in più prima ancora di pensare di scaricare altri script, hai perso la sfida in termini di prestazioni.

Il DOM in soccorso

In realtà la risposta si trova nella specifica HTML5, anche se è nascosta nella parte inferiore della sezione di caricamento dello script.

Traduciamolo in "Earthling":

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

Gli script creati e aggiunti al documento in modo dinamico sono asincroni per impostazione predefinita, non bloccano il rendering e vengono eseguiti non appena vengono scaricati, vale a dire che potrebbero essere visualizzati nell'ordine sbagliato. Tuttavia, possiamo contrassegnarli esplicitamente come non asincroni:

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

In questo modo, i nostri script hanno un comportamento impossibili da ottenere con HTML normale. Essendo esplicitamente non asincroni, gli script vengono aggiunti a una coda di esecuzione, la stessa coda a cui sono stati aggiunti nel nostro primo esempio in HTML normale. Tuttavia, poiché vengono creati dinamicamente, vengono eseguiti al di fuori dell'analisi dei documenti, pertanto il rendering non viene bloccato mentre vengono scaricati (non confondere il caricamento dello script non asincrono con l'XHR di sincronizzazione, che non è mai una cosa positiva).

Lo script riportato sopra deve essere incluso in linea nell'intestazione delle pagine, mettendo in coda i download degli script il prima possibile senza interrompere il rendering progressivo ed eseguito il prima possibile nell'ordine specificato. Il download di “2.js” è senza costi prima di “1.js”, ma non verrà eseguito fino a quando “1.js” non sarà stato scaricato ed eseguito correttamente o non sarà in grado di eseguire l'operazione. Evviva! Download asincrono ma esecuzione ordinata!

Il caricamento degli script in questo modo è supportato da tutti gli elementi che supportano l'attributo asincrono, ad eccezione di Safari 5.0 (5.1 va bene). Inoltre, tutte le versioni di Firefox e Opera sono supportate come versioni che non supportano l'attributo asincrono, eseguendo comodamente gli script aggiunti dinamicamente nell'ordine in cui vengono aggiunti comunque al documento.

È il modo più veloce per caricare gli script? molto utile.

Se stai decidendo in modo dinamico quali script caricare, forse no. Nell'esempio riportato sopra, il browser deve analizzare ed eseguire lo script per scoprire quali script scaricare. In questo modo gli script vengono nascosti dagli scanner di precaricamento. I browser utilizzano questi scanner per trovare risorse nelle pagine che è probabile che visiterai in seguito o per individuare risorse nelle pagine mentre il parser è bloccato da un'altra risorsa.

Possiamo aggiungere di nuovo la rilevabilità inserendo questo testo nell'intestazione del documento:

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

Questo indica al browser che la pagina ha bisogno dei formati 1.js e 2.js. link[rel=subresource] è simile a link[rel=prefetch], ma con semantica diversa. Purtroppo al momento è supportato solo in Chrome e devi dichiarare quali script caricare due volte, una volta tramite gli elementi link e di nuovo nel tuo script.

Correzione: inizialmente ho dichiarato che i problemi sono stati rilevati dallo scanner di precaricamento, ma non dal parser standard. Tuttavia, lo strumento di scansione del precaricamento potrebbe raccogliere questi elementi, ma non ancora, mentre gli script inclusi dal codice eseguibile non possono mai essere precaricati. Ringraziamo Yoav Weiss che mi ha corretto nei commenti.

Questo articolo è deprimente

La situazione è deprimente e dovresti sentirti depressa. Non esiste un modo non ripetitivo ma dichiarativo per scaricare gli script in modo rapido e asincrono, controllando al contempo l'ordine di esecuzione. Con HTTP2/SPDY è possibile ridurre il sovraccarico delle richieste al punto tale che la distribuzione degli script in più piccoli file memorizzabili singolarmente nella cache può essere il modo più rapido. Ecco un possibile scenario:

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

Ogni script di miglioramento ha a che fare con un particolare componente della pagina, ma richiede funzioni di utilità in dipendenze.js. Idealmente, vogliamo scaricare tutti i file in modo asincrono, quindi eseguire gli script di miglioramento il prima possibile, in qualsiasi ordine, ma dopo dipendenze.js. È un miglioramento progressivo! Sfortunatamente, non c'è un modo dichiarativo per raggiungere questo obiettivo, a meno che gli script stessi non vengano modificati per monitorare lo stato di caricamento di dipendenze.js. Anche async=false non risolve questo problema, come l'esecuzione di outbound-10.js bloccherà su 1-9. Esiste infatti un solo browser che rende possibile tutto questo senza compromissioni...

IE ha un'idea.

IE carica gli script in modo diverso rispetto agli altri browser.

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

IE inizia a scaricare "whatever.js", mentre gli altri browser non avviano il download finché lo script non è stato aggiunto al documento. IE include anche un evento "readystatechange" e una proprietà "readystate", che indicano l'avanzamento del caricamento. Ciò è davvero molto utile, in quanto ci consente di controllare il caricamento e l'esecuzione degli script in modo indipendente.

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

Possiamo creare modelli di dipendenza complessi scegliendo quando aggiungere script al documento. IE supporta questo modello dalla versione 6. Piuttosto interessante, ma soffre ancora dello stesso problema di rilevabilità del preloader di async=false.

Abbastanza! Come devo caricare gli script?

Ok, ok. Se vuoi caricare gli script in un modo che non blocchi il rendering, non preveda ripetizioni e abbia un eccellente supporto del browser, propongo quanto segue:

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

Questo. Alla fine dell'elemento body. Sì, essere uno sviluppatore web è come essere il re Sisifo. 100 punti hipster da usare come riferimento per la mitologia greca!). Le limitazioni di HTML e browser ci impediscono di ottenere risultati molto migliori.

Spero che i moduli JavaScript ci aiutino a risparmiare offrendo un modo dichiarativo non bloccante per caricare gli script e dare il controllo sull'ordine di esecuzione, anche se questo richiede la scrittura degli script come moduli.

Ci deve essere qualcosa di migliore che possiamo usare ora?

Abbastanza bene, per ottenere punti bonus, se vuoi essere molto aggressivo nel rendimento e non ti dispiace un po' di complessità e ripetizioni, puoi combinare alcuni dei trucchi precedenti.

Per prima cosa, aggiungiamo la dichiarazione delle sottorisorse per i preloader:

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

Quindi, in linea nella sezione head del documento, carichiamo i nostri script con JavaScript, utilizzando async=false, tornando al caricamento dello script basato su readystate di IE, e terminando con il differimento.

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

Alcuni trucchi e minimizzazioni successive sono di 362 byte + gli URL dello 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 la pena includere i byte in più rispetto a uno script semplice? Se utilizzi già JavaScript per caricare gli script in modo condizionale, come fa la BBC, potrebbe essere utile attivare questi download prima. Altrimenti, magari no, segui il semplice metodo end-of-body.

Fiuuuu, ora so perché la sezione di caricamento dello script WhatWG è così vasta. Mi serve un drink.

Riferimento rapido

Elementi di script semplici

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

Le specifiche indicano: i download insieme, l'esecuzione in ordine dopo qualsiasi CSS in attesa, il blocco del rendering fino al completamento. I browser dicono: Sì, signore!

Rimanda

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

Le specifiche dichiarano: vengono scaricati insieme ed eseguiti nell'ordine appena prima di DOMContentLoaded. Ignora "defer" negli script senza "src". IE < 10 afferma: potrei eseguire 2.js a metà dell'esecuzione di 1.js. Non è divertente? I browser in rosso dicono: Non ho idea di cosa sia questa cosa che "rimanda": caricherò gli script come se non ci fossero. Altri browser dicono: Ok, ma potrei non ignorare l'opzione "rimanda" negli script senza "src".

Asinc

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

Le specifiche indicano: i download insieme, l'esecuzione in qualsiasi ordine in cui vengono scaricati. I browser in rosso dicono: Che cos'è "asinc"? Caricherò gli script come se non fossero presenti. Altri browser dicono: Sì, 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);
});

Le specifiche indicano: i download insieme, l'esecuzione in ordine non appena termina il download. Firefox < 3.6, Opera afferma: Non ho idea di cosa sia questa cosa "asin", ma così accade che eseguo gli script aggiunti tramite JS nell'ordine in cui sono aggiunti. Secondo Safari 5.0, capisco "asinc", ma non capisco come impostarla su "false" con JS. Eseguirò i tuoi script non appena vengono ricevuti, in qualsiasi ordine. IE < 10 dice: Non ho idea di "asinc", ma esiste una soluzione alternativa utilizzando "onreadystatechange". Altri browser in rosso dicono: Non capisco questa cosa "asincrona", pertanto eseguirò i tuoi script non appena arrivano, in qualsiasi ordine. Tutto il resto dice: "Sono tuo amico", lo faremo per noi.