2. Angular: arquitectura, CLI y estructura del proyecto
Antes de escribir un solo componente conviene entender qué clase de herramienta es Angular, qué decisiones ha tomado por ti y cuáles te deja a ti. Este capítulo cubre el andamiaje: la historia que explica por qué el framework es como es, su arquitectura interna de compilación, el CLI completo mandato por mandato, la anatomía de un proyecto recién generado, el fichero angular.json, el arranque de la aplicación con bootstrapApplication y las decisiones de organización que determinarán si dentro de dos años el proyecto sigue siendo mantenible o se ha convertido en una madeja. No es el capítulo más vistoso del libro, pero es el que evita la mayoría de los problemas estructurales que aparecen en el mes seis.
2.1 Qué vas a poder hacer al terminar
- Explicar en una entrevista técnica por qué Angular se reescribió por completo en 2016 y qué problemas concretos de AngularJS resolvía esa reescritura.
- Argumentar con datos, y no con preferencias, cuándo Angular es la elección correcta frente a React, Vue o Svelte, y cuándo no lo es.
- Describir el recorrido completo de una plantilla desde el fichero
.htmlhasta las instrucciones de renderizado que ejecuta el navegador, y qué papel juegan la compilación AOT, Ivy y el tree shaking. - Distinguir el incremental DOM de Angular del virtual DOM de React, y razonar sobre sus consecuencias en memoria y en tamaño del bundle.
- Manejar el CLI con soltura: generar cualquier artefacto, entender qué escribe cada schematic, y actualizar una versión mayor con
ng updatesabiendo qué hacen las migraciones automáticas. - Leer y modificar
angular.jsoncon criterio: builders, configurations, presupuestos, reemplazos de fichero y opciones de optimización. - Escribir un
main.tsy unapp.config.tsde producción, con enrutado, cliente HTTP, animaciones diferidas, inicialización de la aplicación y unErrorHandlerpropio. - Migrar mentalmente —y con la herramienta oficial— de
NgModulea componentes standalone, y explicar por qué el segundo modelo es hoy el estándar. - Diseñar la estructura de carpetas de una aplicación grande, con límites claros entre capas, y saber cuándo un monorepo con Nx aporta más de lo que cuesta.
- Diagnosticar los fallos de arranque y de compilación más frecuentes: versión de Node incompatible, presupuesto excedido,
NG0203, caché corrupta y mezcla de versiones de paquetes.
2.2 Historia y contexto: de AngularJS al Angular de hoy
Ningún framework se entiende sin su historia. Muchas decisiones de Angular que hoy parecen arbitrarias —la inyección de dependencias omnipresente, la obsesión por el tooling, el compilador propio— son respuestas concretas a problemas que el equipo sufrió en la primera versión. Merece la pena dedicarles unos minutos.
2010 · AngularJS. Miško Hevery y Adam Abrons publican un proyecto que Google libera en octubre de 2010. Su propuesta era revolucionaria para la época: en lugar de manipular el DOM a mano con jQuery, se declaraban bindings en el propio HTML y el framework mantenía la sincronía. El eslogan «HTML enhanced for web apps» resumía la idea. Fue un éxito enorme: durante cinco años, AngularJS fue el framework de facto de la aplicación empresarial.
Los problemas de AngularJS. El modelo se apoyaba en tres piezas que envejecieron mal. El $scope era un objeto que se heredaba prototípicamente por el árbol de la interfaz: saber qué scope contenía una variable determinada requería seguir la cadena de prototipos a mano, y las escrituras en un scope hijo enmascaraban silenciosamente al padre. El digest cycle implementaba la sincronización mediante dirty checking: cada expresión enlazada registraba un $watch con el último valor conocido, y al llamar a $apply() el framework recorría todos los watchers comparando valor actual y anterior; como un watcher podía modificar algo observado por otro, el bucle se repetía hasta estabilizarse, con un tope de diez vueltas —de ahí el célebre error 10 $digest() iterations reached—. La consecuencia práctica era que el coste de cada interacción era proporcional al tamaño de la pantalla entera, no al del cambio: la regla no escrita era «no pases de 2.000 watchers por vista», y una tabla con miles de filas y varias columnas enlazadas hacía inutilizable la aplicación. A esto se sumaban una inyección de dependencias basada en cadenas de texto que se rompía al minificar (salvo que se usara la sintaxis de array), la ausencia de tipos y un rendimiento en móviles que en 2015 ya era inaceptable.
Septiembre de 2016 · Angular 2 y la reescritura. El equipo tomó la decisión más impopular y probablemente más acertada de su historia: reescribir el framework desde cero, en TypeScript, con un modelo de componentes en lugar de scopes, inyección de dependencias basada en tipos, un compilador de plantillas propio y detección de cambios unidireccional de arriba abajo. La polémica fue considerable: no había ruta de migración automática y miles de equipos se encontraron con una base de código escrita en un framework que ya no tendría futuro. Google publicó ngUpgrade para hacer convivir ambas versiones en la misma página, pero la migración real seguía siendo una reescritura. El coste reputacional fue alto y todavía hoy aparece en las discusiones sobre «cuál elijo».
2017 · Semver y ciclo semestral. Para no repetir el trauma, Angular adopta versionado semántico estricto y un calendario público: una versión mayor cada seis meses, dos menores por medio y correcciones semanales. Se saltó la versión 3 para alinear el número del framework con el del router, que ya iba por la 3. Cada versión mayor mantiene soporte activo seis meses y soporte a largo plazo (LTS) doce meses más. La promesa implícita es importante: actualizar de una mayor a la siguiente debe ser cuestión de un mandato, no de un proyecto.
Febrero de 2020 · Angular 9 e Ivy. Ivy sustituye al motor de renderizado anterior, ViewEngine. No fue un cambio cosmético: cambió el modelo de compilación de plantillas, redujo drásticamente el tamaño de las aplicaciones pequeñas, hizo posible el tree shaking real del framework, aceleró los tiempos de compilación y desbloqueó todo lo que vino después. Su propiedad clave, la localidad (locality), se explica en 2.5.
2022–2023 · La era standalone. En la v14 (junio de 2022) aparecen los componentes standalone en developer preview; en la v15 (noviembre de 2022) se declaran estables. La v16 y la v17 completan el modelo con las funciones provide*, el bootstrapApplication y las rutas loadComponent. Desde la v17 el CLI genera aplicaciones standalone por defecto y desde la v19 la propiedad standalone: true es implícita: quien quiera seguir en el mundo de los NgModule debe escribir standalone: false de forma explícita.
2023–2024 · Señales y el «renacimiento». La v16 (mayo de 2023) introduce signal(), computed() y effect() en developer preview; la v17 (noviembre de 2023) estabiliza el núcleo de la API y añade el nuevo control flow de plantillas (@if, @for, @switch) y @defer. En paralelo se cambia el sitio de documentación a angular.dev y se rehace la marca. La v17 estabiliza además el builder basado en esbuild con Vite como servidor de desarrollo, que multiplica por varias veces la velocidad de compilación.
2024 en adelante · Hacia el modo zoneless. Las señales se extienden a las entradas, salidas y consultas (input(), output(), viewChild()), aparecen linkedSignal() y resource(), y la detección de cambios sin Zone.js recorre el camino de experimental a estable. El objetivo declarado del equipo es que Zone.js deje de ser necesario y que la reactividad sea granular. Todo eso se trata en detalle en el capítulo 4.
$scope, de NgModule obligatorios o de bundles de dos megabytes, está describiendo un producto que ya no existe. Compara siempre contra la versión actual, no contra el recuerdo.
2.3 Definición y filosofía: framework completo y opinado
Angular es un framework de desarrollo de aplicaciones web, mantenido por Google, escrito en TypeScript, que proporciona en un único paquete versionado de forma conjunta el sistema de componentes, el enrutador, el cliente HTTP, los formularios, la inyección de dependencias, la internacionalización, el renderizado en servidor, las herramientas de compilación y el marco de pruebas. Esa frase larga contiene la diferencia esencial con una librería: React no es comparable a Angular; lo comparable es React más React Router más TanStack Query más React Hook Form más Vite más Vitest, cada uno con su ciclo de vida, su mantenedor y su criterio.
2.3.1 Qué significa «opinado» y por qué es una ventaja o un lastre
Un framework opinado (opinionated) tiene una respuesta preferente para cada pregunta de diseño: cómo se organiza un componente, cómo se obtiene una dependencia, cómo se navega, cómo se prueba. Esa opinión tiene un coste evidente —menos libertad— y una ventaja que solo se aprecia con el tiempo y con equipos grandes: la homogeneidad.
Construir con una librería es como levantar un chalé a medida: eliges cada material, cada distribución, cada proveedor. El resultado puede ser espectacular y estar exactamente adaptado a tus necesidades. También puede ser un desastre si el arquitecto no es bueno, y el fontanero que venga dentro de cinco años tendrá que descubrir por dónde pasan las tuberías.
Construir con un framework opinado es como levantar un bloque de viviendas con un sistema constructivo estándar: menos libertad en el detalle, pero cualquier operario sabe dónde está el cuadro eléctrico sin necesidad de planos. Cuando tienes cuarenta desarrolladores repartidos en seis equipos y una rotación del veinte por ciento anual, esa previsibilidad vale más que la elegancia de una solución a medida.
2.3.2 Qué problema resuelve realmente
- Consistencia entre equipos. Un servicio, un componente o una ruta se escriben igual en Madrid que en Bangalore. La revisión de código deja de discutir estructura y pasa a discutir lógica de negocio, que es donde aporta valor.
- Inyección de dependencias de primera clase. No es un detalle menor: es lo que permite sustituir una implementación por otra en pruebas, en distintos entornos o por configuración, sin tocar el código que la consume. Es el principio de inversión de dependencias de SOLID aplicado en el propio framework, y ningún otro framework de interfaz lo ofrece con esta profundidad.
- Herramientas integradas. Un solo CLI genera, sirve, compila, prueba, analiza y actualiza el proyecto. No hay que ensamblar y mantener una cadena de herramientas propia.
- Testabilidad por diseño. Los componentes son clases con dependencias explícitas; sustituirlas en un test es trivial.
TestBedreconstruye el árbol de inyectores para probar en aislamiento. - Actualizaciones asistidas. Este es el argumento más infravalorado.
ng updateno solo cambia números de versión: ejecuta migraciones de código que reescriben tus ficheros para adaptarlos a las APIs nuevas. En aplicaciones que viven diez años, esto es dinero. - Tipado extremo a extremo. Con
strictTemplatesactivado, incluso las expresiones de las plantillas se comprueban contra los tipos declarados. Un error de nombre de propiedad se detecta al compilar, no en producción.
2.3.3 Para quién NO es la mejor opción
La honestidad intelectual obliga a decir esto con claridad. Angular es una mala elección si:
- Necesitas un widget embebido en una página existente de otra tecnología. El coste de arranque del framework no se amortiza para un formulario de suscripción.
- El proyecto es una landing o un sitio de contenido donde lo que importa es el SEO y el tiempo hasta el primer pintado. Astro, Eleventy o incluso HTML plano ganan sin discusión.
- Trabajas solo o en pareja en un prototipo de dos semanas. La ceremonia de Angular es una inversión: si el proyecto no va a durar, no la recuperas.
- Tu equipo ya domina otro ecosistema y no hay una razón técnica de peso para cambiar. La productividad de un equipo experto en React supera casi siempre a la de un equipo aprendiendo Angular.
- El tamaño del bundle es el requisito número uno por encima de todo lo demás —una aplicación para mercados con conectividad muy limitada, por ejemplo—. Svelte o Preact producen artefactos más pequeños.
2.4 Comparación honesta con React, Vue y Svelte
Las comparaciones de frameworks suelen ser propaganda. Intentemos algo distinto: una tabla donde cada casilla describe un hecho verificable, y después unos criterios de elección explícitos. Ten presente que se compara Angular completo con el core de los demás; donde eso distorsiona, se indica.
| Criterio | Angular | React | Vue | Svelte |
|---|---|---|---|---|
| Curva de aprendizaje | Alta. TypeScript, DI, RxJS, señales, CLI y convenciones desde el primer día | Media al inicio, alta al montar la arquitectura: hay que elegir y aprender diez librerías | Baja. Se puede ser productivo en un par de días | Muy baja. La sintaxis es casi HTML y JavaScript |
| Tamaño del bundle (hola mundo, comprimido) | El mayor de los cuatro, pero muy mejorado con Ivy y tree shaking; escala bien porque el coste fijo se amortiza | Pequeño en el core; crece rápido al sumar router, estado y peticiones | Pequeño y con crecimiento moderado | El más pequeño: compila a JavaScript imperativo y apenas hay runtime |
| Modelo de reactividad | Señales (grafo de dependencias) más RxJS, sobre detección de cambios con o sin Zone.js | Rerenderizado de la función del componente y reconciliación de virtual DOM; memoización manual | Proxies reactivos (ref, reactive) con seguimiento automático de dependencias | Runes en la versión 5: reactividad granular resuelta en tiempo de compilación |
| Enrutado | Oficial, incluido y muy completo: lazy loading, guards, resolvers, rutas hijas, estrategias de reutilización | De terceros (React Router, TanStack Router) o del meta-framework (Next.js) | Oficial (Vue Router), instalación aparte | Del meta-framework (SvelteKit) o de terceros |
| Inyección de dependencias | Jerárquica, con árbol de inyectores, tokens, ámbitos y sustitución. Es un pilar del diseño | No existe como tal; se emula con context y con paso de props | provide/inject, más sencillo y sin jerarquía de inyectores comparable | Contexto por componente, sin sistema de DI |
| Formularios | Dos sistemas oficiales: reactivos y basados en plantilla, con validadores y estado tipado | De terceros (React Hook Form, Formik) o a mano | Enlace bidireccional con v-model; validación de terceros (VeeValidate) | Enlace con bind:; validación a mano o de terceros |
| Renderizado en servidor | Oficial e integrado en el CLI (ng add @angular/ssr), con hidratación incremental en versiones recientes | A través de meta-frameworks (Next.js, Remix); es su terreno más maduro | Nuxt, muy maduro | SvelteKit, muy maduro |
| Grado de opinión | Muy alto: hay una forma canónica de hacer casi todo | Muy bajo: el core solo resuelve la vista | Medio: sugiere convenciones sin imponerlas | Medio-bajo en Svelte, alto en SvelteKit |
| Ecosistema empresarial | Excelente: bibliotecas de componentes maduras, soporte LTS, presencia dominante en banca, seguros y administración pública | El mayor ecosistema en volumen absoluto y en oferta de empleo | Muy fuerte en Asia y en producto; menor presencia corporativa en Europa | Creciente pero mucho menor; menos oferta de perfiles |
| Actualizaciones | Calendario público y migraciones automáticas de código con ng update | Estable, pero cada dependencia se actualiza por su cuenta | Ordenadas; la migración de Vue 2 a 3 fue costosa | La migración de Svelte 4 a 5 introdujo cambios de modelo importantes |
2.4.1 Criterios de elección, sin diplomacia
- Elige Angular si el proyecto va a durar años, lo van a tocar varios equipos, hay rotación de personal, el dominio es complejo (formularios largos, permisos, informes) y valoras poder actualizar con un mandato. Es la elección natural en banca, seguros, salud, logística y administración pública.
- Elige React si necesitas el mayor mercado de talento posible, si vas a usar un meta-framework maduro para SSR y contenido, o si tu producto depende de un ecosistema muy específico (visualización, 3D, realidad aumentada) donde las mejores librerías nacen primero allí.
- Elige Vue si buscas el mejor equilibrio entre curva de aprendizaje y capacidades, si el equipo es pequeño y quieres productividad inmediata sin renunciar a un router y a un sistema de estado oficiales.
- Elige Svelte si el tamaño y el rendimiento en dispositivos modestos son requisitos duros, si el equipo es reducido y experimentado, y si aceptas un ecosistema más pequeño a cambio de un código fuente notablemente más corto.
2.5 Arquitectura interna: cómo funciona Angular por dentro
Angular no interpreta tus plantillas en tiempo de ejecución: las compila. Entender ese proceso explica de golpe por qué existe el CLI, por qué los errores de plantilla aparecen al compilar y por qué el tamaño del bundle depende de qué usas y no de qué instalas.
2.5.1 Compilación AOT frente a JIT
Históricamente hubo dos modos de compilar las plantillas de Angular:
- JIT (Just-In-Time, «justo a tiempo»): el compilador de Angular viajaba dentro del bundle y traducía las plantillas en el navegador, al arrancar la aplicación. Ventaja: compilación de desarrollo rápida. Inconvenientes: el compilador pesaba, el arranque era más lento y los errores de plantilla solo aparecían en tiempo de ejecución.
- AOT (Ahead-Of-Time, «por adelantado»): las plantillas se traducen a JavaScript durante la compilación, en tu máquina o en el servidor de integración continua. El compilador no viaja al navegador. Los errores de plantilla son errores de compilación.
Desde Angular 9, AOT es el modo por defecto también en desarrollo. El modo JIT sigue existiendo para casos muy concretos (algunos escenarios de pruebas y de carga dinámica), pero para el trabajo diario puedes considerar que Angular es un compilador. Esta es una diferencia cualitativa con la mayoría de sus competidores: cuando escribes {{ usuario.nombree }} con una errata, Angular te lo dice antes de arrancar.
PIPELINE DE COMPILACIÓN DE ANGULAR
┌──────────────────────┐ ┌──────────────────────┐
│ componente.ts │ │ componente.html │
│ @Component({...}) │ │ plantilla Angular │
│ class Componente │ │ {{ }} @if @for │
└──────────┬───────────┘ └──────────┬───────────┘
│ │
└───────────┬──────────────┘
▼
┌────────────────────────────────────┐
│ 1. COMPILADOR DE ANGULAR (ngtsc) │
│ Analiza los decoradores y │
│ traduce la plantilla a │
│ instrucciones de renderizado │
└────────────────┬───────────────────┘
▼
┌────────────────────────────────────┐
│ 2. SALIDA: campos estáticos en la │
│ propia clase │
│ ɵcmp = defineComponent({ │
│ template: function(rf, ctx){ │
│ ... elementStart / text / │
│ property / advance ... │
│ }}) │
└────────────────┬───────────────────┘
▼
┌────────────────────────────────────┐
│ 3. TypeScript → JavaScript │
│ comprobación de tipos incluida │
│ en plantillas (strictTemplates)│
└────────────────┬───────────────────┘
▼
┌────────────────────────────────────┐
│ 4. EMPAQUETADO (esbuild/Rollup) │
│ tree shaking · minificación │
│ división en trozos (chunks) │
│ hash en los nombres de fichero │
└────────────────┬───────────────────┘
▼
┌────────────────────────────────────┐
│ 5. dist/ → navegador │
│ main-A1B2C3.js, chunk-*.js │
└────────────────────────────────────┘
2.5.2 Qué es Ivy y qué es la «localidad»
Ivy es el nombre del motor de compilación y renderizado que sustituyó a ViewEngine en Angular 9. Su idea central es la localidad (locality): para compilar un componente basta con la información contenida en ese componente, sin necesidad de analizar globalmente toda la aplicación ni de generar metadatos intermedios (los antiguos ficheros .metadata.json de ViewEngine).
Las consecuencias de la localidad son enormes y explican casi todo lo demás:
- Compilación incremental real. Si cambias un componente, solo se recompila ese componente. Los tiempos de recompilación en desarrollo bajan de segundos a milisegundos.
- Las librerías se distribuyen ya compiladas. Desaparece el paso
ngccque hacía falta al principio de la era Ivy para convertir librerías antiguas. - Tree shaking del propio framework. Como veremos ahora mismo, el código generado importa exactamente las instrucciones que usa. Lo que no se usa no se importa, y lo que no se importa no entra en el bundle.
- Depuración comprensible. Las pilas de llamadas apuntan a funciones con nombre en lugar de a un intérprete genérico, y aparecen APIs de depuración como
ng.getComponent($0)en la consola del navegador.
2.5.3 De la plantilla a las instrucciones de renderizado
Este es el punto que más sorprende a quien viene de React: una plantilla de Angular no se convierte en un árbol de objetos que describe la interfaz. Se convierte en una función que, al ejecutarse, llama a instrucciones que crean y actualizan nodos del DOM. Veámoslo con un ejemplo mínimo.
@Component({
selector: 'app-saludo',
template: `
<h1>Hola, {{ nombre() }}</h1>
<button (click)="saludar()">Saludar</button>
`,
})
export class SaludoComponent {
readonly nombre = signal('Ana');
saludar(): void { /* ... */ }
}
// El compilador añade un campo estático a la propia clase.
// 'rf' son las "render flags": 1 = crear, 2 = actualizar.
SaludoComponent.ɵcmp = defineComponent({
type: SaludoComponent,
selectors: [['app-saludo']],
decls: 4, // número de nodos declarados
vars: 1, // número de expresiones enlazadas
template: function SaludoComponent_Template(rf, ctx) {
if (rf & 1) { // FASE DE CREACIÓN (una sola vez)
elementStart(0, 'h1');
text(1); // nodo de texto vacío, se rellena luego
elementEnd();
elementStart(2, 'button');
listener('click', function () { return ctx.saludar(); });
text(3, 'Saludar');
elementEnd();
}
if (rf & 2) { // FASE DE ACTUALIZACIÓN (cada comprobación)
advance(1); // sitúa el "puntero" en el nodo 1
textInterpolate1('Hola, ', ctx.nombre(), '');
}
},
});
// Nota: los nombres reales van prefijados con ɵɵ (ɵɵelementStart, ɵɵadvance...).
// El prefijo indica API privada del framework: nunca la llames directamente.
Fíjate en tres cosas. Primera: hay dos fases separadas, creación y actualización, y la de actualización solo toca lo que puede cambiar. Segunda: cada nodo tiene un índice fijo conocido en tiempo de compilación, por lo que actualizar el texto es acceder a una posición de un array, no buscar en el DOM. Tercera, y decisiva: solo se importan las instrucciones que la plantilla necesita. Una plantilla sin @for no arrastra el código de repetición de listas.
2.5.4 Incremental DOM frente a Virtual DOM
La estrategia de Ivy se conoce como incremental DOM. La de React, virtual DOM. La diferencia se resume en qué se hace cuando hay que actualizar la pantalla.
| Aspecto | Virtual DOM (React) | Incremental DOM (Angular/Ivy) |
|---|---|---|
| Qué ocurre al actualizar | Se ejecuta la función del componente, que devuelve un árbol de objetos nuevo que describe la interfaz; se compara con el árbol anterior (reconciliación) y se aplican las diferencias al DOM real | Se ejecuta la parte de actualización de la función de plantilla, que comprueba expresión a expresión y escribe directamente en los nodos que han cambiado |
| Memoria | Requiere mantener en memoria una copia del árbol anterior para poder compararla, y asignar el árbol nuevo en cada render | No hay árbol intermedio: la memoria adicional es proporcional al número de expresiones enlazadas, no al tamaño de la interfaz |
| Presión sobre el recolector de basura | Alta en interfaces grandes con actualizaciones frecuentes: cada render genera objetos efímeros | Muy baja: la fase de actualización apenas asigna memoria |
| Tamaño del código de renderizado | El algoritmo de reconciliación es genérico y va siempre completo en el bundle | Las instrucciones son granulares y se importan solo las utilizadas: es tree-shakable |
| Flexibilidad | Máxima: la vista es el resultado de una función de JavaScript, se puede componer con cualquier estructura de control del lenguaje | Menor: la plantilla es un lenguaje aparte con su propio control flow, comprobado por el compilador |
| Coste típico | Proporcional al tamaño del subárbol re-renderizado, salvo memoización manual | Proporcional al número de expresiones comprobadas, que con OnPush y señales se reduce a las vistas marcadas |
El virtual DOM es como reescribir la carta entera cada vez que cambias una palabra, comparar la nueva con la anterior y luego pasar a limpio solo las líneas distintas. Funciona, es muy flexible, pero consumes un folio en cada iteración.
El incremental DOM es como tener la carta ya escrita con unos huecos numerados y pegar un post-it solo en el hueco cuyo contenido ha cambiado. Menos flexible —los huecos están decididos de antemano— y mucho más barato en papel.
2.5.5 El papel de Zone.js, a alto nivel
Nos falta una pieza: ¿quién decide cuándo se ejecuta la fase de actualización? Históricamente, Zone.js. Es una librería que parchea las APIs asíncronas del navegador —setTimeout, addEventListener, XMLHttpRequest, Promise— para saber cuándo empieza y cuándo termina cualquier tarea asíncrona. Cuando la cola de tareas de la zona de Angular se vacía, el framework deduce que «puede haber cambiado algo» y ejecuta un recorrido de detección de cambios.
La ventaja es la comodidad: escribes código normal y la vista se actualiza sola. El inconveniente es la imprecisión: Angular no sabe qué ha cambiado, solo que algo asíncrono ha ocurrido, así que comprueba de más. De ahí la dirección actual del framework, el modo zoneless, en el que las señales notifican con precisión qué vista hay que refrescar y Zone.js deja de ser necesario.
OnPush, las señales y el modo zoneless son el objeto del capítulo 4, que es el capítulo central de la parte de Angular. Por ahora basta con la idea: Zone.js es el disparador de la comprobación, no el mecanismo de renderizado.
2.6 Instalación y CLI completo
2.6.1 Requisitos previos
Angular necesita Node.js y un gestor de paquetes. Cada versión mayor de Angular declara qué versiones de Node, TypeScript y RxJS admite, y el CLI se niega a funcionar —con un mensaje explícito— si la versión de Node está fuera de rango.
angular.dev/reference/versions. Si usas nvm, fija la versión del proyecto en un fichero .nvmrc para que todo el equipo y el servidor de integración continua usen la misma.
# Versiones instaladas
node --version # p. ej. v22.14.0
npm --version # p. ej. 10.9.2
# Gestión de versiones de Node con nvm (recomendado)
nvm install 22
nvm use 22
node --version > .nvmrc # deja constancia de la versión del proyecto
2.6.2 Instalación global frente a npx
# CLI global fijo, instalado una vez hace dos años
npm install -g @angular/cli
# Consecuencia: trabajas en tres proyectos con
# Angular 17, 19 y 20, y tu 'ng' global es de la 16.
# Los generadores producen código de una versión
# y el proyecto espera otra. Los mensajes de error
# son confusos porque nadie mira la versión del CLI.
ng version # dice 16.x mientras el proyecto es 20.x
# 1) Crear proyectos con npx: siempre la última versión
npx @angular/cli@latest new mi-app
# 2) Dentro del proyecto, usar SIEMPRE el CLI local
# (el que está en node_modules y en package.json)
npx ng generate component tareas
npm run ng -- generate component tareas
# El CLI local está fijado en package.json, así que
# todo el equipo y la CI usan exactamente el mismo.
# Si además instalas uno global, mantenlo actualizado:
npm install -g @angular/cli@latest
ng qué versión usar Cuando ejecutas ng dentro de un directorio que contiene un angular.json, el CLI global delega en el CLI local del proyecto si lo encuentra. Por eso a veces «funciona» pese a tener una global antigua. Aun así, la fuente de verdad debe ser siempre la dependencia del package.json: es lo único que la integración continua va a respetar.
2.6.3 ng new: crear el proyecto
npx @angular/cli@latest new gestor-tareas \
--routing \ # genera app.routes.ts y provideRouter
--style=scss \ # css | scss | sass | less
--ssr=false \ # renderizado en servidor: pregunta si se omite
--package-manager=npm \ # npm | yarn | pnpm | cnpm | bun
--prefix=app \ # prefijo de los selectores: <app-tareas>
--skip-tests=false \ # true omite los ficheros .spec.ts
--skip-git=false # true no inicializa el repositorio
# Opciones útiles adicionales:
# --dry-run muestra qué ficheros se crearían, sin escribir nada
# --skip-install no ejecuta npm install (útil en CI o sin red)
# --minimal sin pruebas ni configuración extra (prototipos)
# --inline-template plantilla dentro del .ts en lugar de fichero aparte
# --inline-style estilos dentro del .ts
# --create-application=false crea un espacio de trabajo vacío (monorepo)
# --directory=carpeta nombre de la carpeta si difiere del nombre del proyecto
# El catálogo completo de opciones de TU versión, siempre aquí:
ng new --help
ng new cambian entre versiones Algunas banderas nacen (por ejemplo, la creación directa de proyectos sin Zone.js apareció en versiones recientes), otras se vuelven el valor por defecto y dejan de tener sentido (--standalone, que hoy es implícito) y otras se retiran. Antes de copiar un mandato de un blog de hace tres años, ejecuta ng new --help con tu versión instalada: es la única fuente fiable.
2.6.4 ng generate: los schematics
Un schematic es un generador de código: una función que recibe unas opciones y produce o modifica ficheros del proyecto siguiendo las convenciones oficiales. No es un lujo cosmético: garantiza que todos los artefactos del proyecto se llamen, se sitúen y se registren igual, y evita las erratas de copiar y pegar.
| Schematic | Alias | Qué genera | Ejemplo real |
|---|---|---|---|
component | c | Clase con @Component, plantilla, estilos y fichero de pruebas | ng g c features/tareas/lista-tareas |
directive | d | Clase con @Directive y su spec | ng g d shared/directives/autofoco |
pipe | p | Clase con @Pipe y su spec | ng g p shared/pipes/tiempo-relativo |
service | s | Clase con @Injectable({ providedIn: 'root' }) | ng g s core/api/tareas |
guard | g | Guard funcional; pregunta el tipo (CanActivate, CanMatch…) | ng g guard core/auth/sesion --functional |
interceptor | — | Interceptor funcional de HttpClient | ng g interceptor core/http/token |
resolver | — | Resolver de datos de ruta | ng g resolver features/tareas/detalle-tarea |
class | — | Clase TypeScript simple | ng g class core/modelos/tarea --type=model |
interface | — | Interfaz TypeScript | ng g interface core/modelos/usuario |
enum | — | Enumerado TypeScript | ng g enum core/modelos/estado-tarea |
environments | — | Carpeta environments/ y el fileReplacements en angular.json | ng g environments |
config | — | Ficheros de configuración externos (karma, browserslist) | ng g config karma |
library | — | Librería publicable dentro del espacio de trabajo | ng g library ui-kit |
application | — | Aplicación adicional en el mismo espacio de trabajo | ng g application panel-admin |
module | m | NgModule (solo si mantienes código heredado) | ng g m legacy/informes --routing |
web-worker | — | Worker y la configuración de compilación asociada | ng g web-worker core/calculo-pesado |
service-worker | — | Configuración de PWA con @angular/service-worker | ng add @angular/pwa |
ng generate# --dry-run (-d): imprime qué ficheros se crearían SIN escribir nada.
# Úsalo siempre la primera vez que pruebes un schematic desconocido.
ng g c features/facturas/tabla-facturas --dry-run
# Opciones habituales de 'component'
ng g c features/facturas/tabla-facturas \
--change-detection=OnPush \ # estrategia de detección de cambios
--inline-style \ # estilos en el .ts
--inline-template \ # plantilla en el .ts
--flat \ # no crea subcarpeta propia
--skip-tests \ # sin fichero .spec.ts
--export # lo exporta del NgModule (solo código heredado)
# --project: obligatorio en espacios de trabajo con varias aplicaciones
ng g c cabecera --project=panel-admin
# Fijar valores por defecto para TODO el equipo, en angular.json:
# "schematics": { "@schematics/angular:component": {
# "changeDetection": "OnPush", "style": "scss" } }
# Así nadie tiene que acordarse de escribir las banderas.
2.6.5 El resto de mandatos
| Mandato | Para qué sirve | Ejemplo real |
|---|---|---|
ng serve | Compila en memoria y levanta el servidor de desarrollo con recarga en caliente | ng serve --port 4300 --open --configuration=development |
ng build | Compila a dist/. Por defecto usa la configuración de producción | ng build --configuration=production --stats-json |
ng test | Ejecuta las pruebas unitarias con el runner configurado | ng test --watch=false --code-coverage |
ng lint | Analiza el código. Requiere instalarlo antes con ng add @angular-eslint/schematics | ng lint --fix |
ng update | Actualiza paquetes y ejecuta migraciones de código | ng update @angular/core@20 @angular/cli@20 |
ng add | Instala un paquete y ejecuta su schematic de instalación, que configura el proyecto | ng add @angular/material |
ng deploy | Publica la aplicación. Solo existe si un paquete aporta el builder de despliegue | ng add angular-cli-ghpages && ng deploy |
ng cache | Gestiona la caché de compilación en disco (.angular/cache) | ng cache info · ng cache clean |
ng analytics | Controla el envío anónimo de telemetría del CLI | ng analytics disable --global |
ng version | Muestra las versiones de Angular, CLI, Node y paquetes. Primer mandato al pedir ayuda | ng version |
ng config | Lee o escribe valores de angular.json desde la línea de mandatos | ng config cli.packageManager pnpm |
ng run | Ejecuta un target cualquiera del angular.json, incluidos los personalizados | ng run mi-app:build:staging |
2.6.6 ng update y las migraciones automáticas
Este es el argumento comercial más sólido de Angular y conviene entender cómo funciona por dentro. Cuando ejecutas ng update @angular/core@20, el CLI hace cuatro cosas:
- Comprueba el estado del repositorio. Si hay cambios sin confirmar, se niega a continuar (salvo
--allow-dirty). Quiere que puedas revisar el diff con claridad. - Resuelve el árbol de dependencias y verifica que las versiones de todos los paquetes de Angular sean coherentes entre sí y con la de TypeScript y RxJS que exige la nueva versión.
- Actualiza
package.jsone instala. - Ejecuta las migraciones declaradas por el paquete en su fichero
migrations.json. Una migración es un schematic que analiza tu código con el árbol de sintaxis de TypeScript y lo reescribe: renombra APIs, cambia importaciones, transforma decoradores. No es una expresión regular: es una transformación consciente del AST.
# 0) Punto de partida limpio y una rama para la actualización
git status # debe estar limpio
git switch -c chore/actualizar-angular-20
# 1) Consultar la guía oficial ANTES de tocar nada.
# update.angular.dev genera la lista exacta de pasos
# para tu par de versiones (origen -> destino).
# 2) Nunca saltes versiones mayores. De la 18 a la 20 se pasa
# por la 19: cada mayor trae sus propias migraciones.
ng update @angular/core@19 @angular/cli@19
git add -A && git commit -m "chore: Angular 19"
ng update @angular/core@20 @angular/cli@20
# 3) Revisar QUÉ ha reescrito la herramienta en tu código
git diff --stat
git diff src/
# 4) Ejecutar migraciones opcionales que no se aplican solas
ng update @angular/core --migrate-only --name=nombre-de-la-migracion
# 5) Verificar
npm run test -- --watch=false
ng build --configuration=production
# Migrar de NgModules a componentes standalone. Son tres pasos que
# se ejecutan en orden y por separado; se detallan en la sección 2.10.
ng generate @angular/core:standalone
# Otras migraciones útiles del paquete @angular/core
# (los nombres disponibles dependen de tu versión: consúltalos
# en la guía de actualización antes de invocarlos)
ng generate @angular/core:control-flow # *ngIf/*ngFor -> @if/@for
ng generate @angular/core:inject # constructor -> inject()
ng generate @angular/core:signal-inputs # @Input() -> input()
ng generate @angular/core:output-migration # @Output() -> output()
ng generate @angular/core: y pulsa el tabulador, o consulta node_modules/@angular/core/schematics/migrations.json. El catálogo cambia en cada versión mayor: unas migraciones se añaden y otras se retiran cuando la API antigua desaparece. No copies nombres de migración de un tutorial sin comprobar antes que existen en tu instalación.
2.7 Estructura del proyecto, archivo por archivo
Un proyecto recién creado con el CLI tiene unos veinte ficheros. Ninguno sobra y conviene saber qué hace cada uno antes de empezar a moverlos de sitio.
gestor-tareas/
│
├── angular.json Configuración del ESPACIO DE TRABAJO: proyectos,
│ builders, configuraciones, presupuestos, activos.
│ Es el fichero más importante y el menos leído.
│
├── package.json Dependencias y scripts de npm.
├── package-lock.json Árbol exacto de versiones. VA AL REPOSITORIO.
│
├── tsconfig.json Configuración base de TypeScript + angularCompilerOptions
├── tsconfig.app.json Extiende la base: qué entra en la compilación de la app
├── tsconfig.spec.json Extiende la base: qué entra en la compilación de tests
│
├── .editorconfig Estilo de fichero (indentación, fin de línea)
├── .gitignore Excluye node_modules/, dist/, .angular/
│
├── public/ Activos estáticos copiados TAL CUAL a dist/
│ └── favicon.ico (en versiones anteriores esta carpeta era src/assets/)
│
└── src/
├── main.ts PUNTO DE ENTRADA. Arranca la aplicación.
├── index.html Documento HTML base. Contiene <app-root></app-root>
├── styles.scss Estilos globales (reset, tipografía, tema)
│
├── environments/ (solo si ejecutas 'ng generate environments')
│ ├── environment.ts valores de PRODUCCIÓN
│ └── environment.development.ts valores de DESARROLLO
│
└── app/
├── app.ts Componente raíz (clase)
├── app.html Plantilla del componente raíz
├── app.scss Estilos del componente raíz
├── app.spec.ts Pruebas del componente raíz
├── app.config.ts PROVEEDORES de la aplicación
└── app.routes.ts Tabla de rutas
NOTA SOBRE LOS NOMBRES: a partir de Angular v20 el CLI genera 'app.ts'
en lugar de 'app.component.ts' (desaparecen los sufijos .component,
.service, .directive...). Los proyectos anteriores conservan los sufijos
y ambos estilos son válidos. Comprueba qué genera TU versión con
'ng generate component prueba --dry-run'.
2.7.1 Qué hace exactamente cada fichero
| Fichero | Responsabilidad | Cuándo lo tocarás |
|---|---|---|
angular.json | Define los proyectos del espacio de trabajo y, para cada uno, qué builder ejecuta cada tarea y con qué opciones | Al añadir estilos globales, activos, presupuestos, entornos o una configuración nueva |
package.json | Dependencias, versiones y scripts de npm. Las de dependencies van al bundle; las de devDependencies, no | Al instalar librerías o definir scripts del equipo |
package-lock.json | Fija el árbol de dependencias completo, versión a versión, con sus hashes. Es lo que hace reproducible una instalación | Nunca a mano. Se confirma siempre en el repositorio |
tsconfig.json | Opciones del compilador de TypeScript y del compilador de Angular (angularCompilerOptions). Aquí viven strict, strictTemplates y los alias de rutas | Al configurar alias de importación o endurecer el modo estricto |
tsconfig.app.jsontsconfig.spec.json | Extienden al anterior y delimitan qué ficheros entran en cada compilación: el primero excluye los .spec.ts; el segundo añade los tipos del runner de pruebas | Rara vez: al declarar tipos globales o al cambiar de runner |
src/main.ts | Punto de entrada: llama a bootstrapApplication(App, appConfig) | Casi nunca: la configuración vive en app.config.ts |
src/index.html | Documento base. Contiene la etiqueta del componente raíz, el <base href> y las metaetiquetas | Al añadir metaetiquetas, tipografías o el color del tema |
src/styles.scss | Estilos globales: reset, variables CSS, tipografía. No estilos de componentes | Al definir el sistema de diseño |
app.config.ts | El ApplicationConfig: la lista de proveedores de raíz de toda la aplicación | Constantemente: cada vez que añades una capacidad global |
app.routes.ts | Tabla de rutas de primer nivel, normalmente con carga diferida | Al añadir una sección nueva |
public/ | Activos copiados sin procesar a la raíz de dist/: favicon, robots.txt, imágenes fijas | Al añadir recursos estáticos que se sirven por URL |
environments/ | Constantes que cambian según el entorno de compilación | Al distinguir URLs de API entre desarrollo y producción |
.angular/cache/ | Caché de compilación en disco. Se regenera sola | Nunca. Está en .gitignore y se borra con ng cache clean |
index.html y el <base href> Si despliegas la aplicación en un subdirectorio (por ejemplo https://empresa.com/gestor/), tienes que ajustar la etiqueta <base href="/gestor/"> o compilar con ng build --base-href=/gestor/. Es la causa número uno de «en local funciona y en el servidor la aplicación arranca en blanco con errores 404 de los ficheros JavaScript».
2.8 angular.json en detalle
Casi nadie lee este fichero hasta que algo falla. Es un error: angular.json describe cómo se construye tu producto. Vamos por partes.
2.8.1 Anatomía: proyectos, targets y builders
La jerarquía es siempre la misma: el espacio de trabajo contiene proyectos; cada proyecto tiene unos targets (también llamados architect targets: build, serve, test, lint); cada target nombra un builder —la función que ejecuta el trabajo— y le pasa unas options; y cada target puede definir varias configurations que sobrescriben esas opciones.
angular.json
│
└─ projects
└─ gestor-tareas
├─ projectType: "application"
├─ prefix: "app"
├─ sourceRoot: "src"
├─ schematics ← valores por defecto de 'ng generate'
└─ architect
├─ build
│ ├─ builder: "@angular/build:application"
│ ├─ options ← base común
│ └─ configurations
│ ├─ production ← optimiza, hashea, reemplaza ficheros
│ ├─ staging ← la que tú añadas
│ └─ development ← sourceMaps, sin optimizar
├─ serve
│ ├─ builder: "@angular/build:dev-server"
│ └─ configurations ← apuntan a una configuración de 'build'
├─ test
└─ lint
'ng build' usa la configuración por defecto (production)
'ng build -c staging' usa options + configurations.staging
'ng run gestor-tareas:build:staging' forma larga y explícita
application, pero cambió de paquete: primero se publicó como @angular-devkit/build-angular:application y después se movió al paquete @angular/build, quedando como @angular/build:application. Los proyectos antiguos pueden seguir con el builder de webpack (:browser). Mira qué pone tu angular.json antes de copiar configuraciones de internet: las opciones no son idénticas entre builders.
2.8.2 Un fragmento real, comentado
{
"projects": {
"gestor-tareas": {
"projectType": "application",
"prefix": "app",
"sourceRoot": "src",
"schematics": {
"@schematics/angular:component": {
"changeDetection": "OnPush",
"style": "scss"
}
},
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"index": "src/index.html",
"tsConfig": "tsconfig.app.json",
"outputPath": "dist/gestor-tareas",
"assets": [ { "glob": "**/*", "input": "public" } ],
"styles": [ "src/styles.scss" ],
"scripts": [],
"stylePreprocessorOptions": { "includePaths": ["src/estilos"] }
},
"configurations": {
"production": {
"budgets": [
{ "type": "initial",
"maximumWarning": "500kB", "maximumError": "1MB" },
{ "type": "anyComponentStyle",
"maximumWarning": "4kB", "maximumError": "8kB" }
],
"fileReplacements": [
{ "replace": "src/environments/environment.ts",
"with": "src/environments/environment.production.ts" }
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false
},
"development": {
"optimization": false,
"outputHashing": "none",
"sourceMap": true,
"extractLicenses": false
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": { "buildTarget": "gestor-tareas:build:production" },
"development": { "buildTarget": "gestor-tareas:build:development" }
},
"defaultConfiguration": "development"
}
}
}
}
}
2.8.3 Las opciones que de verdad importan
| Opción | Qué hace | Recomendación |
|---|---|---|
assets | Ficheros copiados sin procesar al directorio de salida | Solo lo que se sirve por URL. Las imágenes de un componente deben importarse desde el código para que entren en el grafo del empaquetador |
styles | Hojas de estilo globales, en orden de inclusión | Reset, variables y tema. Los estilos de componente van en el componente |
scripts | JavaScript global inyectado antes de la aplicación | Evítalo. Es un agujero por el que se escapan el tipado y el tree shaking. Instala la librería como dependencia |
fileReplacements | Sustituye un fichero por otro durante la compilación | Solo para los environments. Sustituir servicios enteros produce compilaciones imposibles de razonar |
optimization | Minificación, tree shaking, CSS crítico en línea, tipografías en línea. Admite true o un objeto detallado | true en producción. Si algo se rompe solo al optimizar, desactiva las subopciones una a una para localizar la causa |
sourceMap | Genera mapas de fuentes. Admite objeto con scripts, styles, vendor y hidden | Activo en desarrollo. En producción, considera hidden: true y súbelos a tu servicio de monitorización sin publicarlos |
outputHashing | none, media, bundles o all. Añade un hash del contenido al nombre del fichero | all en producción: es lo que permite cachear los estáticos para siempre e invalidarlos con cada despliegue |
budgets | Umbrales de tamaño que producen aviso o error | Obligatorios. Un presupuesto es la única defensa automática contra la degradación progresiva del bundle |
defaultConfiguration | Configuración usada cuando no se pasa -c | Déjala en production para build: es lo que evita desplegar una compilación de desarrollo por error |
2.8.4 Presupuestos: cómo leer un fallo de budget
Los presupuestos son un contrato con tu yo del futuro. Sin ellos, el bundle crece un poco cada semana y nadie lo nota hasta que la aplicación tarda ocho segundos en arrancar en un móvil de gama media.
$ ng build
✔ Building...
Initial chunk files | Names | Raw size | Estimated transfer size
main-7QZK4XJ2.js | main | 1.42 MB | 312.05 kB
styles-K3MPQ8LA.css | styles | 84.31 kB | 9.12 kB
polyfills-B6TL9N1E.js | polyfills | 34.58 kB | 11.32 kB
▲ [WARNING] bundle initial exceeded maximum budget.
Budget 500.00 kB was not met by 1.02 MB with a total of 1.52 MB.
✘ [ERROR] bundle initial exceeded maximum budget.
Budget 1.00 MB was not met by 520.13 kB with a total of 1.52 MB.
Error: Bundle exceeded maximum budget.
Cómo se interpreta. El presupuesto de tipo initial mide todo lo que el navegador debe descargar antes de poder pintar la primera pantalla. Aquí hay 1,52 MB sin comprimir frente a un tope de 1 MB. Ojo con una confusión muy extendida: el presupuesto compara contra el raw size (sin comprimir), no contra el estimated transfer size (comprimido), que es lo que realmente viaja por la red. Un bundle de 1,4 MB sin comprimir suele quedarse en unos 300 kB por el cable.
Qué hacer, en este orden. Uno: ejecuta ng build --stats-json y analiza el resultado (ver 2.13) para averiguar qué ocupa. Dos: si es una librería pesada usada en una sola pantalla, muévela detrás de una ruta con carga diferida o de un bloque @defer. Tres: comprueba que no estás importando la librería entera cuando solo necesitas una función. Cuatro, y solo cuando hayas agotado lo anterior: sube el presupuesto documentando en el mensaje del commit por qué. Subir el presupuesto sin investigar es apagar la alarma de incendios porque molesta el ruido.
// Importa TODA la librería para usar una función.
// El empaquetador no siempre puede eliminar el resto,
// y en el caso de lodash (CommonJS) desde luego no puede.
import _ from 'lodash';
import * as moment from 'moment';
const nombres = _.uniq(this.usuarios.map((u) => u.nombre));
const fecha = moment().format('DD/MM/YYYY');
// Resultado: ~70 kB de lodash y ~230 kB de moment
// (con todas sus localizaciones) en el bundle inicial.
// 1) La plataforma ya lo resuelve: no añadas dependencia.
const nombres = [...new Set(this.usuarios.map((u) => u.nombre))];
const fecha = new Intl.DateTimeFormat('es-ES').format(new Date());
// 2) Si necesitas una librería, elige una modular y
// ESM, e importa solo lo que uses:
import { format } from 'date-fns';
import { es } from 'date-fns/locale';
const legible = format(new Date(), 'dd/MM/yyyy', { locale: es });
// Resultado: unos pocos kB, y solo los que se usan.
2.9 Arranque de la aplicación
2.9.1 El modelo antiguo y el actual
import { platformBrowserDynamic }
from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
platformBrowserDynamic()
.bootstrapModule(AppModule)
.catch((err) => console.error(err));
// Problemas de este modelo:
// · 'platformBrowserDynamic' implica compilador JIT
// disponible en el arranque.
// · Toda la configuración vive dentro de un NgModule
// con arrays 'imports' y 'providers' que crecen sin
// control y que el empaquetador no puede podar.
// · Importar un módulo arrastra TODO su contenido,
// se use o no.
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig } from './app/app.config';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));
// Ventajas:
// · Sin compilador en tiempo de ejecución.
// · La configuración es una lista PLANA de funciones
// 'provide*' que el empaquetador puede analizar:
// lo que no se provee, no entra en el bundle.
// · El fichero de arranque es trivial y estable;
// la configuración vive aparte y se puede reutilizar
// en las pruebas y en el renderizado en servidor.
FLUJO DE ARRANQUE DE UNA APLICACIÓN ANGULAR
navegador descarga index.html
│
▼
carga main-HASH.js (punto de entrada)
│
▼
┌──────────────────────────────────────────────┐
│ bootstrapApplication(App, appConfig) │
└───────────────────┬──────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ 1. Se crea la PLATAFORMA y el INYECTOR RAÍZ │
│ con todos los providers de appConfig │
│ (router, HttpClient, animaciones...) │
└───────────────────┬──────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ 2. INICIALIZADORES de la aplicación │
│ Angular ESPERA a que terminen (si │
│ devuelven una promesa u observable) │
│ antes de renderizar nada. │
│ Ej.: cargar config remota, restaurar sesión│
└───────────────────┬──────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ 3. Se instancia el COMPONENTE RAÍZ y se monta │
│ en el elemento <app-root> del index.html │
└───────────────────┬──────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ 4. El ROUTER lee la URL actual, resuelve la │
│ ruta, ejecuta guards y resolvers y carga │
│ de forma diferida el chunk de la sección │
└───────────────────┬──────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ 5. PRIMERA DETECCIÓN DE CAMBIOS: se ejecuta │
│ la fase de creación de cada plantilla y │
│ aparecen los píxeles │
└──────────────────────────────────────────────┘
2.9.2 Un app.config.ts de producción
import {
ApplicationConfig, ErrorHandler, inject, isDevMode,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import {
provideRouter, withComponentInputBinding, withInMemoryScrolling,
withRouterConfig, withViewTransitions,
} from '@angular/router';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { rutas } from './app.routes';
import { interceptorToken } from './core/http/token.interceptor';
import { interceptorErrores } from './core/http/errores.interceptor';
import { ManejadorErroresGlobal } from './core/errores/manejador-errores';
import { ConfiguracionService } from './core/config/configuracion.service';
export const appConfig: ApplicationConfig = {
providers: [
// ── ENRUTADO ──────────────────────────────────────────────
provideRouter(
rutas,
// Enlaza los parámetros de ruta directamente a los inputs
// del componente: se acabó inyectar ActivatedRoute para todo.
withComponentInputBinding(),
// Restaura la posición de scroll al navegar atrás y salta
// al ancla cuando la URL trae fragmento.
withInMemoryScrolling({
scrollPositionRestoration: 'enabled',
anchorScrolling: 'enabled',
}),
// Transiciones de vista del navegador donde estén soportadas.
withViewTransitions(),
withRouterConfig({ onSameUrlNavigation: 'reload' }),
),
// ── CLIENTE HTTP ──────────────────────────────────────────
provideHttpClient(
// Usa la API fetch en lugar de XMLHttpRequest: necesario
// para el renderizado en servidor y para respuestas por streaming.
withFetch(),
// Los interceptores funcionales se ejecutan EN ORDEN.
withInterceptors([interceptorToken, interceptorErrores]),
),
// ── ANIMACIONES ───────────────────────────────────────────
// La variante 'Async' carga el motor de animaciones de forma
// diferida: no pesa en el bundle inicial si nada anima al arrancar.
provideAnimationsAsync(),
// ── ERRORES ───────────────────────────────────────────────
// Captura errores no controlados de promesas y del propio
// 'window' y los encamina hacia el ErrorHandler de Angular.
provideBrowserGlobalErrorListeners(),
{ provide: ErrorHandler, useClass: ManejadorErroresGlobal },
],
};
provideBrowserGlobalErrorListeners()) apareció en versiones recientes; la inicialización de la aplicación pasó del token APP_INITIALIZER a la función provideAppInitializer(); y el proveedor del modo sin zona se llamó primero provideExperimentalZonelessChangeDetection() y después provideZonelessChangeDetection() al estabilizarse. La comprobación es inmediata: escribe el nombre en el editor y mira si TypeScript ofrece la importación automática desde @angular/core; si no aparece, esa API no existe en tu versión.
import { ErrorHandler, Injectable, NgZone, inject, isDevMode } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { Router } from '@angular/router';
@Injectable({ providedIn: 'root' })
export class ManejadorErroresGlobal implements ErrorHandler {
private readonly zona = inject(NgZone);
private readonly router = inject(Router);
private readonly telemetria = inject(TelemetriaService);
handleError(error: unknown): void {
// 1) En desarrollo, que se vea en consola con su pila completa.
if (isDevMode()) {
console.error('[Error no controlado]', error);
}
// 2) Los errores HTTP ya se tratan en el interceptor: aquí solo
// llegan los que nadie ha capturado. Evita duplicar avisos.
if (error instanceof HttpErrorResponse) {
if (error.status === 401) {
// El ErrorHandler se ejecuta FUERA de la zona de Angular:
// hay que volver a entrar para que la navegación
// dispare la detección de cambios.
this.zona.run(() => this.router.navigate(['/entrar']));
}
return;
}
// 3) Los errores de carga de un chunk suelen significar que se ha
// desplegado una versión nueva y el fichero antiguo ya no existe.
const mensaje = error instanceof Error ? error.message : String(error);
if (/ChunkLoadError|dynamically imported module/i.test(mensaje)) {
location.reload();
return;
}
// 4) Todo lo demás va a la telemetría, SIN datos personales.
this.telemetria.registrarExcepcion(error);
}
}
// Cargar configuración remota ANTES de que se pinte nada.
// Angular espera a que la promesa se resuelva.
//
// API actual (v19 y posteriores):
provideAppInitializer(() => {
const config = inject(ConfiguracionService);
return config.cargar(); // devuelve Promise<void> u Observable
}),
// API anterior, equivalente, todavía presente en muchos proyectos:
// {
// provide: APP_INITIALIZER,
// useFactory: (config: ConfiguracionService) => () => config.cargar(),
// deps: [ConfiguracionService],
// multi: true,
// },
// AVISO DE RENDIMIENTO: todo lo que pongas aquí RETRASA el primer
// pintado. Reserva este mecanismo para lo imprescindible (la URL de
// la API, la marca blanca del cliente) y carga el resto en paralelo
// una vez arrancada la aplicación.
2.10 Standalone frente a NgModules
2.10.1 Qué era un NgModule y qué problemas tenía
Un NgModule era una clase decorada que agrupaba componentes, directivas y pipes, declaraba qué otros módulos necesitaba y qué exportaba al exterior. Cumplía tres funciones a la vez: ámbito de compilación (qué directivas puede usar una plantilla), agrupación para carga diferida y contenedor de proveedores. Mezclar tres responsabilidades en un mismo artefacto es exactamente lo que aconseja evitar el principio de responsabilidad única, y las consecuencias fueron las esperables:
- Indirección constante. Para saber por qué una directiva funcionaba en una plantilla había que abrir el módulo, ver qué importaba y, con frecuencia, seguir la cadena a través de dos o tres módulos más.
- Ceremonia desproporcionada. Crear un componente exigía crearlo, declararlo en un módulo y exportarlo. Tres ficheros tocados para una unidad de interfaz.
- Peor tree shaking. Importar un módulo arrastraba todo lo declarado en él, aunque la plantilla usara un solo componente.
- El
SharedModulemonstruo. El patrón universal era un módulo compartido que exportaba todo; acababa importado en todas partes y arrastrando la mitad de la aplicación al bundle inicial. - Errores desconcertantes.
'app-tarjeta' is not a known elementsignificaba casi siempre un módulo mal importado, no un error real de plantilla. - Barrera de entrada. Era el concepto que más costaba explicar a quien llegaba de React o de Vue, porque no tiene equivalente en ningún otro framework.
// FICHERO 1: el módulo
@NgModule({
declarations: [ListaTareasComponent, FilaTareaComponent],
imports: [CommonModule, ReactiveFormsModule, RouterModule],
exports: [ListaTareasComponent],
providers: [TareasService],
})
export class TareasModule {}
// FICHERO 2: el componente. Sus dependencias reales
// NO se ven aquí: están en el módulo de al lado.
@Component({
selector: 'app-lista-tareas',
templateUrl: './lista-tareas.component.html',
})
export class ListaTareasComponent {}
// Para leer este componente hay que abrir DOS ficheros
// y reconstruir mentalmente el ámbito de compilación.
// UN SOLO FICHERO. Las dependencias son explícitas
// y locales: se leen en el propio componente.
@Component({
selector: 'app-lista-tareas',
imports: [ReactiveFormsModule, RouterLink, FilaTarea],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './lista-tareas.html',
})
export class ListaTareas {
private readonly tareas = inject(TareasService);
}
// Ventajas concretas:
// · El empaquetador ve exactamente qué se usa.
// · Un import sobrante lo marca el linter como
// 'unused', cosa imposible con NgModules.
// · 'standalone: true' es implícito desde la v19.
2.10.2 Cómo se migra
No a mano. Angular incluye una migración oficial que hace el trabajo pesado en tres pasos, y la clave está en ejecutarlos en orden y confirmar en el control de versiones entre uno y otro para poder revisar cada diff por separado.
# Paso 1 · Marcar componentes, directivas y pipes como standalone
# y moverles sus dependencias desde el NgModule.
ng generate @angular/core:standalone
# → elegir "Convert all components, directives and pipes to standalone"
git add -A && git commit -m "refactor: componentes standalone"
# Paso 2 · Eliminar los NgModules que han quedado sin contenido.
ng generate @angular/core:standalone
# → elegir "Remove unnecessary NgModule classes"
git add -A && git commit -m "refactor: eliminar NgModules vacíos"
# Paso 3 · Cambiar el arranque a bootstrapApplication.
ng generate @angular/core:standalone
# → elegir "Bootstrap the project using standalone APIs"
git add -A && git commit -m "refactor: arranque standalone"
# Después de cada paso, SIEMPRE:
npm run test -- --watch=false
ng build --configuration=production
2.10.3 Interoperabilidad entre ambos mundos
La migración puede ser gradual porque los dos modelos conviven de forma bidireccional. Merece la pena tener claras las dos direcciones:
| Situación | Cómo se resuelve |
|---|---|
Un componente standalone necesita algo declarado en un NgModule (por ejemplo, una librería antigua) | Se añade el módulo entero al array imports del componente: imports: [MatButtonModule] |
Un NgModule necesita usar un componente standalone en las plantillas de sus declaraciones | Se añade el componente al array imports del módulo (no a declarations: un standalone no se declara en ningún sitio) |
Hay proveedores repartidos en varios NgModule y quieres arrancar con bootstrapApplication | importProvidersFrom(MiModulo) extrae los proveedores del módulo hacia el inyector raíz. Es un puente de transición: cuando la librería ofrezca una función provide*, cámbiala |
| Rutas con carga diferida de código antiguo | loadChildren sigue funcionando con módulos; para componentes standalone se usa loadComponent, y para grupos de rutas, loadChildren devolviendo un array de rutas |
NgModule.
2.11 Organización del código a escala
El CLI genera una carpeta app/ plana. Eso vale para el tutorial y deja de valer alrededor del componente número veinte. La pregunta que hay que responder es: ¿cuál es el criterio principal para agrupar ficheros? Hay dos respuestas posibles y solo una escala.
2.11.1 Agrupar por tipo frente a agrupar por funcionalidad
| Por tipo (components/, services/…) | Por funcionalidad (features/facturas/…) | |
|---|---|---|
| Cómo se ve | Todos los componentes juntos, todos los servicios juntos | Todo lo de facturación junto, todo lo de clientes junto |
| Añadir una función | Se tocan cuatro carpetas lejanas entre sí | Se toca una carpeta; el diff se lee de un vistazo |
| Borrar una función | Hay que ir a cazar los restos por todo el proyecto | Se borra la carpeta y punto |
| Carga diferida | Difícil: las dependencias cruzan carpetas sin control | Natural: cada funcionalidad es una unidad de carga |
| Propiedad del código | Imposible asignar carpetas a equipos | Un equipo, una carpeta, un fichero CODEOWNERS |
| Escala hasta | Unos veinte ficheros | Miles |
ESTRUCTURA RECOMENDADA A ESCALA
src/app/
│
├── core/ Lo que existe UNA sola vez en la aplicación.
│ │ Se usa en todas partes. No depende de features.
│ ├── auth/ sesión, guards de autenticación, permisos
│ ├── http/ interceptores (token, errores, reintentos)
│ ├── errores/ ErrorHandler global, tipos de error
│ ├── config/ configuración en tiempo de ejecución
│ └── modelos/ tipos e interfaces del dominio compartido
│
├── shared/ Piezas REUTILIZABLES y SIN ESTADO de negocio.
│ │ No depende de core ni de features.
│ ├── ui/ botón, tarjeta, modal, tabla, paginador
│ ├── directivas/ autofoco, permiso, clic-fuera
│ ├── pipes/ tiempo-relativo, moneda-es, truncar
│ └── utilidades/ funciones puras, sin dependencias de Angular
│
├── features/ UNA CARPETA POR ÁREA FUNCIONAL.
│ ├── facturas/ Cada una se carga de forma diferida.
│ │ ├── facturas.routes.ts rutas de la sección
│ │ ├── datos/ servicios de acceso a la API
│ │ ├── modelos/ tipos propios de esta feature
│ │ └── paginas/ componentes con ruta
│ │ ├── lista-facturas/
│ │ └── detalle-factura/
│ ├── clientes/
│ └── informes/
│
├── layout/ Cabecera, barra lateral, pie, contenedor
│
├── app.ts / app.html
├── app.config.ts
└── app.routes.ts Solo rutas de primer nivel, todas diferidas
REGLA DE DEPENDENCIAS (la flecha significa "puede importar de"):
features ──▶ shared ──▶ (nada del proyecto)
│
└──────▶ core ──▶ shared
features ──✗──▶ otra feature (prohibido: extrae a shared o core)
shared ──✗──▶ core o features (prohibido: shared no sabe de negocio)
core ──✗──▶ features (prohibido: crea ciclos)
shared/ Si un componente de shared/ necesita importar un servicio de negocio, ya no es compartido: es de una funcionalidad concreta y está en el sitio equivocado. Un componente compartido recibe datos por sus entradas y emite eventos por sus salidas; no sabe qué es una factura. En cuanto relajas esta regla, shared/ se convierte en el nuevo SharedModule monstruo y arrastra media aplicación al bundle inicial.
2.11.2 Barrel files: la buena idea que envejece mal
Un barrel file es un index.ts que reexporta el contenido de una carpeta para poder importar desde un único sitio. Es cómodo y en aplicaciones grandes causa dos problemas serios.
// Un barril que reexporta TODO
export * from './boton/boton';
export * from './tabla/tabla';
export * from './editor-richtext/editor-richtext'; // arrastra 400 kB
export * from './grafico/grafico'; // arrastra 180 kB
// El consumidor solo quiere el botón:
import { Boton } from '@app/shared/ui';
// PROBLEMA 1 · TREE SHAKING: el empaquetador debe analizar el
// módulo entero. Si el editor tiene efectos secundarios en el
// nivel superior, no puede podarlo y entran los 400 kB.
//
// PROBLEMA 2 · CICLOS: si un componente del barril importa a
// otro a través del propio barril, se crea una dependencia
// circular. Síntomas típicos y desconcertantes:
// - "Cannot access 'X' before initialization" en el arranque
// - NG0203 al inyectar dentro de un campo de clase
// - un servicio que llega como 'undefined' sin motivo aparente
//
// PROBLEMA 3 · COMPILACIÓN: cualquier cambio en cualquier
// fichero del barril invalida a todos sus consumidores.
// Alias de rutas en lugar de barriles: rutas cortas
// SIN indirección y sin riesgo de ciclos.
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@core/*": ["src/app/core/*"],
"@shared/*": ["src/app/shared/*"],
"@features/*": ["src/app/features/*"]
}
}
}
// Consumo: se importa el fichero concreto.
// import { Boton } from '@shared/ui/boton/boton';
//
// Ventajas:
// · El empaquetador ve la dependencia exacta.
// · Imposible crear un ciclo a través del barril.
// · Al leer el import sabes DÓNDE está el fichero.
//
// Si aun así quieres barriles, limítalos al borde
// PÚBLICO de una librería (el public-api.ts de una
// librería publicable) y nunca dentro de una carpeta
// cuyos ficheros se importen entre sí.
2.11.3 Convenciones de nombres
- Ficheros y carpetas en kebab-case:
lista-facturas/,tiempo-relativo.ts. Nunca espacios ni mayúsculas: hay sistemas de ficheros sensibles a mayúsculas y otros que no, y esa diferencia rompe la integración continua de formas muy difíciles de depurar. - Clases en PascalCase:
ListaFacturas,FacturasService. - Un artefacto por fichero y el nombre del fichero igual al de su artefacto principal.
- Prefijo de selector coherente con el
prefixdel proyecto:app-lista-facturas. En un monorepo, un prefijo por librería (ui-boton,fact-lista) evita colisiones. - Sufijos: los proyectos anteriores a la v20 usan
.component.ts,.service.ts,.pipe.ts; los nuevos generan nombres sin sufijo. Ambos son válidos. Lo que no es válido es mezclar los dos criterios en el mismo repositorio: elige uno y escríbelo en la guía de estilo del equipo. - Idioma: decide si el dominio se escribe en español o en inglés y respétalo. Lo peor es
getFacturasPendientes()junto aobtenerInvoiceList().
2.11.4 Cuándo pasar a un monorepo con Nx
Nx es un sistema de construcción para monorepos que se integra con Angular y sustituye o envuelve al CLI. No es «Angular avanzado»: es una herramienta de escala que aporta cosas concretas.
- Grafo de dependencias. Nx analiza las importaciones reales y construye el grafo del proyecto. Puedes visualizarlo y, sobre todo, preguntarle qué proyectos se ven afectados por un cambio concreto.
- Ejecución por afectación. En un pull request, en lugar de compilar y probar los cuarenta proyectos, Nx ejecuta solo los afectados por los ficheros tocados. En un monorepo grande, la diferencia es de cuarenta minutos a cuatro.
- Caché de tareas. Si una tarea ya se ejecutó con exactamente las mismas entradas, se reutiliza el resultado guardado en lugar de repetirla. Con caché remota compartida, el segundo desarrollador que compile una rama obtiene el resultado del primero al instante.
- Reglas de límites entre módulos. La regla de lint
@nx/enforce-module-boundariesconvierte en error de compilación lo que en un proyecto normal es solo una convención escrita en un documento que nadie lee. Se etiqueta cada librería (type:feature,type:ui,scope:facturas) y se declara qué puede importar qué. - Generadores propios. Además de los de Angular, puedes escribir generadores para las convenciones de tu empresa.
nx init); adoptarlo «por si acaso» rara vez compensa.
2.12 Configuración por entornos
2.12.1 Configuración en tiempo de compilación
El mecanismo clásico de Angular es el reemplazo de ficheros. Se genera con ng generate environments, que crea la carpeta y además escribe el fileReplacements correspondiente en angular.json.
// ── environment.ts (el que se importa SIEMPRE en el código) ──
// Contiene los valores de PRODUCCIÓN. Es el fichero por defecto.
export const environment = {
produccion: true,
apiUrl: 'https://api.empresa.com/v1',
version: '2.4.0',
registroNivel: 'error' as const,
};
// ── environment.development.ts ──
export const environment = {
produccion: false,
apiUrl: 'http://localhost:3000/api/v1',
version: 'dev',
registroNivel: 'debug' as const,
};
// ── Uso en el código: SIEMPRE se importa el fichero base ──
import { environment } from '../environments/environment';
private readonly base = environment.apiUrl;
// Durante la compilación de desarrollo, el builder sustituye
// físicamente el módulo por el de desarrollo. El código
// consumidor no se entera y no hay ningún 'if' en el bundle.
environments es una filtración, no una configuración. Y no, ponerlo en una variable de entorno de la pipeline no cambia nada: acaba igualmente incrustada en el bundle.
export const environment = {
apiUrl: 'https://api.empresa.com',
// TODO ESTO ES PÚBLICO. Está en el bundle.
claveApiStripe: 'sk_live_51H8...', // clave SECRETA
secretoJwt: 'mi-secreto-de-firma', // firma de tokens
usuarioBd: 'admin',
passwordBd: 'Passw0rd!',
claveCifrado: 'AES256-clave-maestra',
};
// Consecuencia real: cualquiera abre las DevTools,
// busca "sk_live" en los ficheros descargados y tiene
// tu clave de cobros. Ha ocurrido muchas veces y sale
// carísimo.
export const environment = {
produccion: true,
// Solo datos que YA son públicos por naturaleza:
apiUrl: 'https://api.empresa.com/v1',
version: '2.4.0',
// Clave PUBLICABLE de Stripe: está diseñada para
// vivir en el cliente y no permite cobrar por sí sola.
clavePublicaStripe: 'pk_live_51H8...',
// Identificadores públicos de OAuth: el flujo con PKCE
// no requiere secreto de cliente en aplicaciones SPA.
clienteOauthId: 'gestor-tareas-web',
};
// Todo lo secreto vive en el BACKEND. El frontend pide
// al backend, y el backend habla con Stripe usando su
// clave secreta, que nunca sale del servidor.
2.12.2 Configuración en tiempo de ejecución
El reemplazo de ficheros tiene una limitación importante: obliga a compilar una vez por entorno. Si tienes desarrollo, integración, preproducción y producción, son cuatro artefactos distintos, y el que pruebas en preproducción no es exactamente el que despliegas en producción. Para muchos equipos eso es inaceptable, y para el modelo de contenedores («compila una vez, despliega en todas partes») directamente no funciona.
La alternativa es cargar la configuración al arrancar, desde un fichero JSON estático que se sustituye en cada despliegue.
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
export interface ConfiguracionApp {
readonly apiUrl: string;
readonly entorno: 'desarrollo' | 'integracion' | 'preproduccion' | 'produccion';
readonly funcionesActivas: readonly string[];
}
@Injectable({ providedIn: 'root' })
export class ConfiguracionService {
private readonly http = inject(HttpClient);
private readonly _config = signal<ConfiguracionApp | null>(null);
readonly config = this._config.asReadonly();
/** Se invoca desde provideAppInitializer: Angular espera a la promesa. */
async cargar(): Promise<void> {
// El fichero vive en public/, así que se copia sin procesar a dist/.
// En el despliegue se monta encima el config.json del entorno
// (un volumen de Kubernetes, una capa de la imagen, un ConfigMap...).
const config = await firstValueFrom(
this.http.get<ConfiguracionApp>('/config.json'),
);
this._config.set(config);
}
/** Lectura segura: si esto lanza, el inicializador no se ejecutó. */
get valor(): ConfiguracionApp {
const c = this._config();
if (!c) throw new Error('Configuración no cargada: falta el inicializador');
return c;
}
}
Tiempo de compilación (fileReplacements) | Tiempo de ejecución (config.json) | |
|---|---|---|
| Artefactos | Uno por entorno | Uno solo para todos |
| Coste en el arranque | Cero | Una petición HTTP antes del primer pintado |
| Optimización | El valor es una constante: el compilador puede eliminar ramas muertas | El valor es dinámico: no hay eliminación de código muerto |
| Cambiar un valor | Requiere recompilar y volver a desplegar | Basta con cambiar el fichero y recargar |
| Encaja con | Proyectos con pocos entornos y despliegue directo desde CI | Contenedores, «compila una vez», marca blanca por cliente |
environments para lo que es estructuralmente distinto entre desarrollo y producción y no cambia nunca (el indicador produccion, el nivel de registro, si se activan las herramientas de depuración) y config.json para lo que varía por despliegue (la URL de la API, los interruptores de funcionalidad, el tema del cliente). Y recuerda: en ambos casos, nada secreto.
2.13 Rendimiento de la compilación y del arranque
2.13.1 El builder basado en esbuild y Vite
Durante años Angular compiló con webpack. Desde la v17 el builder por defecto para proyectos nuevos es el llamado application, que usa esbuild para el empaquetado y Vite como servidor de desarrollo. Las diferencias en la práctica:
- Compilación de producción varias veces más rápida. esbuild está escrito en Go y paraleliza de forma agresiva. Los tiempos típicos bajan de minutos a decenas de segundos.
- Servidor de desarrollo casi instantáneo. Vite no empaqueta en desarrollo: sirve módulos ES nativos al navegador y transforma solo lo que se pide. El arranque deja de depender del tamaño de la aplicación.
- Recarga en caliente de estilos y plantillas sin perder el estado de la pantalla.
- Salida moderna: genera módulos ES, y no las parejas de bundles diferenciales de la época de webpack.
architect.build.builder en tu angular.json: si pone :browser, sigues en webpack; si pone :application, ya estás en esbuild. Angular publica una migración automática para el cambio; el nombre exacto del schematic depende de la versión, así que consúltalo en la guía de actualización oficial en lugar de adivinarlo. Antes de migrar, revisa dos cosas: los plugins de webpack personalizados (si usabas @angular-builders/custom-webpack, no tienen equivalente directo) y los polyfills declarados a mano.
2.13.2 Analizar el bundle
# 1) Compilar generando el fichero de estadísticas
ng build --configuration=production --stats-json
# 2) IMPORTANTE: el formato de stats.json depende del builder.
# · builder ':browser' -> formato de webpack
# · builder ':application' -> "metafile" de esbuild
# Las herramientas NO son intercambiables.
# Para el builder moderno (esbuild): esbuild-visualizer
npx esbuild-visualizer --metadata dist/gestor-tareas/stats.json \
--filename analisis.html --open
# Alternativa sin instalar nada: subir el metafile al
# analizador oficial en esbuild.github.io/analyze/
# Para el builder antiguo (webpack): webpack-bundle-analyzer
npx webpack-bundle-analyzer dist/gestor-tareas/stats.json
# 3) Vista rápida sin herramientas: los tamaños por chunk que el
# propio 'ng build' imprime al terminar. Compáralos entre commits.
Qué buscar en el análisis. Primero, cualquier librería en el chunk inicial que solo se use en una pantalla concreta: es candidata a carga diferida. Segundo, dependencias duplicadas en varias versiones (dos copias de la misma librería suele significar un package-lock.json desordenado o dos rutas de importación distintas al mismo paquete). Tercero, locales e idiomas completos de librerías de fechas o de internacionalización. Cuarto, imágenes o fuentes incrustadas en base64 que serían más eficientes como ficheros aparte.
2.13.3 Qué hacer cuando el build tarda demasiado
- Comprueba la caché primero.
ng cache infote dice si está activa y cuánto ocupa. En integración continua suele estar desactivada por defecto en entornos de solo escritura: si la activas y persistes.angular/cacheentre ejecuciones, los tiempos caen a la mitad. - Mide antes de optimizar. Ejecuta
ng build --verboseo cronometra las fases: casi siempre el culpable es identificable y concreto. - Revisa el preprocesador de estilos. Un
@useo@importde un fichero de variables grande en cada componente multiplica el trabajo de Sass por el número de componentes. UsastylePreprocessorOptions.includePathsy limita los imports globales a variables y mixins, nunca a reglas CSS. - Vigila el número de ficheros que entra en la compilación: un
includedemasiado amplio entsconfig.app.jsonpuede estar arrastrando ficheros de pruebas o generados. Y desactiva los mapas de fuentes en la compilación de CI si no los subes a ninguna parte, porque generarlos cuesta tiempo y memoria. - Sube la memoria de Node si ves
JavaScript heap out of memory:NODE_OPTIONS=--max-old-space-size=8192 ng build. Es un parche, pero desbloquea mientras investigas la causa. - Si nada de esto basta, el problema es de arquitectura: tienes un monolito de frontend y toca dividirlo. Ahí es donde Nx y su ejecución por afectación empiezan a compensar.
2.13.4 El arranque en el navegador
- Lazy loading por ruta. Todo lo que no sea la primera pantalla debe estar detrás de un
loadComponento unloadChildren. Es la optimización con mejor relación entre esfuerzo y resultado, con diferencia. @deferdentro de una pantalla para bloques pesados y secundarios: un gráfico, un editor de texto enriquecido, un mapa. Se descarga cuando el bloque entra en el viewport o cuando el usuario interactúa.- Presupuestos ajustados para que la degradación se detecte en el pull request y no en producción seis meses después, y mucho cuidado con lo que pones en el inicializador de la aplicación: cada llamada que hagas ahí retrasa el primer pintado para todos los usuarios.
- Renderizado en servidor con hidratación si el tiempo hasta el primer contenido importa de verdad. Se añade con
ng add @angular/ssry se trata en el capítulo 7.
2.14 Errores comunes y cómo solucionarlos
| Error | Causa | Solución |
|---|---|---|
Node.js version vX.Y.Z detected. The Angular CLI requires a minimum... |
La versión de Node está fuera del rango que admite tu versión de Angular, o es una versión impar (no LTS) | Instala una versión LTS par admitida con nvm install 22 && nvm use 22, fíjala en un .nvmrc y en la imagen base de CI. Consulta el rango exacto en angular.dev/reference/versions |
ng: command not found · 'ng' no se reconoce como un comando |
No hay CLI global, o el directorio global de npm no está en el PATH |
Usa el CLI local: npx ng .... Si prefieres el global, npm install -g @angular/cli y añade $(npm config get prefix)/bin al PATH |
bundle initial exceeded maximum budget |
El bundle inicial supera el tope de budgets. Casi siempre por una librería pesada importada en el arranque |
Analiza con --stats-json, mueve lo pesado a carga diferida o a @defer, e importa solo lo que uses. Subir el umbral es el último recurso y hay que justificarlo |
NG0203: inject() must be called from an injection context |
Se ha llamado a inject() fuera de un contexto de inyección: dentro de un método, en una función suelta, en un setTimeout o tras un await. También aparece por dependencias circulares entre ficheros (barriles) |
Llama a inject() solo en la inicialización de campos de clase, en el constructor o en factorías. Si necesitas inyectar más tarde, captura el Injector y usa runInInjectionContext. Si el código parece correcto, busca un ciclo de importación |
| Errores absurdos tras actualizar: tipos que no cuadran, plantillas que fallan sin haber cambiado | Caché de compilación corrupta o desincronizada con las dependencias nuevas | ng cache clean. Si persiste, borra .angular/, node_modules/ y dist/, y reinstala con npm ci (que respeta el lock) en lugar de npm install |
The Angular Compiler requires TypeScript >=X.Y.Z and <A.B.C but X.Y.Z was found |
Mezcla de versiones: paquetes de Angular en versiones distintas entre sí, o TypeScript/RxJS fuera del rango que exige el core | Todos los paquetes @angular/* deben compartir versión mayor y menor. Revisa con npm ls @angular/core y corrige con ng update, nunca editando versiones a mano en el package.json |
Todo se rompe después de un npm install --force o --legacy-peer-deps |
Se han silenciado conflictos reales de peer dependencies: npm ha instalado un árbol que ninguna librería declara como compatible | Nunca uses --force para «arreglar» una instalación. Lee el conflicto que npm reporta y resuélvelo actualizando el paquete que va retrasado. Si una librería no admite tu Angular, la decisión es esperar, sustituirla o contribuir a ella |
| La actualización de versión mayor falla a mitad y deja el repositorio en un estado extraño | Se han saltado versiones intermedias, o había cambios sin confirmar, o una dependencia de terceros no admite la versión destino | Vuelve al estado anterior con git reset --hard, actualiza de una mayor en una mayor confirmando entre pasos, y sigue la lista concreta que genera update.angular.dev para tu par de versiones |
'app-tarjeta' is not a known element |
El componente standalone no está en el array imports de quien lo usa (o, en código heredado, el módulo no lo exporta) |
Añádelo a imports del componente consumidor. Si es un elemento web ajeno a Angular, añade CUSTOM_ELEMENTS_SCHEMA al componente |
| La aplicación arranca en blanco al desplegar en un subdirectorio, con 404 de los ficheros JavaScript | El <base href> apunta a la raíz pero la aplicación se sirve desde una subcarpeta |
Compila con ng build --base-href=/subcarpeta/ y configura el servidor para que devuelva el index.html en cualquier ruta desconocida (reescritura de SPA) |
Cannot access 'X' before initialization en el arranque |
Dependencia circular entre módulos, típicamente a través de un barrel file | Importa el fichero concreto en lugar del barril. Añade la regla import/no-cycle al linter para que el ciclo se detecte en el pull request |
JavaScript heap out of memory durante el build |
El proceso de Node agota su memoria: proyecto muy grande, mapas de fuentes activos o una biblioteca que genera código en exceso | Amplía el límite con NODE_OPTIONS=--max-old-space-size=8192 como medida inmediata, y después investiga qué ha crecido tanto con el análisis del bundle |
2.15 Buenas y malas prácticas
Haz esto
- Fija la versión de Node en un
.nvmrcy usa la misma imagen base en la integración continua que en tu máquina. - Usa siempre el CLI local del proyecto (
npx ng) y confirma elpackage-lock.jsonen el repositorio. - Ejecuta
ng generateen lugar de copiar y pegar ficheros: las convenciones se mantienen solas y no hay erratas en los selectores. - Configura los valores por defecto de los schematics en
angular.json(changeDetection: OnPush, estilo) para que nadie tenga que acordarse de las banderas. - Actualiza de una versión mayor en una versión mayor, con el repositorio limpio, confirmando entre pasos y revisando el diff de cada migración.
- Define presupuestos y trátalos como una prueba más: si fallan, se investiga la causa, no se sube el umbral.
- Organiza por funcionalidad desde el primer día y respeta la regla de dependencias
features → sharedyfeatures → core. - Usa alias de rutas (
@core/*,@shared/*) e importa el fichero concreto en lugar de un barril. - Activa
strictystrictTemplatesdesde el primer commit: retrofitarlos en una aplicación grande cuesta semanas. - Escribe la configuración global en
app.config.tscon funcionesprovide*, dejamain.tsreducido al arranque y ejecuta--dry-runla primera vez que uses un schematic que no conozcas.
Evita esto
- Depender de un CLI global antiguo mientras el proyecto va varias versiones por delante.
- Ejecutar
npm install --forceo--legacy-peer-depspara silenciar un conflicto en lugar de resolverlo. - Saltar versiones mayores al actualizar, o hacerlo con cambios sin confirmar.
- Editar a mano las versiones de
@angular/*en elpackage.json: acabarás con paquetes descoordinados entre sí. - Poner secretos en
environments/. Todo el bundle es público, sin excepciones. - Crear un
SharedModule—o unshared/index.ts— que lo exporte todo y acabe importado en todas partes. - Escribir
NgModulepara código nuevo cuando standalone es el estándar y la interoperabilidad está resuelta. - Meter librerías en
scriptsdeangular.jsonpara «evitar problemas de tipos»: pierdes tipado y tree shaking a la vez. - Cargar media aplicación en el inicializador: cada llamada ahí retrasa el primer pintado de todos los usuarios.
- Adoptar Nx, micro-frontends o una librería de estado «por si acaso», o copiar configuraciones de
angular.jsonde internet sin comprobar antes qué builder usa tu proyecto.
2.16 Preguntas frecuentes
¿Angular sigue siendo pesado y lento como se decía hace años?
¿Tengo que aprender RxJS para usar Angular hoy?
HttpClient devuelve observables, el router expone observables y todo lo que tiene dimensión temporal —debounce, cancelación, reintentos con retroceso, sondeo— se resuelve mejor con RxJS. Lo razonable es empezar por seis o siete operadores (map, filter, switchMap, catchError, debounceTime, takeUntilDestroyed) y ampliar cuando aparezca la necesidad. El capítulo 4 lo trata en detalle.Tengo una aplicación con NgModule que funciona. ¿Merece la pena migrar?
NgModule sigue soportado y no hay una fecha de retirada anunciada, así que nada se va a romper mañana. Dicho eso, las APIs nuevas del framework se diseñan pensando en standalone, y las tres migraciones oficiales hacen el noventa por ciento del trabajo automáticamente. La estrategia sensata es ejecutar la migración cuando toque una actualización de versión mayor, revisar el diff con calma y prohibir por convención escribir NgModule nuevos a partir de ese momento.¿Cuál es la diferencia entre ng add y npm install?
npm install descarga el paquete y lo apunta en package.json: nada más. ng add hace eso y además ejecuta el schematic de instalación que el paquete publica, que suele modificar angular.json, añadir estilos globales, registrar proveedores en app.config.ts o crear ficheros de configuración. Para cualquier paquete que ofrezca integración con el CLI —Angular Material, el service worker, el renderizado en servidor, ESLint— usa siempre ng add; te ahorra una configuración manual que es fácil dejar a medias.¿Qué es exactamente un builder y puedo escribir uno?
angular.json: recibe las opciones declaradas y realiza el trabajo (compilar, servir, probar, desplegar). Angular publica los suyos, pero la interfaz es pública y sí puedes escribir el tuyo, empaquetarlo como paquete de npm y referenciarlo en architect. Es lo que hacen los paquetes de despliegue y los que permiten inyectar configuración de webpack personalizada. En la práctica, la mayoría de los equipos nunca necesitan escribir uno: casi todo lo que se quiere hacer cabe en un script de npm antes o después del build.Mi ng build tarda cinco minutos. ¿Por dónde empiezo?
:browser), migrar al de esbuild (:application) suele ser la mayor ganancia individual disponible. Dos: si la caché está activa y si persiste entre ejecuciones de CI (ng cache info). Tres: los estilos, porque importar un fichero de variables grande en cada componente multiplica el trabajo de Sass por el número de componentes. Solo después de eso tiene sentido plantearse dividir el proyecto o adoptar Nx.¿Puedo usar Vite, webpack o Rollup directamente, sin el CLI de Angular?
ng update y quedarte fuera del camino que el equipo de Angular prueba en cada versión. El coste aparece siempre en la siguiente actualización mayor.¿Por qué el bundle crece tanto al añadir una librería de componentes?
¿Qué significa el prefijo ɵ que aparece en algunos símbolos de Angular?
¿Debo confirmar el package-lock.json en el repositorio?
npm ci en lugar de npm install: el primero instala exactamente lo que dice el lock y falla si el package.json se ha desincronizado, que es justo el comportamiento que quieres en una pipeline.¿Cómo elijo entre ng serve y compilar y servir con un servidor estático?
ng serve es para desarrollar: compila en memoria, recarga en caliente y aplica la configuración de desarrollo, sin optimizar. Nunca lo uses para servir nada real, ni siquiera una demostración interna, porque no está pensado como servidor de producción ni endurecido para ello. Para verificar cómo se comporta la compilación real, ejecuta ng build y sirve el contenido de dist/ con cualquier servidor estático configurando la reescritura hacia index.html; es la única forma de detectar antes del despliegue los problemas de base href, de rutas y de optimización.2.17 Ejercicios
2.1 Crea un proyecto nuevo con ng new usando SCSS, enrutado y sin renderizado en servidor. Antes de ejecutarlo de verdad, hazlo con --dry-run y anota la lista completa de ficheros que se van a crear. Después ábrelos uno a uno y escribe en una frase para qué sirve cada uno.
2.2 Genera con el CLI un componente, un servicio, un guard, un pipe y una interfaz respetando la estructura por funcionalidad de 2.11. Ejecuta ng version y comprueba si tu versión genera nombres con sufijo (.component.ts) o sin él.
2.3 Abre angular.json y localiza el builder de build, la configuración de producción, los presupuestos y el defaultConfiguration. Añade una configuración nueva llamada staging que optimice como producción pero conserve los mapas de fuentes, y compílala con ng build -c staging.
2.4 Configura los schematics del proyecto en angular.json para que todos los componentes se generen con OnPush y sin fichero de pruebas. Verifica con ng g c prueba --dry-run que el cambio surte efecto.
2.5 Provoca deliberadamente un fallo de presupuesto: instala una librería pesada, impórtala en el componente raíz y compila. Interpreta el mensaje de error distinguiendo raw size de estimated transfer size, y resuélvelo moviendo la librería detrás de una ruta con carga diferida. Documenta el tamaño del bundle inicial antes y después.
2.6 Configura alias de rutas (@core/*, @shared/*, @features/*) en tsconfig.json, reorganiza el proyecto según el árbol de 2.11 y sustituye todas las importaciones relativas con más de dos niveles de ../.
2.7 Escribe un app.config.ts completo con enrutado (incluyendo withComponentInputBinding), cliente HTTP con withFetch y un interceptor propio, animaciones diferidas y un ErrorHandler personalizado que distinga errores HTTP de errores de carga de chunk.
2.8 Implementa configuración en tiempo de ejecución: un config.json en public/, un servicio que lo cargue y un inicializador de aplicación que espere a la carga. Comprueba en la pestaña de red que la petición ocurre antes del primer pintado.
2.9 Ejecuta ng build --stats-json y analiza el resultado con la herramienta que corresponda a tu builder. Identifica las tres dependencias más pesadas del bundle inicial y propón una acción concreta para cada una.
2.10 Toma un proyecto pequeño basado en NgModule (puedes generarlo con una versión antigua del CLI o escribirlo a mano) y migra a standalone con las tres migraciones oficiales, confirmando en git entre paso y paso. Escribe un informe con lo que reescribió cada migración y lo que tuviste que arreglar a mano.
2.11 Crea deliberadamente una dependencia circular a través de un barrel file hasta reproducir un NG0203 o un Cannot access 'X' before initialization. Después elimina el barril, añade la regla import/no-cycle al linter y verifica que el ciclo se detecta ahora en el análisis estático.
2.12 Monta la compilación del proyecto en una pipeline de integración continua con npm ci, caché persistente de .angular/cache y presupuestos como criterio de fallo. Mide el tiempo de compilación con y sin caché y documenta la diferencia.
2.13 Compara la salida de ng build con el builder de webpack (:browser) y con el de esbuild (:application) sobre el mismo proyecto: tiempo de compilación, número de chunks y tamaño total. Explica a qué se deben las diferencias que observes.
Soluciones comentadas (2.3 y 2.8)
2.3 · Una configuración staging. La clave es entender que options es la base común y que cada entrada de configurations la sobrescribe parcialmente. Hay que tocar dos targets: el de build, que define la configuración, y el de serve, que la referencia.
"build": {
"configurations": {
"staging": {
"budgets": [
{ "type": "initial", "maximumWarning": "700kB", "maximumError": "1.5MB" }
],
"fileReplacements": [
{ "replace": "src/environments/environment.ts",
"with": "src/environments/environment.staging.ts" }
],
"optimization": true,
"outputHashing": "all",
"sourceMap": { "scripts": true, "styles": true, "hidden": false }
}
}
},
"serve": {
"configurations": {
"staging": { "buildTarget": "gestor-tareas:build:staging" }
}
}
Compruébalo con ng build -c staging y con la forma larga ng run gestor-tareas:build:staging. Los mapas de fuentes visibles permiten depurar en preproducción con las pilas de llamadas reales; en producción conviene hidden: true, que los genera pero no los referencia desde el bundle, de modo que solo tu servicio de monitorización los usa.
2.8 · Configuración en tiempo de ejecución. Los tres puntos que suelen fallar son el orden de arranque, el tipado y qué ocurre si la petición falla.
// public/config.json ← se copia sin procesar a dist/
// { "apiUrl": "https://api.empresa.com/v1", "entorno": "produccion",
// "funcionesActivas": ["facturacion-v2"] }
// app.config.ts
providers: [
provideHttpClient(withFetch()),
// El inicializador se ejecuta ANTES de crear el componente raíz.
// Angular espera a la promesa: nada se pinta hasta que resuelve.
provideAppInitializer(() => inject(ConfiguracionService).cargar()),
],
// configuracion.service.ts (fragmento clave)
async cargar(): Promise<void> {
try {
const c = await firstValueFrom(
this.http.get<ConfiguracionApp>('/config.json'),
);
this._config.set(c);
} catch {
// DECISIÓN DE DISEÑO: si la configuración no carga, la aplicación
// no puede funcionar. Es preferible fallar de forma ruidosa y
// explícita que arrancar con una apiUrl 'undefined' y producir
// cien errores incomprensibles en cascada.
throw new Error('No se pudo cargar /config.json');
}
}
// Verificación: en la pestaña de red, /config.json debe aparecer
// ANTES de cualquier otra llamada a la API y antes del primer
// pintado. Si ves peticiones de negocio previas, algún servicio
// se está instanciando fuera del inicializador.
Nota de rendimiento: esta técnica añade una ida y vuelta a la red antes del primer pintado. Mantén el fichero pequeño, sírvelo desde el mismo dominio para evitar una resolución DNS adicional y cachéalo con una política corta que puedas invalidar en cada despliegue.
2.18 Resumen del capítulo
- Angular se entiende por su historia. Los problemas de AngularJS —
$scope, digest cycle y comprobación sucia proporcional al tamaño de la pantalla— explican la reescritura de 2016, y esta explica el ciclo semestral, las migraciones automáticas y la obsesión por el tooling. - Es un framework completo y opinado. Comparado honestamente, no compite con React sino con React más media docena de librerías. Su ventaja es la homogeneidad a escala; su desventaja, la ceremonia en proyectos pequeños.
- Angular es un compilador. Con AOT por defecto, las plantillas se traducen a funciones de renderizado con dos fases, creación y actualización, y las instrucciones se importan una a una: por eso el bundle depende de qué usas, no de qué tiene el framework.
- Ivy trajo la localidad: compilar un componente no requiere información global. De ahí salen la compilación incremental, el tree shaking real y las librerías precompiladas.
- El incremental DOM no construye un árbol intermedio, a diferencia del virtual DOM: menos memoria, menos presión sobre el recolector de basura y código de renderizado podable, a cambio de menos flexibilidad en la plantilla.
- El CLI es el centro de gravedad del proyecto: genera, compila, sirve, prueba y —lo más valioso— actualiza reescribiendo tu código con migraciones conscientes del árbol de sintaxis.
angular.jsondescribe cómo se construye tu producto. Proyectos, targets, builders, configurations y presupuestos. Los presupuestos son la única defensa automática contra la degradación progresiva del bundle.- El arranque moderno es
bootstrapApplicationcon unApplicationConfigde funcionesprovide*: una lista plana, analizable por el empaquetador y reutilizable en pruebas y en renderizado en servidor. - Standalone es el estándar. Los
NgModulemezclaban tres responsabilidades y añadían indirección; la migración es automática en tres pasos y la interoperabilidad entre ambos mundos es bidireccional. - Organiza por funcionalidad, no por tipo, con una regla de dependencias explícita, alias de rutas en lugar de barriles, y Nx solo cuando tengas varias aplicaciones o una integración continua insostenible.
- En el frontend no hay secretos. Todo lo que compilas es público; los secretos viven en el backend, sin excepciones.
2.19 Recursos adicionales
- angular.dev — la documentación oficial actual. Sustituye a
angular.io, que documenta versiones antiguas: comprueba siempre en qué sitio estás antes de seguir un tutorial. - angular.dev/cli — referencia completa del CLI, mandato a mandato, con todas las opciones de
ng new,ng generate,ng buildyng update. - update.angular.dev — la guía interactiva de actualización: eliges versión de origen y de destino y genera la lista exacta de pasos y de cambios incompatibles. Consúltala antes de cada actualización mayor.
- Angular · Compatibilidad de versiones — la tabla que dice qué versiones de Node, TypeScript y RxJS admite cada versión de Angular, y el calendario de soporte a largo plazo.
- Angular · Configuración del espacio de trabajo — la referencia exhaustiva de todas las claves de
angular.json. - Blog oficial de Angular — los anuncios de cada versión mayor, con las razones de diseño detrás de cada cambio. La mejor fuente para entender hacia dónde va el framework.
- nx.dev — documentación de Nx: monorepos, grafo de dependencias, caché de tareas y reglas de límites entre módulos.
- esbuild · Bundle Size Analyzer — analizador oficial del metafile que genera
ng build --stats-jsoncon el builder moderno. - Angular · CHANGELOG — el detalle exacto de qué cambió en cada versión, incluidos los cambios incompatibles y las migraciones asociadas.