Immergiti nelle acque torbide del caricamento degli script

Jake Archibald
Jake Archibald

Introduzione

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

No, aspetta, torna indietro. So che sembra banale e semplice, ma ricorda che questo accade nel browser, dove la semplicità teorica diventa un problema dovuto a elementi legacy. Conoscere queste peculiarità ti consente di scegliere il modo più rapido e meno invasivo per caricare gli script. Se hai poco tempo, vai alla sezione di riferimento rapido.

Per iniziare, ecco come la specifica definisce i vari modi in cui uno script può essere scaricato ed eseguito:

WHATWG sul caricamento dello script
Il WHATWG sul caricamento degli script

Come tutte le specifiche WHATWG, inizialmente sembra il risultato di una bomba a grappolo in una fabbrica di Scrabble, ma dopo averla letta per la quinta volta e aver asciugato 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>

Che dolce semplicità. In questo caso, il browser scaricherà entrambi gli script in parallelo ed eseguirli il prima possibile, mantenendone l'ordine. "2.js" non verrà eseguito finché non sarà stato eseguito (o non avrà avuto esito negativo) "1.js", "1.js" non verrà eseguito finché non sarà stato eseguito lo script o lo stile precedente e così via.

Purtroppo, durante questo processo il browser blocca l'ulteriore rendering della pagina. Questo è dovuto alle API DOM della "prima era del web" che consentono di aggiungere stringhe ai contenuti analizzati dal parser, ad esempio document.write. I browser più recenti continueranno a eseguire la scansione o l'analisi del documento in background e attiveranno i download dei contenuti esterni di cui potrebbe aver bisogno (JS, immagini, CSS e così via), ma il rendering è ancora bloccato.

Per questo motivo, i professionisti del rendimento consigliano di inserire gli elementi di script alla fine del documento, in modo da bloccare il minor numero possibile di contenuti. Purtroppo, significa che lo script non viene visualizzato dal browser finché non scarica tutto il codice HTML e, a quel punto, ha iniziato a scaricare altri contenuti, come CSS, immagini e iframe. I browser moderni sono abbastanza intelligenti da dare la priorità a JavaScript rispetto alle immagini, ma possiamo fare di meglio.

Grazie IE! (no, non è sarcasmo)

<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 il comando "defer" in Internet Explorer 4. In pratica, significa "Prometto di non iniettare elementi nel parser utilizzando elementi come document.write. Se non mantengo la promessa, puoi punirmi come meglio credi". Questo attributo è entrato a far parte di HTML4 ed è apparso in altri browser.

Nell'esempio precedente, il browser scaricherà entrambi gli script in parallelo ed eseguirli appena prima dell'attivazione di DOMContentLoaded, mantenendone l'ordine.

Come una bomba a grappolo in una fabbrica di pecore, "rimandare" è diventato un pasticcio lanoso. Tra gli attributi "src" e "defer" e i tag script rispetto agli script aggiunti dinamicamente, abbiamo 6 pattern di aggiunta di uno script. Naturalmente, i browser non erano d'accordo sull'ordine di esecuzione. Mozilla ha scritto un ottimo articolo sul problema nel 2009.

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

Grazie IE! (ok, ora sto scherzando)

Dona e toglie. Purtroppo, in IE4-9 esiste un brutto bug che può causare l'esecuzione degli script in un ordine imprevisto. Ecco cosa succede:

1.js

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

2.js

console.log('3');

Supponendo che nella pagina sia presente un paragrafo, l'ordine previsto dei log è [1, 2, 3], anche se in IE9 e versioni precedenti ottieni [1, 3, 2]. Operazioni DOM specifiche causano la messa in pausa dell'esecuzione dello script corrente e l'esecuzione di altri script in attesa prima di continuare.

Tuttavia, anche nelle implementazioni prive di bug, come IE10 e altri browser, l'esecuzione dello script viene ritardata fino a quando l'intero documento non è stato scaricato e analizzato. Questa opzione può essere comoda se comunque intendi attendere DOMContentLoaded, ma se vuoi ottenere un rendimento davvero elevato, puoi iniziare ad aggiungere ascoltatori e avviare l'avvio iniziale prima…

HTML5 viene in soccorso

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

HTML5 ci ha fornito un nuovo attributo, "async", che presuppone che non utilizzerai document.write, ma non attende l'analisi del documento per l'esecuzione. Il browser scaricherà entrambi gli script in parallelo ed eseguirli il prima possibile.

Purtroppo, poiché verranno eseguiti il prima possibile, "2.js" potrebbe essere eseguito prima di "1.js". Non c'è problema se sono indipendenti, ad esempio se "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 dipende "2.js", la tua pagina sarà ricoperta di errori, come una bomba a grappolo in un… non so… non ho niente per questo.

So cosa ci serve, una libreria JavaScript.

L'obiettivo finale è scaricare immediatamente un insieme di script senza bloccare il rendering ed eseguirli il prima possibile nell'ordine in cui sono stati aggiunti. Purtroppo HTML non ti ama e non ti consente di farlo.

Il problema è stato affrontato da JavaScript in alcune versioni. Alcune richiedevano di apportare modifiche al codice JavaScript, inserendo un callback chiamato dalla libreria nell'ordine corretto (ad es. RequireJS). Altri utilizzavano XHR per il download in parallelo e poi eval() nell'ordine corretto, il che non funzionava per gli script su un altro dominio, a meno che non avessero un'intestazione CORS e il browser la supportasse. Alcuni hanno persino utilizzato hack super magici, come LabJS.

Gli hack prevedevano di indurre il browser a scaricare la risorsa in modo da attivare un evento al termine, 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, questi venivano aggiunti di nuovo con un tipo corretto, sperando che il browser li recuperasse direttamente dalla cache ed eseguisse immediatamente, in ordine. Questo dipendeva da un comportamento pratico, ma non specificato, e si è interrotto quando HTML5 ha dichiarato che i browser non devono scaricare script con un tipo non riconosciuto. Vale la pena notare che LabJS si è adattato a queste modifiche e ora utilizza una combinazione dei metodi descritti in questo articolo.

Tuttavia, i caricatori di script hanno un problema di prestazioni proprio: devi attendere che il codice JavaScript della libreria venga scaricato e analizzato prima che qualsiasi script gestito possa iniziare a essere scaricato. Inoltre, come faremo a caricare il caricatore di script? Come faremo a caricare lo script che dice al caricatore di script cosa caricare? Chi guarda Watchmen? Perché sono nuda? Sono tutte domande difficili.

In sostanza, se devi scaricare un file di script aggiuntivo prima ancora di pensare di scaricare altri script, hai già perso la battaglia per le prestazioni.

Il DOM in soccorso

La risposta è in realtà nella specifica HTML5, anche se è nascosta nella parte inferiore della sezione di caricamento dello script.

Traduzione in "Terreno":

[
  '//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 dinamicamente al documento sono asincroni per impostazione predefinita, non bloccano il rendering e vengono eseguiti non appena vengono scaricati, il che significa 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);
});

Ciò conferisce ai nostri script una combinazione di comportamenti che non è possibile ottenere con il semplice HTML. Poiché non sono esplicitamente asincroni, gli script vengono aggiunti a una coda di esecuzione, la stessa a cui vengono aggiunti nel nostro primo esempio HTML non elaborato. Tuttavia, poiché vengono creati dinamicamente, vengono eseguiti al di fuori dell'analisi del documento, quindi il rendering non viene bloccato durante il download (non confondere il caricamento di script non asincroni con XHR sincrono, che non è mai una buona idea).

Lo script riportato sopra deve essere incluso in linea nell'intestazione delle pagine, mettere in coda i download degli script il prima possibile senza interrompere il rendering progressivo ed essere eseguito il prima possibile nell'ordine specificato. "2.js" può essere scaricato prima di "1.js", ma non verrà eseguito finché "1.js" non viene scaricato ed eseguito correttamente o non viene eseguito. Evviva! async-download, ma con esecuzione ordinata.

Il caricamento degli script in questo modo è supportato da tutto ciò che supporta l'attributo async, ad eccezione di Safari 5.0 (5.1 è OK). Inoltre, sono supportate tutte le versioni di Firefox e Opera, in quanto le versioni che non supportano l'attributo async eseguono comunque comodamente gli script aggiunti dinamicamente nell'ordine in cui vengono aggiunti al documento.

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

Sì, se decidi dinamicamente quali script caricare, altrimenti forse no. Nell'esempio riportato sopra, il browser deve analizzare ed eseguire lo script per scoprire quali script scaricare. In questo modo i tuoi script vengono nascosti agli scanner di precaricamento. I browser utilizzano questi scanner per trovare le risorse nelle pagine che probabilmente visiterai successivamente o per trovare le risorse della pagina mentre il parser è bloccato da un'altra risorsa.

Possiamo aggiungere nuovamente la rilevabilità inserendo questo codice 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 di 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 elementi link e di nuovo nello script.

Correzione: inizialmente ho affermato che questi elementi venivano rilevati dallo scanner di precarica, ma non è così, vengono rilevati dal parser normale. Tuttavia, lo scanner di precaricamento potrebbe rilevarli, ma non lo fa ancora, mentre gli script inclusi dal codice eseguibile non possono mai essere precaricati. Grazie a Yoav Weiss che mi ha corretto nei commenti.

Questo articolo mi deprime

La situazione è deprimente e dovresti sentirti depresso. 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 puoi ridurre l'overhead delle richieste al punto che l'invio di script in più piccoli file archiviabili singolarmente può essere il modo più veloce. Immagina:

<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 gestisce un determinato componente della pagina, ma richiede funzioni di utilità in dependencies.js. Idealmente, vogliamo scaricare tutto in modo asincrono, quindi eseguire gli script di miglioramento il prima possibile, in qualsiasi ordine, ma dopo dependencies.js. È un potenziamento progressivo progressivo. Purtroppo non esiste un modo dichiarativo per farlo, a meno che gli script stessi non vengano modificati per monitorare lo stato di caricamento di dependencies.js. Anche async=false non risolve il problema, poiché l'esecuzione di enhancement-10.js si bloccherà su 1-9. In realtà, esiste un solo browser che lo rende possibile senza trucchi…

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" ora, mentre gli altri browser non avviano il download finché lo script non è stato aggiunto al documento. IE ha anche un evento, "readystatechange", e una proprietà, "readystate", che ci indicano l'avanzamento del caricamento. Questo è 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. Molto interessante, ma presenta ancora lo stesso problema di rilevabilità del preloader di async=false.

Basta! Come faccio a caricare gli script?

Ok ok. Se vuoi caricare gli script in modo che non blocchino il rendering, non richiedano ripetizioni e abbiano un'eccellente compatibilità con i browser, ecco cosa ti propongo:

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

Come sembra. Alla fine dell'elemento body. Sì, essere uno sviluppatore web è un po' come essere il re Sisifo (boom! 100 punti hipster per il riferimento alla mitologia greca. Le limitazioni di HTML e dei browser ci impediscono di fare molto di più.

Spero che i moduli JavaScript ci salvino fornendo un modo dichiarativo e non bloccante per caricare gli script e dare il controllo sull'ordine di esecuzione, anche se questo richiede che gli script vengano scritti come moduli.

Eww, ci deve essere qualcosa di meglio che possiamo usare ora?

Giusto, per ottenere punti bonus, se vuoi ottenere un rendimento davvero elevato e non ti dispiace un po' di complessità e ripetizione, puoi combinare alcuni dei trucchi sopra indicati.

Innanzitutto, aggiungiamo la dichiarazione della risorsa secondaria per i preloader:

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

Poi, in linea nell'elemento head del documento, carichiamo i nostri script con JavaScript, utilizzando async=false, passando al caricamento dello script basato su readystate di IE, passando a defer.

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

Dopo alcuni trucchi e la minimizzazione, il codice misura 362 byte + gli URL degli 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 utilizzare i byte aggiuntivi rispetto a una semplice inclusione di script? Se utilizzi già JavaScript per caricare gli script in modo condizionale, come fa la BBC, potresti trarre vantaggio dall'attivazione anticipata di questi download. In caso contrario, forse è meglio utilizzare il semplice metodo di fine del corpo.

Ora so perché la sezione di caricamento dello script WHATWG è così vasta. Ho bisogno di un drink.

Riferimento rapido

Elementi di script semplici

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

Le specifiche indicano: scarica insieme, esegui in ordine dopo eventuali CSS in attesa, blocca il rendering fino al completamento. I browser dicono: Sì, signore.

Rimandare

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

La specifica dice: scarica insieme, esegui in ordine appena prima di DOMContentLoaded. Ignora "defer" negli script senza "src". IE < 10 dice: 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 "posticipa", caricherò gli script come se non ci fossero. Altri browser dicono: Ok, ma potrei non ignorare "defer" negli script senza "src".

Asinc

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

La specifica prevede: scarica insieme, esegui nell'ordine in cui vengono scaricati. I browser in rosso dicono: che cos'è "async"? Caricherò gli script come se non fossero presenti. Gli 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);
});

La specifica dice: scarica insieme, esegui in ordine non appena tutti i download sono stati completati. Firefox < 3.6, Opera dice: Non ho idea di cosa sia questa cosa "asincrona", ma è un caso che esegua gli script aggiunti tramite JS nell'ordine in cui vengono aggiunti. Safari 5.0 dice: capisco "async", ma non capisco come impostarlo su "false" con JS. Eseguirò i tuoi script non appena vengono pubblicati, in qualsiasi ordine. IE < 10 dice: Non ho idea di cosa sia "async", ma esiste una soluzione alternativa che utilizza "onreadystatechange". Gli altri browser in rosso dicono: Non capisco questa cosa di "async", eseguirò i tuoi script non appena vengono caricati, in qualsiasi ordine. Tutto il resto dice: sono tuo amico, lo faremo nel rispetto delle regole.