El build: elm make con optimize, minificar de verdad y el tamaño final
Compilar para producción es el momento en que las decisiones de diseño de un lenguaje se convierten en bytes que alguien tiene que descargar por una red que no controlamos. Esta lección desmonta el proceso de construcción de una aplicación Elm en sus dos etapas reales y explica qué hace cada una. Primero, qué significa exactamente la bandera de optimización: qué elimina, por qué puede eliminarlo sin analizar el programa entero, qué construcciones del lenguaje prohíbe a cambio y por qué esa prohibición no es un capricho. Segundo, por qué la minificación posterior admite en Elm banderas agresivas que en cualquier otro código de JavaScript serían temerarias, y qué propiedad del lenguaje concreto lo autoriza. Después se examinan cifras reales de tamaño frente a otros marcos de trabajo, se distingue con cuidado entre el coste fijo del entorno de ejecución y el coste marginal de cada funcionalidad añadida, y se delimita qué preguntas de rendimiento el tamaño transferido no responde en absoluto.
El tamaño del paquete que se envía al navegador es la métrica más citada y peor entendida de la interfaz web. Se cita porque es fácil de medir y se entiende mal porque casi siempre se compara un número aislado con otro número aislado, sin distinguir qué parte corresponde al coste fijo de tener un entorno de ejecución en el cliente y qué parte crece con cada pantalla que se añade. Elm ocupa en esa conversación una posición peculiar: no gana la carrera del programa más pequeño, porque siempre habrá algo que pese menos que un entorno de ejecución completo, pero exhibe una propiedad que casi ningún competidor tiene, y es que la eliminación de código muerto funciona de verdad, a nivel de función, sin configuración y sin excepciones. La razón no está en el compilador sino en el lenguaje: en un sistema sin reflexión, sin despacho dinámico y sin efectos ocultos, la pregunta de si una función puede llegar a ejecutarse tiene respuesta exacta. Lo que sigue es el recorrido completo desde el código fuente hasta el archivo que se sirve, y la explicación de por qué cada paso puede permitirse lo que se permite.
- Describir con precisión qué transformaciones aplica la bandera de optimización y qué construcciones del lenguaje quedan prohibidas al activarla.
- Explicar por qué la eliminación de código muerto a nivel de función es posible en Elm y por qué es parcial en la mayoría de los ecosistemas.
- Configurar la segunda etapa de minificación con banderas agresivas y justificar por qué son seguras sobre la salida del compilador.
- Interpretar una comparación de tamaños distinguiendo el coste fijo del entorno de ejecución del coste marginal por funcionalidad.
Qué hace exactamente la bandera de optimización
La compilación de desarrollo y la de producción no son la misma compilación con distinto nivel de esfuerzo: son dos modos con reglas de lenguaje distintas. Al activar la optimización, el compilador rechaza cualquier uso del módulo de depuración, y ese rechazo es la condición que habilita todo lo demás. La función que imprime valores por consola, la que convierte cualquier valor en su representación textual y la que marca una rama como pendiente obligan al programa compilado a llevar consigo información sobre la forma de todos los tipos, porque en tiempo de ejecución hay que poder inspeccionarlos. Si ninguna de esas funciones puede aparecer, esa información deja de ser necesaria y se puede tirar entera. La bandera no es un interruptor de velocidad; es una renuncia declarada que desbloquea supresiones.
# Desarrollo: mensajes de error legibles y depurador disponible
elm make src/Main.elm --output=elm.js
# Produccion: sin modulo de depuracion, con supresiones habilitadas
elm make src/Main.elm --optimize --output=elm.js
Sobre esa base el compilador aplica varias transformaciones concretas. Elimina toda función que no sea alcanzable desde el punto de entrada, y aquí conviene subrayar que la unidad de eliminación es la función y no el módulo: importar un módulo enorme para usar tres funciones no arrastra el resto. Acorta los nombres de los campos de los registros a identificadores mínimos, lo cual reduce mucho el peso porque en un programa de Elm los registros están por todas partes. Convierte los tipos personalizados de un solo constructor con un solo argumento en el valor que envuelven, de modo que la abstracción que en el código fuente cuesta una envoltura en tiempo de ejecución no cuesta nada. Y reescribe la representación interna de valores que en desarrollo llevan etiquetas legibles para poder inspeccionarse.
Conviene entender por qué el acortamiento de campos es una transformación global y no local, porque explica por qué otros optimizadores no pueden intentarla. Renombrar el campo de un objeto solo es seguro si se conocen todos los sitios del programa que leen o escriben ese campo, y en un lenguaje donde una propiedad puede alcanzarse por nombre calculado, serializarse por su nombre o exponerse a una biblioteca externa, esa certeza no existe nunca. En Elm el conjunto de accesos a un campo es finito, enumerable y visible para el compilador, así que el renombrado se aplica de una vez a todo el programa. El único punto donde la transformación tiene que detenerse es la frontera con el exterior, y por eso los valores que cruzan por puertos o por banderas conservan sus nombres: allí el otro lado también los lee, y el compilador lo sabe.
La eliminación de código muerto es un problema de alcanzabilidad, y su exactitud depende por completo de que el analizador pueda saber, sin ejecutar nada, qué llamadas son posibles. En un lenguaje con reflexión, con acceso dinámico a propiedades por nombre calculado, con importación condicional o con la posibilidad de que un módulo modifique objetos ajenos al cargarse, cualquier función puede ser alcanzable por una vía que el análisis no ve, y el analizador correcto tiene que conservarla. De ahí que la poda en los empaquetadores modernos sea buena pero conservadora, dependa de que los módulos se declaren libres de efectos secundarios y se rompa en cuanto una biblioteca hace algo ingenioso. Elm no tiene ninguna de esas escapatorias, así que la respuesta a si esta función puede ejecutarse no es una estimación prudente sino un hecho. El precio de esa exactitud se pagó años antes, al diseñar el lenguaje.
Queda una consecuencia de tener dos modos que conviene tener presente y que en otros ecosistemas causa problemas graves: la divergencia entre lo que se prueba y lo que se despliega. Aquí esa divergencia existe, porque el binario de desarrollo lleva el depurador y datos de tipos que el de producción no lleva, pero está acotada de una forma poco común. Las transformaciones de la compilación optimizada no cambian la semántica de ningún programa válido: eliminan lo inalcanzable, acortan nombres y deshacen envolturas, y ninguna de esas operaciones puede alterar el resultado de una función. Lo que sí cambia es qué programas son válidos, ya que los que usan el módulo de depuración dejan de compilar. Dicho de otro modo, el modo de producción rechaza más y ejecuta igual, que es exactamente la dirección correcta para una diferencia entre entornos.
La segunda etapa: minificar con permisos que en otro sitio serían temerarios
El compilador produce JavaScript legible y no minificado, y ese archivo todavía tiene mucho aire. La segunda etapa es un minificador estándar, pero configurado de una forma que en código escrito a mano sería imprudente. Se le indica que las funciones auxiliares que el compilador emite para aplicar funciones curradas son puras y pueden eliminarse cuando su resultado no se usa; se le permite asumir que leer una propiedad no tiene efectos secundarios; se le autoriza a ignorar el número declarado de argumentos. Cada una de esas suposiciones es falsa en el JavaScript general y verdadera sin excepciones en la salida del compilador de Elm, porque ahí no existen los captadores con efectos, ni el objeto de argumentos, ni la mutación de prototipos.
uglifyjs elm.js \
--compress 'pure_funcs=[F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9],pure_getters,keep_fargs=false,unsafe_comps,unsafe' \
| uglifyjs --mangle --output elm.min.js
La invocación se hace en dos pasadas y no en una, y el motivo no es folclore. La compresión y el acortamiento de identificadores interfieren entre sí cuando se ejecutan juntos: al acortar nombres se pierde la información que la compresión usaba para decidir qué es seguro suprimir, y el resultado es un archivo mayor que el que se obtiene ejecutando primero la compresión completa sobre nombres largos y acortando después sobre el resultado ya reducido. La diferencia entre una pasada y dos es pequeña en términos porcentuales pero constante, y como el proceso es determinista y barato, no hay razón para renunciar a ella. En la práctica cualquier minificador moderno sirve, y lo que importa no es cuál se elija sino que reciba explícitamente el permiso para tratar como puras las funciones auxiliares de aplicación currada, porque son con diferencia lo más abundante en la salida.
Un aviso que ahorra un desconcierto clásico: estas banderas solo son seguras sobre la salida del compilador, y aplicarlas al artefacto combinado que produce un empaquetador cuando ahí dentro conviven código de Elm y código de JavaScript escrito a mano es una invitación al fallo silencioso. Si el proceso de construcción une ambos mundos en un mismo archivo, hay que configurar el minificador para que trate cada origen con sus propias reglas, o minificar la salida de Elm por separado antes de unirla. La regla es simple de enunciar y fácil de infringir por descuido: la agresividad es un permiso concedido a un generador de código concreto, no una opción global del proyecto.
Existe además una capa opcional de la comunidad que aplica transformaciones que el compilador oficial no hace, orientadas sobre todo a la velocidad de ejecución y en menor medida al tamaño: convierte ciertos usos de listas en bucles, especializa llamadas de funciones curradas cuando se conocen todos los argumentos y reordena estructuras que el generador emite de forma genérica. Merece la pena conocerla, y también merece la pena tratarla como lo que es: una herramienta externa que introduce un paso más entre el código verificado y el artefacto servido. Adoptarla es razonable cuando se ha medido que hace falta, y conviene medir después y no antes.
Poda por función
Lo no alcanzable desde el punto de entrada desaparece, aunque viva en un módulo del que sí se usa otra cosa.
Campos acortados
Los nombres de campo de los registros pasan a identificadores mínimos en todo el programa a la vez.
Envolturas desechas
Un tipo de un solo constructor con un argumento se representa como el valor que envuelve: abstracción sin coste.
Minificado agresivo
Las banderas inseguras del minificador son seguras aquí porque la salida no usa ninguna de las construcciones que las harían peligrosas.
Lo que dicen las cifras y lo que no dicen
Antes de mirar comparaciones ajenas conviene saber medir la propia, porque el número que importa no es el que aparece en un artículo sino el que sale de tu proyecto. La secuencia mínima es medir en los tres estados por los que pasa el archivo, ya que cada uno responde a una pregunta distinta: la salida cruda dice cuánto código generó el compilador, la minificada cuánto sobrevivió a las transformaciones y la comprimida cuántos bytes viajan de verdad por la red.
elm make src/Main.elm --optimize --output=elm.js
uglifyjs elm.js --compress 'pure_funcs=[F2,F3,F4,A2,A3,A4],pure_getters,keep_fargs=false,unsafe' \
| uglifyjs --mangle --output elm.min.js
gzip -9 -c elm.min.js | wc -c
brotli -q 11 -c elm.min.js | wc -c
elm.js 234 KB salida cruda del compilador
elm.min.js 58 KB tras compresion y acortado
elm.min.js.gz 17 KB lo que descarga el navegador
elm.min.js.br 15 KB con el algoritmo mas moderno
Sobre la aplicación de lista de tareas que sirve de referencia comparativa desde hace años, la salida de Elm ronda las tres decenas de kilobytes minificada y baja a menos de diez comprimida. Los marcos de trabajo basados en bibliotecas de tiempo de ejecución grandes han estado históricamente en un orden de magnitud por encima en la versión comprimida, aunque las cifras se mueven con cada versión y no conviene tratarlas como una tabla clasificatoria. Más útil que el número absoluto es entender la forma de la curva. Un marco con entorno de ejecución tiene un coste fijo que se paga desde la primera pantalla y luego crece despacio; un compilador que genera código sin entorno de ejecución, como los que producen actualizaciones granulares del documento, arranca casi en cero y crece más deprisa porque cada componente aporta su propio código de actualización. Elm está en el primer grupo, con un coste fijo modesto y un crecimiento marginal contenido gracias a que la poda es exacta.
La consecuencia de esa forma de curva es que la comparación cambia de ganador según el tamaño del proyecto que se mida, y por eso las tablas de referencia con una sola aplicación diminuta engañan en las dos direcciones. En un ejemplo de cien líneas gana quien no lleva entorno de ejecución, porque el coste fijo domina y ahí Elm parte con desventaja. En una aplicación con cuarenta pantallas, cientos de componentes y varios años de historia, el coste fijo se ha vuelto irrelevante y lo que decide es cuánto añade cada pantalla nueva y cuánto de lo que ya no se usa sigue viajando. Si vas a elegir con datos, mide el proyecto que vas a construir y no el que aparece en el artículo comparativo.
flowchart LR A[Codigo fuente Elm] --> B[elm make con optimize] B --> C[JavaScript sin datos de tipos] C --> D[Minificador con banderas puras] D --> E[Mangle de nombres] E --> F[Compresion del servidor] F --> G[Bytes que descarga el usuario] style B fill:#cba6f7,color:#11111b style D fill:#89b4fa,color:#11111b style G fill:#a6e3a1,color:#11111b
Hay una asimetría que las comparaciones suelen ignorar y que cambia el resultado en proyectos reales. En un proyecto grande de JavaScript o TypeScript, buena parte del peso no viene del marco de trabajo sino de las dependencias que se acumulan alrededor: una biblioteca de fechas, un cliente de datos, un sistema de formularios, utilidades varias. Como el catálogo de Elm es pequeño y muchas de esas piezas se escriben en casa con el núcleo del lenguaje, el árbol de dependencias de una aplicación Elm madura tiende a ser mucho más corto. Esa es una razón considerable de la diferencia de tamaño en producción, y es honesto reconocer que también describe un coste: lo que no se descarga hay que escribirlo.
Hay una segunda razón, menos visible, que tiene que ver con la forma del código que se escribe. En un lenguaje sin valores nulos ni excepciones desaparece el código defensivo que en otros sitios rodea cada frontera interna: las comprobaciones de existencia antes de leer un campo, los bloques de captura por si acaso, los valores por defecto repetidos en cada llamada. Ese código no es marginal, y su ausencia se nota en el recuento final tanto como cualquier transformación del optimizador. La contrapartida también existe y hay que nombrarla: la ausencia de sobrecarga de operadores y de polimorfismo por interfaz obliga a repetir estructura entre módulos que hacen cosas parecidas sobre tipos distintos, y esa repetición sí añade bytes.
Optimizar el número que aparece en el panel de red es útil hasta cierto punto y engañoso más allá. Lo que percibe una persona es cuándo aparece contenido y cuándo el contenido responde al tacto, y en esa cadena intervienen cosas que el peso no captura: el tiempo de análisis y compilación del JavaScript por parte del motor, el coste de la primera construcción del árbol de la interfaz, el número de peticiones que la aplicación lanza antes de poder pintar algo útil, y si hay o no una etapa de rehidratación sobre marcado ya servido. Una aplicación de Elm compilada sin optimizar, con el depurador activo, puede pesar bastante más y sentirse igual de rápida en una máquina de escritorio; la misma aplicación en un teléfono de gama baja con una red lenta cuenta otra historia. Mide en el dispositivo peor que te importe, no en el tuyo, y mide tiempo hasta interacción, no kilobytes.
Lo que no se puede dividir y qué hacer con ello
Hay una limitación que conviene conocer antes de comprometerse, porque no tiene solución dentro de la herramienta oficial: el compilador emite un único bloque por punto de entrada y no admite división automática por rutas. En un marco de trabajo convencional es habitual que el empaquetador corte el código por pantallas y cargue cada trozo bajo demanda, de modo que quien entra en la portada no descarga el editor avanzado que solo usan tres administradores. Aquí eso no ocurre: si el editor forma parte del programa alcanzable desde el punto de entrada, viaja siempre. La poda exacta compensa mucho, porque elimina lo que nadie usa, pero no puede eliminar lo que alguien usa a veces.
# Un artefacto por punto de entrada, no por ruta
elm make src/Portada.elm --optimize --output=dist/portada.js
elm make src/Panel.elm --optimize --output=dist/panel.js
La razón de esta limitación no es un descuido de la herramienta sino la misma propiedad que hace exacta la poda. Dividir el código por rutas exige que el punto de corte sea una importación diferida, es decir, un lugar donde el programa dice explícitamente que este trozo llegará más tarde y que hasta entonces no está disponible. Eso introduce en el lenguaje un estado en el que una función existe y no existe a la vez, y con él la aritmética de fallos que lo acompaña: qué pasa si el trozo no llega, qué se pinta mientras tanto, qué tipo tiene lo que aún no se ha descargado. Elm no ha querido incorporar esa categoría, y el resultado es un compromiso explícito que se puede discutir pero no confundir con una carencia accidental.
Las estrategias disponibles son tres y se eligen según la forma del producto. La primera es partir en varios puntos de entrada cuando la aplicación se corresponde con varias páginas servidas por separado, lo cual devuelve la granularidad al nivel de la página y suele bastar en productos con áreas bien delimitadas. La segunda es incrustar programas independientes como fragmentos dentro de páginas que gobierna otra tecnología, de forma que cada página cargue solo el suyo. La tercera es sencillamente aceptar el paquete único, y es la elección correcta más a menudo de lo que la intuición sugiere: para una aplicación de tamaño medio el artefacto completo suele quedar por debajo de lo que otros ecosistemas pagan solo en dependencias, y a cambio se obtiene un modelo mental sin cargas diferidas, sin estados intermedios de descarga y sin la clase de fallos que aparecen cuando un trozo tarda o no llega.
Antes de invertir esfuerzo en partir el artefacto conviene comprobar dónde está el gasto real, porque en la mayoría de los proyectos no está donde se supone. Un paquete de cincuenta kilobytes comprimidos se descarga en una fracción de segundo incluso en conexiones modestas y se analiza en unas pocas decenas de milisegundos en un teléfono corriente; mientras tanto, la misma pantalla puede estar esperando tres peticiones encadenadas al servidor, cargando una tipografía que bloquea el pintado y descargando una imagen de cabecera que pesa más que todo el código junto. Dividir el paquete es una optimización de rendimientos decrecientes que además introduce complejidad permanente en el despliegue y en la caché. Ordena las intervenciones por el tiempo que devuelven y no por lo interesantes que resultan, y verás que el corte por rutas casi nunca encabeza la lista.
Conviene resistir la lectura fácil de esta lección, que sería concluir que el compilador de Elm es especialmente bueno optimizando. No lo es, y sus autores lo han dicho: las transformaciones que aplica son pocas y bien conocidas, y no hay aquí nada parecido a la maquinaria de un compilador optimizador maduro. Lo que ocurre es que el compilador trabaja sobre un lenguaje en el que la información necesaria para decidir está presente y es completa, y esa diferencia vale más que cualquier técnica. La cadena causal completa merece verse entera, porque es el argumento central del track expresado en bytes. Como no hay valores nulos ni excepciones, no hace falta código defensivo por todas partes. Como no hay reflexión, la alcanzabilidad es decidible y la poda es exacta. Como no hay efectos ocultos ni captadores con conducta, el minificador puede reordenar y suprimir con permisos que en otro sitio serían una invitación al desastre. Como no hay despacho dinámico, un tipo de una sola envoltura puede desaparecer sin dejar rastro. Ninguna de esas cuatro cosas se decidió pensando en el tamaño del paquete; se decidieron pensando en la corrección, y el tamaño es un subproducto. La generalización que sobrevive fuera de Elm es incómoda y útil a la vez: el peso de un artefacto no lo determina principalmente la calidad de las herramientas de construcción, sino cuántas cosas el lenguaje permite hacer en tiempo de ejecución que ningún análisis estático puede descartar. Cada libertad dinámica que un lenguaje concede se cobra después, en cada compilación de producción de cada proyecto, en forma de código que el optimizador no se atreve a tocar. Optimizar es, casi siempre, el arte de recuperar información que el diseño del lenguaje había tirado.
- Compila el mismo proyecto con y sin la bandera de optimización y anota el tamaño en cada paso: salida cruda, minificada y comprimida.
- Introduce una llamada a la función de depuración y observa el mensaje exacto con el que el compilador rechaza la compilación optimizada; explica qué información habría tenido que conservar.
- Aplica el minificador primero sin las banderas agresivas y luego con ellas; cuantifica la diferencia y razona qué propiedad del lenguaje autoriza cada bandera.
- Añade un módulo con veinte funciones e importa solo una; verifica en la salida que las otras diecinueve no aparecen.
- Envuelve un identificador en un tipo de un solo constructor, comprueba en la salida optimizada que la envoltura desapareció y explica qué se ganó en el código fuente sin perder nada en ejecución.
- Construye una comparación honesta con un proyecto equivalente en otro marco de trabajo midiendo tiempo hasta interacción en un dispositivo modesto, y discute en qué casos el tamaño dejó de ser el factor dominante.