Sumérgete en las oscuras aguas de la carga de secuencias de comandos

Introducción

En este artículo, te enseñaré a cargar código JavaScript en el navegador y ejecutarlo.

No, espera, vuelve. Sé que suena mundano y simple, pero recuerda que esto sucede en el navegador, donde lo teóricamente simple se convierte en un error de comportamiento heredado. Conocer estas peculiaridades le permite elegir la forma más rápida y menos disruptiva de cargar secuencias de comandos. Si tienes un horario apretado, ve a la referencia rápida.

Para empezar, esta es la forma en que la especificación define las diferentes formas en que una secuencia de comandos podría descargarse y ejecutarse:

WHATWG sobre la carga de secuencias de comandos
WHATWG sobre la carga de secuencias de comandos

Al igual que todas las especificaciones de WHATWG, al principio parece el resultado de una bomba de racimo en una fábrica de Scrabble, pero una vez que lo lees por quinta vez y te limpias la sangre de los ojos, resulta bastante interesante:

Mi primer guion incluye

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

¡Ah, la simplicidad! Aquí, el navegador descargará ambas secuencias de comandos en paralelo y las ejecutará lo antes posible, manteniendo su orden. “2.js” no se ejecutará hasta que se ejecute “1.js” (o no se ejecute), “1.js” no se ejecutará hasta que se ejecute la secuencia de comandos o el diseño de hojas de estilo anterior, etcétera.

Lamentablemente, mientras todo esto sucede, el navegador bloquea la renderización de la página. Esto se debe a las APIs de DOM de “la primera era de la Web” que permiten que se adjunten cadenas al contenido que analiza el analizador, como document.write. Los navegadores más nuevos seguirán analizando o analizando el documento en segundo plano y activarán las descargas de contenido externo que puedan necesitar (js, imágenes, css, etc.), pero la renderización seguirá bloqueada.

Por este motivo, los expertos en rendimiento recomiendan colocar los elementos de secuencia de comandos al final del documento, ya que bloquean la menor cantidad de contenido posible. Lamentablemente, esto significa que el navegador no ve tu secuencia de comandos hasta que descarga todo el código HTML y, en ese momento, ya comenzó a descargar otro contenido, como CSS, imágenes y iframes. Los navegadores modernos son lo suficientemente inteligentes como para priorizar JavaScript sobre las imágenes, pero podemos hacer mejor.

Gracias, IE. (no, no estoy siendo sarcástico)

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

Microsoft reconoció estos problemas de rendimiento y presentó la función “aplaza” en Internet Explorer 4. Básicamente, esto dice: “Prometo no insertar elementos en el analizador con elementos como document.write. Si no cumplo con esa promesa, puede castirme de la manera que mejor le parezca”. Este atributo se convirtió en HTML4 y apareció en otros navegadores.

En el ejemplo anterior, el navegador descargará ambas secuencias de comandos en paralelo y las ejecutará justo antes de que se active DOMContentLoaded, manteniendo su orden.

Como una bomba de racimo en una fábrica de ovejas, “aplazar” se convirtió en un lío lanoso. Entre los atributos "src" y "defer", y las etiquetas de secuencia de comandos en comparación con las secuencias de comandos agregadas de forma dinámica, tenemos 6 patrones para agregar una secuencia de comandos. Por supuesto, los navegadores no coincidieron en el orden que deberían ejecutar. Mozilla escribió un excelente artículo sobre el problema y se remonta al año 2009.

WHATWG hizo que el comportamiento fuera explícito y declaró que “aplazar” no tiene efecto en las secuencias de comandos que se agregaron de forma dinámica o que no tenían “src”. De lo contrario, las secuencias de comandos diferidas deberían ejecutarse después de que se analice el documento, en el orden en que se agregaron.

Gracias, IE (ahora sí estoy siendo sarcástica)

Da y quita. Lamentablemente, hay un error desagradable en IE4-9 que puede hacer que las secuencias de comandos se ejecuten en un orden inesperado. Esto es lo que sucede:

1.js

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

2.js

console.log('3');

Si suponemos que hay un párrafo en la página, el orden esperado de los registros es [1, 2, 3], aunque en IE9 y versiones anteriores, se obtiene [1, 3, 2]. Algunas operaciones de DOM hacen que IE pause la ejecución de la secuencia de comandos actual y ejecute otras secuencias de comandos pendientes antes de continuar.

Sin embargo, incluso en implementaciones sin errores, como IE10 y otros navegadores, la ejecución de la secuencia de comandos se retrasa hasta que se descarga y analiza todo el documento. Esto puede ser conveniente si, de todos modos, vas a esperar a DOMContentLoaded, pero si quieres ser realmente agresivo con el rendimiento, puedes comenzar a agregar objetos de escucha y a inicializar antes…

HTML5 al rescate

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

HTML5 nos dio un nuevo atributo, “async”, que supone que no usarás document.write, pero no espera hasta que el documento se analice para ejecutarse. El navegador descargará ambas secuencias de comandos en paralelo y las ejecutará lo antes posible.

Lamentablemente, como se ejecutarán lo antes posible, es posible que “2.js” se ejecute antes que “1.js”. Esto está bien si son independientes, tal vez “1.js” sea una secuencia de comandos de seguimiento que no tiene nada que ver con “2.js”. Sin embargo, si “1.js” es una copia de jQuery de CDN de la que depende “2.js”, tu página se llenará de errores, como una bomba de racimo en un… no sé… No tengo nada para esto.

Sé lo que necesitamos: una biblioteca de JavaScript.

El santo grial es tener un conjunto de secuencias de comandos que se descargan de inmediato sin bloquear la renderización y se ejecutan lo antes posible en el orden en que se agregaron. Lamentablemente, HTML te odia y no te permitirá hacerlo.

JavaScript abordó el problema en algunos tipos. Algunos requerían que realizaras cambios en tu código JavaScript y lo unieras en una devolución de llamada a la que la biblioteca llama en el orden correcto (p. ej., RequireJS). Otros usaban XHR para descargar en paralelo y, luego, eval() en el orden correcto, lo que no funcionaba para las secuencias de comandos en otro dominio, a menos que tuvieran un encabezado CORS y el navegador lo admitiera. Algunos incluso usaron hacks supermágicos, como LabJS.

Estos trucos implicaban engañar al navegador para que descargara el recurso de una manera que activaría un evento cuando se completó, pero evitaría ejecutarlo. En LabJS, la secuencia de comandos se agregaría con un tipo MIME incorrecto, p. ej., <script type="script/cache" src="...">. Una vez que se descargaran todas las secuencias de comandos, se volverían a agregar con un tipo correcto, con la esperanza de que el navegador las obtenga directamente de la caché y las ejecute de inmediato, en orden. Esto dependía de un comportamiento conveniente, pero no especificado, y fallaba cuando HTML5 declaraba que los navegadores no debían descargar secuencias de comandos con un tipo no reconocido. Vale la pena señalar que LabJS se adaptó a estos cambios y ahora usa una combinación de los métodos de este artículo.

Sin embargo, los cargadores de secuencias de comandos tienen su propio problema de rendimiento. Debes esperar a que el código JavaScript de la biblioteca se descargue y analice antes de que pueda comenzar a descargarse cualquiera de las secuencias de comandos que administra. Además, ¿cómo cargaremos el cargador de secuencias de comandos? ¿Cómo vamos a cargar la secuencia de comandos que le indica al cargador de secuencias de comandos qué cargar? ¿Quién vigila a los vigilantes? ¿Por qué estoy desnudo? Todas estas son preguntas difíciles.

Básicamente, si tienes que descargar un archivo de secuencia de comandos adicional antes de pensar en descargar otras secuencias de comandos, ya perdiste la batalla de rendimiento.

El DOM al rescate

La respuesta está en la especificación de HTML5, aunque está oculta en la parte inferior de la sección de carga de secuencias de comandos.

Traduzcamos eso a “Terremoto”:

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

Las secuencias de comandos que se crean y agregan al documento de forma dinámica son asíncronas de forma predeterminada, no bloquean la renderización y se ejecutan en cuanto se descargan, lo que significa que podrían aparecer en el orden incorrecto. Sin embargo, podemos marcarlos de forma explícita como no así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);
});

Esto les brinda a nuestras secuencias de comandos una combinación de comportamientos que no se pueden lograr con HTML simple. Dado que no son explícitamente asíncronas, las secuencias de comandos se agregan a una cola de ejecución, la misma cola a la que se agregan en nuestro primer ejemplo de HTML sin formato. Sin embargo, como se crean de forma dinámica, se ejecutan fuera del análisis de documentos, por lo que la renderización no se bloquea mientras se descargan (no confundas la carga de secuencias de comandos no asíncronas con XHR síncrona, lo que nunca es bueno).

La secuencia de comandos anterior debe incluirse intercalada en el encabezado de las páginas, poner en cola las descargas de secuencias de comandos lo antes posible sin interrumpir la renderización progresiva y se ejecuta lo antes posible en el orden que especificaste. La descarga de “2.js” es gratuita antes de que “1.js”, pero no se ejecutará hasta que “1.js” se haya descargado y ejecutado correctamente, o hasta que no se ejecute correctamente. ¡Hurra! Descarga asíncrona, pero ejecución ordenada.

La carga de secuencias de comandos de esta manera es compatible con todo lo que admite el atributo async, a excepción de Safari 5.0 (5.1 está bien). Asimismo, todas las versiones de Firefox y Opera son compatibles como versiones que no admiten el atributo asíncrono que ejecutan secuencias de comandos agregadas de forma dinámica en el orden en el que, de todos modos, se agregan al documento.

Esa es la forma más rápida de cargar secuencias de comandos, ¿no? ¿Cierto?

Bueno, si decides de forma dinámica qué secuencias de comandos cargar, sí, de lo contrario, tal vez no. Con el ejemplo anterior, el navegador debe analizar y ejecutar la secuencia de comandos para descubrir qué secuencias de comandos descargar. Esto oculta tus secuencias de comandos de los escáneres de precarga. Los navegadores usan estos escáneres para descubrir recursos en las páginas que es probable que visites a continuación o para descubrir recursos de la página mientras otro recurso bloquea el analizador.

Para agregar visibilidad nuevamente, coloca esto en el encabezado del documento:

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

Esto le indica al navegador que la página necesita 1.js y 2.js. link[rel=subresource] es similar a link[rel=prefetch], pero con semántica diferente. Lamentablemente, por el momento, solo es compatible con Chrome, y debes declarar qué secuencias de comandos cargar dos veces, una a través de elementos de vínculo y otra en tu secuencia de comandos.

Corrección: Originalmente, indiqué que el escáner de carga previa detectaba estos errores, pero no es así, los detecta el analizador normal. Sin embargo, el escáner de carga previa podría detectarlos, pero aún no lo hace, mientras que las secuencias de comandos incluidas por el código ejecutable nunca se pueden precargar. Gracias a Yoav Weiss, que me corrigió en los comentarios.

Este artículo me deprime

La situación es deprimente y deberías sentirte deprimido. No hay una forma no repetitiva pero declarativa de descargar secuencias de comandos de forma rápida y asíncrona mientras se controla el orden de ejecución. Con HTTP2/SPDY, puedes reducir la sobrecarga de la solicitud al punto en que la forma más rápida sea entregar secuencias de comandos en varios archivos pequeños que se pueden almacenar en caché de manera individual. Imagina lo siguiente:

<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 secuencia de comandos de mejora se ocupa de un componente de página en particular, pero requiere funciones de utilidad en dependencies.js. Lo ideal es descargar todo de forma asíncrona y, luego, ejecutar las secuencias de comandos de mejora lo antes posible, en cualquier orden, pero después de dependencies.js. Es una mejora progresiva progresiva. Lamentablemente, no hay una forma declarativa de lograr esto, a menos que se modifiquen las secuencias de comandos para hacer un seguimiento del estado de carga de dependencies.js. Incluso async=false no resuelve este problema, ya que la ejecución de enhancement-10.js se bloqueará en 1-9. De hecho, solo hay un navegador que lo hace posible sin hacks…

IE tiene una idea

IE carga secuencias de comandos de manera diferente a otros navegadores.

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

IE comienza a descargar “whatever.js” ahora, mientras que otros navegadores no comienzan a descargar hasta que se agrega la secuencia de comandos al documento. IE también tiene un evento, "readystatechange", y una propiedad, "readystate", que nos indican el progreso de la carga. Esto es muy útil, ya que nos permite controlar la carga y ejecución de secuencias de comandos de forma independiente.

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 compilar modelos de dependencia complejos eligiendo cuándo agregar secuencias de comandos al documento. IE admite este modelo desde la versión 6. Es bastante interesante, pero aún tiene el mismo problema de visibilidad del cargador previo que async=false.

Ya basta. ¿Cómo debo cargar las secuencias de comandos?

Muy bien. Si deseas cargar secuencias de comandos de una manera que no bloquee la renderización, no implique repetición y tenga una excelente compatibilidad con el navegador, te propongo lo siguiente:

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

Eso. Al final del elemento del cuerpo Sí, ser desarrollador web es muy parecido a ser el rey Sísifo (¡bum! 100 puntos hipster por la referencia a la mitología griega). Las limitaciones en HTML y navegadores nos impiden hacer un trabajo mucho mejor.

Espero que los módulos de JavaScript nos salven, ya que proporcionan una forma declarativa y no bloqueante de cargar secuencias de comandos y controlar el orden de ejecución, aunque esto requiere que las secuencias de comandos se escriban como módulos.

¿Debería haber algo mejor que podamos usar ahora?

Tienes razón. Para obtener puntos adicionales, si quieres ser muy agresivo con el rendimiento y no te importa un poco de complejidad y repetición, puedes combinar algunos de los trucos anteriores.

Primero, agregamos la declaración de subrecurso para los precargadores:

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

Luego, intercalamos nuestras secuencias de comandos con JavaScript en el encabezado del documento, usando async=false, y recurrimos a la carga de secuencias de comandos basada en el estado listo de IE y, luego, a la demora.

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

Después de algunos trucos y reducción, son 362 bytes más las URLs de tu secuencia de comandos:

!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 usar los bytes adicionales en comparación con una inclusión de secuencia de comandos simple? Si ya utilizas JavaScript para cargar secuencias de comandos de forma condicional, como lo hace la BBC, también podrías beneficiarte de activar esas descargas antes. De lo contrario, quizás no, quédate con el método simple de final del cuerpo.

Uf, ahora sé por qué la sección de carga de secuencias de comandos de WHATWG es tan extensa. Necesito un trago.

Referencia rápida

Elementos de secuencia de comandos sin formato

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

Según las especificaciones: Descargar juntos, ejecutar en orden después de cualquier CSS pendiente, bloquear la renderización hasta que se complete. Los navegadores dicen: ¡Sí, señor!

Aplazar

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

La especificación indica lo siguiente: Descargar juntos, ejecutar en orden justo antes de DOMContentLoaded. Ignora “aplaza” en secuencias de comandos sin “src”. IE < 10 dice: Podría ejecutar 2.js a mitad de la ejecución de 1.js. ¿No es divertido? Los navegadores en rojo dicen: No tengo idea de qué es esta opción de "diferir", voy a cargar las secuencias de comandos como si no estuvieran allí. Otros navegadores dicen: De acuerdo, pero es posible que no ignoremos "aplazar" en secuencias de comandos sin "src".

Asíncrono

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

La especificación dice lo siguiente: Descargar juntos, ejecutar en cualquier orden en el que se descarguen. Los navegadores en rojo dicen: ¿Qué es “async”? Voy a cargar las secuencias de comandos como si no estuviera allí. Otros navegadores dicen lo siguiente: Sí, de acuerdo.

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 especificación dice lo siguiente: Descargar juntos y ejecutar en orden en cuanto se descarguen todos. Firefox < 3.6, Opera dice: No tengo idea de qué es esto "asíncrono", pero en realidad ejecuto las secuencias de comandos que se agregan a través de JS en el orden en que se agregan. Safari 5.0 dice: Comprendo el término “asíncrono”, pero no entiendo configurarlo como “falso” con JS. Ejecutaré tus secuencias de comandos en cuanto lleguen, en cualquier orden. IE < 10 dice: No tengo idea de "async", pero hay una solución alternativa con "onreadystatechange". Otros navegadores en rojo dicen: No entiendo este asunto de "async", ejecutaré tus secuencias de comandos en cuanto lleguen, en cualquier orden. Todo lo demás dice: Soy tu amigo, vamos a hacer esto según el libro.