Compilación perezosa: cómo el motor evita compilar lo que no vas a llamar
El preparseo, la heurística que decide qué se compila de inmediato, por qué un paréntesis puede cambiar el tiempo de arranque, y las pistas explícitas de compilación que existen hoy.
En un bundle típico, la mayor parte de las funciones declaradas no se llegan a llamar en la carga inicial: son manejadores de eventos, rutas alternativas, ramas de error, utilidades de un módulo del que solo usas una función. Compilarlas todas sería tirar tiempo, y los motores llevan una década evitándolo con una técnica que funciona muy bien y que tiene una consecuencia sorprendente: la forma en que escribes el código cambia cuánto trabajo hace el motor al arrancar, aunque el código haga exactamente lo mismo.
- Describir qué hace el preparseo y en qué se diferencia del parseo completo.
- Explicar la heurística de compilación ansiosa y cómo se activa.
- Reconocer el patrón que hace que el motor compile dos veces la misma función.
- Valorar cuándo usar las pistas explícitas de compilación y por qué son un arma de doble filo.
Preparseo: leer sin entender del todo
Cuando el motor recibe un fichero de JavaScript, no construye el árbol sintáctico completo de todas las funciones. Hace dos pasadas conceptualmente distintas.
La primera es el preparseo. El motor recorre el texto entero, pero de las funciones solo determina lo mínimo: dónde empiezan y dónde acaban, qué variables declaran, si capturan variables del ámbito exterior, y si el código es sintácticamente válido. No construye el árbol interno, no resuelve nada, no genera bytecode. Es aproximadamente el doble de rápido que el parseo completo.
La segunda es el parseo completo, que solo ocurre para el código que de verdad se va a ejecutar: el nivel superior del módulo y las funciones que se llaman. Cuando una función preparseada se invoca por primera vez, el motor vuelve al texto, la parsea de verdad y la compila a bytecode.
El resultado es que un fichero de 640 KB descomprimidos, del que se ejecuta el 20 por ciento en el arranque, cuesta el preparseo de los 640 más el parseo completo de 128. Muchísimo menos que parsear 640 dos veces.
Esta arquitectura tiene una implicación de diseño que hay que interiorizar: el motor lee el texto entero de tu bundle, siempre, aunque no ejecutes nada de él. La compilación perezosa ahorra las fases caras, no la lectura. Por eso el tamaño total sigue importando incluso con código que nunca se llama, y por eso el troceado rinde más que confiar en la pereza del motor.
La heurística ansiosa, y el paréntesis mágico
Preparsear una función que se va a llamar inmediatamente es trabajo tirado: hay que volver a leerla enseguida. Por eso los motores intentan detectar las funciones que se van a ejecutar ya, y esas las compilan de forma ansiosa desde el principio.
La señal más antigua y más conocida es el paréntesis de apertura antes de la palabra function. Una expresión de función inmediatamente invocada, envuelta en paréntesis, es una pista fuerte de que va a ejecutarse ya:
// El motor lo detecta como ansiosa: parentesis antes de function
(function inicializar() {
configurarObservadores();
})();
Sin los paréntesis exteriores esa detección no ocurre, y algunos empaquetadores han llegado a añadirlos deliberadamente en el código generado. Es un detalle real, documentado por el equipo de V8, y también es una micro-optimización que casi nunca merece la pena buscar a mano: el efecto se mide en unidades de milisegundos salvo en ficheros enormes.
Lo que sí merece la pena es el caso contrario, que sí cuesta y aparece constantemente: la doble compilación. Ocurre cuando una función es preparseada, y después llamada, y el motor la parsea entera. Si además esa función encierra a otras, el trabajo se anida. El patrón que más lo dispara es el módulo que hace mucho trabajo en su nivel superior, porque el nivel superior siempre se compila de forma ansiosa y arrastra con él todo lo que llame:
// Coste de arranque: se ejecuta al importar, aunque nadie use la tabla
const TABLA = construirTablaDeConversiones(); // 4.000 entradas
export function convertir(v, de, a) {
return v * TABLA[`${de}:${a}`];
}
// Coste diferido: no se paga hasta la primera llamada real
let tabla = null;
export function convertir(v, de, a) {
tabla ??= construirTablaDeConversiones();
return tabla[`${de}:${a}`];
}
Los dos módulos exportan lo mismo y pesan casi lo mismo. El primero cuesta la construcción de la tabla en el arranque de todos los usuarios; el segundo, solo de los que convierten algo. Este patrón —mover el trabajo del nivel superior a la primera llamada— es la optimización de compilación perezosa más rentable que existe, y no requiere ninguna herramienta.
Pistas explícitas de compilación
En 2025 V8 añadió un mecanismo para invertir la decisión cuando sabes que la heurística se equivoca: un comentario mágico que fuerza la compilación ansiosa de todo el fichero.
//# allFunctionsCalledOnLoad
// El resto del modulo
export function arrancar() { /* ... */ }
El comentario tiene que ir en la primera línea del fichero. Le dice al motor que compile todas las funciones de ese fichero de inmediato, sin preparseo previo. La ganancia es contraintuitiva y viene de dónde ocurre el trabajo: la compilación ansiosa se puede hacer en un hilo de fondo, mientras que la compilación diferida ocurre en el hilo principal en el momento de la llamada. Mover trabajo del hilo principal a un hilo de fondo es la ganancia, no hacer menos trabajo.
Los datos que publicó el equipo de V8 sobre veinte páginas populares: diecisiete mejoraron, con una reducción media del tiempo de parseo y compilación en primer plano de unos 630 milisegundos. Es una cifra grande.
Y también es una función con filo. Los avisos que acompañan a la característica son serios y hay que respetarlos: compilar de más consume tiempo y memoria, y aplicar el comentario a un fichero donde de verdad la mayoría de las funciones no se llaman produce una regresión. Está disponible solo en Chromium desde la versión 136; los otros motores ignoran el comentario, que para ellos es un comentario normal. Y hay un problema de cadena de herramientas real: los minificadores y empaquetadores eliminan comentarios por defecto, así que hay que configurarlos explícitamente para conservarlo.
La regla práctica: úsalo solo en el bundle de entrada crítico, ese del que sabes que se ejecuta casi entero en el arranque, y mide antes y después. Nunca lo pongas por defecto en la plantilla de todos los módulos.
Hay una segunda capa de este tema que solo aparece cuando perfilas código que se ejecuta mucho, y que explica regresiones muy difíciles de diagnosticar.
Después de la compilación a bytecode, el motor observa qué funciones se ejecutan muchas veces y las recompila con el compilador optimizador, que genera código máquina especializado según los tipos que ha visto pasar. Esa recompilación tiene un límite de tamaño: las funciones por encima de cierto número de nodos en su representación intermedia no se optimizan nunca, porque el coste de optimizarlas supera el beneficio esperado.
La consecuencia es brutal y contraintuitiva: una función de 800 líneas que se ejecuta en un bucle caliente puede correr diez veces más lento que la misma lógica repartida en seis funciones de 130 líneas, porque las seis pequeñas se optimizan y se integran una dentro de otra, y la grande se queda en bytecode interpretado para siempre. He visto reescrituras que solo consistían en partir una función en trozos y que multiplicaron por cinco el rendimiento del bucle.
El mismo mecanismo explica un problema con los empaquetadores: la integración agresiva de módulos puede fabricar funciones gigantes que cruzan el umbral. Si tu compilación agresiva empeoró un bucle caliente sin cambiar la lógica, este es el sospechoso.
Cómo verlo sin adivinar. Arrancando el navegador con las banderas de traza del motor, se puede ver qué funciones se optimizan y cuáles se desoptimizan y por qué:
chrome --js-flags="--trace-opt --trace-deopt --trace-opt-verbose"La salida es densa pero el patrón que buscas es explícito: mensajes de rechazo de optimización con el motivo, y ciclos de optimización y desoptimización repetidos sobre la misma función, que indican que los tipos que le llegan cambian y el motor no puede especializar. La cura de ese segundo caso no es partir la función, es dejar de pasarle formas de objeto distintas en llamadas distintas.
Un matiz de honestidad: esto solo importa en código caliente, el que se ejecuta miles de veces. Aplicar estas ideas al código de arranque, que se ejecuta una vez, no sirve de nada y hace el código peor. Perfila antes de reescribir.
Qué hacer con esto mañana
Tres acciones concretas, ordenadas por rentabilidad.
Auditar el trabajo en el nivel superior de tus módulos. Busca en tu código constantes que se construyen con una llamada a función, instancias que se crean al importar, expresiones inmediatamente invocadas, registros de componentes. Cada uno de esos es coste de arranque garantizado. Muchos se convierten en perezosos con dos líneas.
Comprobar que tu empaquetador no rompe la pereza. Algunas configuraciones envuelven todo el bundle en una única función enorme, lo que fuerza el parseo completo de todo el contenido al llamarla. La forma de verlo es mirar la salida generada: si tu bundle es un solo (function(){ ... })() de un megabyte, la compilación perezosa está trabajando mucho peor de lo que podría.
Medir antes de tocar nada de esto. El panel de rendimiento distingue la compilación de la evaluación. Si tu tiempo de compilación es el 5 por ciento del total y la evaluación es el 70, todo este tema es irrelevante para ti y el trabajo está en reducir lo que se ejecuta.
Coge un módulo de tu proyecto que haga trabajo en el nivel superior. Mide el tiempo de evaluación de ese módulo aislado en el panel de rendimiento con CPU a 4x. Conviértelo a inicialización perezosa con el patrón de ??= y vuelve a medir. Después, en un bucle caliente de tu aplicación, arranca el navegador con --trace-opt y comprueba si alguna de tus funciones aparece rechazada por tamaño.