wandres.dev
JAVASCRIPT II · Presupuesto y análisis del bundle

Objetivos de compilación modernos: qué cambia cuando dejas de compilar hacia atrás

Dónde se declara el objetivo en cada herramienta, por qué hay dos o tres sitios que se contradicen, qué gana el motor con sintaxis moderna, y cómo verificar que la salida es realmente la que crees.

⏱ 17 min

El objetivo de compilación es el ajuste con mejor relación entre esfuerzo y resultado de todo este nivel: una línea de configuración que puede quitar el 20 por ciento del bundle y una parte proporcional del tiempo de ejecución. Y es también uno de los más fáciles de configurar mal, porque en un proyecto típico hay tres sitios distintos donde se declara —el transpilador, el minificador y el empaquetador— y basta con que uno de los tres esté en un valor antiguo para que el trabajo de los otros dos no sirva de nada.

🎯 Al terminar esta lección sabrás
  • Localizar todos los sitios donde se declara el objetivo en tu cadena de compilación.
  • Explicar qué gana el motor cuando recibe sintaxis moderna en lugar de su equivalente antiguo.
  • Verificar en el fichero de salida que el objetivo se ha aplicado de verdad.
  • Distinguir el objetivo de sintaxis del objetivo de APIs y tratarlos por separado.

Los tres sitios donde se declara, y cómo se contradicen

En una cadena de compilación moderna hay tres etapas que pueden bajar el nivel del código, y cada una tiene su propio ajuste.

El transpilador, que reescribe sintaxis. Se configura con browserslist o con un campo targets propio.

El minificador, que además de acortar nombres puede tener su propio objetivo de sintaxis y volver a bajar lo que el transpilador había dejado moderno, o negarse a aplicar transformaciones modernas que reducirían el tamaño.

El empaquetador, que genera el envoltorio de módulos, el código de carga de fragmentos y los ayudantes de interoperabilidad, y que tiene su propio ajuste de qué sintaxis emitir.

El fallo clásico es tener el transpilador configurado a un objetivo moderno y el minificador con el valor por defecto es5, que era el habitual en configuraciones de hace años. El resultado es un bundle que parece moderno en la configuración y es antiguo en el disco.

La configuración coherente en las tres herramientas más habituales:

// vite.config.js
export default {
  build: {
    // Objetivo del empaquetador y del minificador a la vez
    target: ['chrome111', 'edge111', 'firefox113', 'safari16.4'],
    minify: 'esbuild',
    cssTarget: ['chrome111', 'safari16.4'],
  },
};
// tsconfig.json — lo que TypeScript emite antes de pasar por el resto
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "useDefineForClassFields": true
  }
}
// package.json — la fuente de verdad para las herramientas que la leen
{
  "browserslist": ["chrome >= 111", "edge >= 111", "firefox >= 113", "safari >= 16.4", "not dead"]
}

Un detalle de TypeScript que merece atención: target en tsconfig.json no controla lo que llega al navegador si usas un empaquetador que hace su propia transpilación. En una configuración con Vite o esbuild, TypeScript solo elimina los tipos y el objetivo efectivo lo fija el empaquetador. Poner "target": "ES5" ahí y pensar que estás dando compatibilidad es un malentendido frecuente, y en el otro sentido, poner "target": "ESNext" no garantiza nada si el empaquetador baja después.

Qué gana el motor con sintaxis moderna

No es solo el tamaño. Hay tres ganancias de ejecución que se acumulan.

Las funciones asíncronas nativas se optimizan. Un async function que el motor reconoce como tal usa el mecanismo interno de continuaciones, que está muy optimizado y no asigna un objeto de estado por llamada. La versión transpilada a generador con máquina de estados asigna objetos, pasa por un tiempo de ejecución intermedio y impide que el compilador optimizador integre la función. En código asíncrono llamado con frecuencia, la diferencia se mide en decenas de por ciento.

Las clases nativas tienen formas ocultas estables. Cuando el motor ve una class con sus campos declarados, puede fijar la forma del objeto desde la construcción. La versión transpilada a función constructora con asignaciones sucesivas en el cuerpo hace que el objeto cambie de forma varias veces durante su creación, lo que fuerza transiciones internas y empeora el acceso posterior a propiedades.

Los operadores modernos son instrucciones, no llamadas. a?.b, a ?? b, a ||= b se compilan a saltos condicionales en el bytecode. Sus equivalentes transpilados son expresiones con variables temporales y comprobaciones múltiples. Individualmente es ruido; en un bucle sobre diez mil elementos deja de serlo.

Y hay una cuarta ganancia que es de tamaño puro y muy grande en código con muchos módulos: los módulos ES nativos no necesitan envoltorio. Cuando el objetivo permite emitir import y export reales, el empaquetador puede generar un fichero con módulos de verdad en lugar de envolver cada uno en una función y montar un registro con un cargador. Ese registro y esos envoltorios son entre 1 y 3 KB fijos más una sobrecarga por módulo que en aplicaciones con cientos de módulos se nota.

Verificar la salida, no la configuración

La única forma fiable de saber qué estás enviando es mirar el fichero. Cuatro comprobaciones sobre el bundle de producción, todas de una línea.

cd dist/assets

# 1. Sintaxis moderna presente: si no aparece nada, algo la esta bajando
grep -c 'async function\|=>\|??\|?\.' *.js | head

# 2. Ayudantes del transpilador: si aparecen, el objetivo es antiguo
grep -o '_asyncToGenerator\|regeneratorRuntime\|_createClass\|_objectSpread' *.js | sort | uniq -c

# 3. Polyfills del nucleo de compatibilidad
grep -c 'core-js\|es6-promise\|whatwg-fetch' *.js

# 4. Version efectiva de la sintaxis emitida, con el propio esbuild
npx esbuild --analyze --bundle /dev/null 2>/dev/null; node -e "
  const s=require('fs').readFileSync(process.argv[1],'utf8');
  const pruebas={'campos privados de clase':/#\w+\s*[=;)]/, 'encadenamiento opcional':/\?\./,
    'coalescencia nula':/\?\?/, 'funciones asincronas':/async\s+function|async\s*\(/, 'flecha':/=>/};
  for(const [n,re] of Object.entries(pruebas)) console.log(re.test(s)?'SI':'no ',n);
" *.js | head -20

Lo que buscas en la segunda comprobación es una salida vacía. Cualquier aparición de regeneratorRuntime o de _asyncToGenerator en un bundle de 2026 es una señal inequívoca de que hay un objetivo antiguo en algún punto de la cadena, y localizar cuál es cuestión de ir desactivando etapas.

Sintaxis y APIs son dos objetivos distintos

El error conceptual que queda por deshacer. El objetivo de compilación resuelve la sintaxis. No resuelve las APIs.

Si compilas para Safari 16.4 pero tu código llama a Array.prototype.findLast, el fichero se parsea perfectamente y falla en tiempo de ejecución si esa versión no tiene el método. La sintaxis y la disponibilidad de APIs avanzan por separado, y el compilador solo controla la primera.

Las herramientas para la segunda son dos, y conviene tener las dos.

Una regla de análisis estático que compruebe la compatibilidad de APIs contra tu browserslist. Hay reglas de linter dedicadas a esto que fallan en el momento de escribir el código, que es cuando cuesta nada arreglarlo.

La detección en tiempo de ejecución para lo que de verdad sea reciente. Cuando usas una API que no está en todos tus navegadores objetivo, la comprobación es explícita y hay camino alternativo:

// Correcto: comprobar y degradar
const soportaVista = 'startViewTransition' in document;
function navegar(actualizar) {
  if (soportaVista) document.startViewTransition(actualizar);
  else actualizar();
}

Este patrón aparecerá varias veces en niveles posteriores: es exactamente lo que hay que hacer con las APIs de planificación de tareas, que están en Chromium y Firefox pero no en Safari, y con cualquier cosa que no esté en los tres motores.

El CSS también tiene objetivo de compilación, y ahí el bajado automático puede duplicar tu hoja de estilos

Toda esta lección va de JavaScript, y el mismo problema existe en CSS con menos visibilidad y a veces con más impacto relativo.

Los procesadores de CSS modernos aplican transformaciones hacia atrás igual que los transpiladores de JavaScript: convierten el anidamiento nativo en selectores expandidos, añaden prefijos de proveedor, duplican reglas con color-mix() en variantes con colores calculados, y expanden las capas en cascada. Con un objetivo antiguo, cada una de esas transformaciones multiplica reglas, y la salida puede ser el doble de la entrada.

Los números que se ven en la práctica: una hoja de 40 KB escrita con anidamiento, capas y funciones de color modernas puede salir a 75 u 85 KB con un objetivo de 2018. Y el CSS es peor que el JavaScript en un aspecto concreto: bloquea el primer pintado. Cada kilobyte de hoja de estilos crítica retrasa directamente el primer pintado con contenido, sin las mitigaciones que tiene el JavaScript.

La configuración tiene su propio ajuste, separado del de JavaScript, y hay que ponerlo:

// vite.config.js
export default {
  build: {
    target: ['chrome111', 'edge111', 'firefox113', 'safari16.4'],
    cssTarget: ['chrome111', 'edge111', 'firefox113', 'safari16.4'],
  },
};

Sin cssTarget, algunas configuraciones aplican un objetivo de CSS mucho más conservador que el de JavaScript, por precaución, y nadie lo mira.

Dos comprobaciones sobre la hoja generada que revelan el problema al instante:

# Prefijos de proveedor que ya no hace falta emitir
grep -o '\-webkit-[a-z-]*\|\-moz-[a-z-]*' dist/assets/*.css | sort | uniq -c | sort -rn | head -20

# Anidamiento nativo conservado: si no aparece '&', se expandio todo
grep -c '&' dist/assets/*.css

Una lista larga de prefijos con propiedades que llevan años estandarizadas —-webkit-box-shadow, -moz-border-radius, -webkit-transition— es la firma de un objetivo de CSS de hace una década. Quitarlo suele recortar entre un 10 y un 25 por ciento de la hoja, en el recurso que más bloquea de toda la página.

⚔️ Reto práctico

Ejecuta las cuatro comprobaciones sobre tu bundle de producción y anota qué encuentras. Si aparece cualquier ayudante de transpilador, localiza cuál de las tres etapas lo introduce desactivándolas una a una. Después haz lo mismo con el CSS: cuenta los prefijos de proveedor y comprueba si el anidamiento sobrevive. Mide el tamaño de la hoja crítica antes y después de corregir el objetivo de CSS, y compáralo con el primer pintado con contenido en el dispositivo de referencia.