Parte 2: Angular¶
Esta página es una parte de la guía de estudio; allí están el
mapa general y la ruta recomendada. La Parte 1, sobre TypeScript, está en
01-typescript-intro. Proyecto: 02-bases/.
Desde aquí la guía sigue el curso de Angular. Los ejemplos están escritos y compilados con Angular 22, la versión actual. Las diapositivas del curso muestran Angular 20: donde algo cambió, la guía lo dice.
Qué es Angular¶
Angular es un framework opinionado: ya decidió por ti cómo se enruta, cómo se hacen los formularios, cómo se pide información a una API y cómo se organiza el código. Eso promueve orden y legibilidad, porque dos proyectos de Angular se parecen mucho entre sí.
Con los años se volvió más flexible. Antes todo componente tenía que
declararse dentro de un módulo (NgModule); hoy los componentes son
standalone: cada uno declara lo que necesita y los módulos son opcionales.
Decoradores¶
Un decorador es una etiqueta que empieza con @ y se pone encima de una
clase (o de una propiedad) para decirle a Angular qué es y cómo usarla. La
clase sola es TypeScript normal; el decorador es lo que la convierte en
componente, servicio o directiva.
@Component({
selector: 'app-hero',
template: `<h1>{{ name }}</h1>`
})
export class Hero {
name = 'Geralt'
}
Sin @Component, Hero es una clase cualquiera. Con él, Angular sabe que es
una pieza de pantalla, con qué etiqueta HTML se usa (<app-hero>) y qué
dibuja.
| Decorador | Qué le dice a Angular |
|---|---|
@Component |
Es una pieza de pantalla, con su template |
@Directive |
Agrega comportamiento a un elemento que ya existe |
@Pipe |
Transforma un valor para mostrarlo ({{ price \| currency }}) |
@Injectable |
Es un servicio: Angular puede crearlo y entregarlo a quien lo pida |
@Input / @Output |
Datos que entran al componente / eventos que salen |
Por qué ayudan a leer el código: abres un archivo y, con sólo ver el decorador, ya sabes qué papel cumple esa clase.
Por dentro, un decorador es una función que recibe la clase y le adjunta información (metadata). Angular lee esa información al compilar.
Ojo: en el Angular actual varios decoradores de propiedad tienen un reemplazo
en forma de función (input() y output() en lugar de @Input y @Output).
Los de clase (@Component, @Injectable, @Directive, @Pipe) se siguen
usando igual.
Mitos sobre Angular¶
Antes de empezar conviene despejar algunas ideas que se repiten mucho y que no son ciertas, o que ya dejaron de serlo.
Mito 1: «Angular es mejor (o peor) que React, Vue, Svelte o Solid»¶
Realidad: no, son herramientas distintas. Angular es un framework opinionado: ya trae resuelto el enrutado, los formularios y las peticiones HTTP, y te dice cómo organizar el código. React es una librería, y Vue, Svelte y Solid son frameworks más livianos: dan más libertad, pero dejan más decisiones en manos del equipo. Ninguno es mejor en general; depende de lo que necesite tu proyecto.
Ojo: la herramienta no garantiza el orden. La estructura de carpetas y la disciplina dependen del equipo, y también hay proyectos de Angular desordenados.
Mito 2: «Hay que volver a aprender Angular cada 6 meses»¶
Realidad: no. Angular publica una versión mayor cada seis meses, pero la
mayoría de los cambios no son disruptivos. Y cuando lo son, no hay que
aprender de nuevo todo el framework: lo que ya sabes sigue sirviendo. Además,
ng update hace buena parte de la migración por ti, la documentación oficial
es muy buena y la comunidad es grande, así que ponerse al día cuesta poco.
Mito 3: «Las aplicaciones de Angular son muy pesadas»¶
Realidad: cada versión reduce el bundle (lo que el navegador tiene que descargar). Hoy el peso depende sobre todo de ti y de las buenas prácticas que apliques.
Para que pese menos:
- Lazy loading de rutas. Cada pantalla se descarga sólo cuando alguien
la visita, en lugar de bajar todo al entrar:
{ path: 'heroes', loadComponent: () => import('./heroes/heroes').then(m => m.Heroes) }. @deferen el template. Lo mismo, pero para un pedazo de pantalla: un gráfico pesado se descarga recién cuando aparece en el área visible.- Cuidar las dependencias. Cada librería que instalas viaja al navegador. Importa sólo lo que usas y desconfía de las librerías enormes para tareas pequeñas.
- Componentes compartidos. Un botón o una tarjeta se escriben una vez y se reutilizan; copiar y pegar el mismo código en diez pantallas lo descarga diez veces.
- Budgets. En
angular.jsonse define un tamaño máximo; si el build lo supera, avisa o falla. Así el peso no crece sin que nadie lo note.
Para que no se vuelva lenta con el uso (no cambia el peso, sí la experiencia):
- Cerrar las suscripciones. Una suscripción abierta sigue trabajando
aunque el componente ya no esté en pantalla, y la memoria nunca se libera.
Se evita con el pipe
asyncen el template o contakeUntilDestroyed(). - Respetar el ciclo de vida. Lo que el componente abre (un temporizador,
un listener) lo cierra al destruirse (
ngOnDestroyoDestroyRef). - Signals u
OnPush. Angular revisa sólo los componentes cuyos datos cambiaron, en lugar de revisar todos. tracken@for. Al cambiar una lista, Angular reutiliza los elementos que ya estaban en pantalla en lugar de dibujarlos de nuevo.
Mito 4: «Angular no es SEO friendly»¶
Realidad: sí lo es. Angular puede armar la página en el servidor (SSR) o dejarla armada de antemano al compilar (prerender). En los dos casos Google recibe HTML con el contenido ya puesto, no una página vacía que depende de JavaScript. Después, en el navegador, Angular «hidrata» esa página: la vuelve interactiva sin dibujarla otra vez.
De dónde viene el mito. Una aplicación clásica de Angular se dibuja en el
navegador (CSR: client-side rendering, «renderizado del lado del cliente»). El servidor manda un HTML casi
vacío, apenas <app-root></app-root> y los scripts, y el contenido aparece
recién cuando el JavaScript se descarga y corre. Un robot que lee ese HTML sin
ejecutar JavaScript no ve nada. Google sí ejecuta JavaScript, pero lo hace más
tarde y con límites; las vistas previas de redes sociales y apps de mensajería
directamente no lo ejecutan.
Las tres formas de dibujar la página:
CSR servidor manda HTML vacío ------> el navegador baja el JS -> el JS dibuja la página
SSR servidor dibuja en cada visita -> manda HTML completo ----> hidratación
Prerender se dibuja una vez al compilar --> se guarda como .html ---> hidratación
- SSR (server-side rendering, «renderizado del lado del servidor»): sirve cuando el contenido cambia seguido o depende de quién visita (una tienda, un perfil público).
- Prerender (también llamado SSG): sirve cuando el contenido casi no cambia (un blog, una página de presentación). Es lo más rápido, porque el HTML ya está hecho.
- CSR: alcanza para pantallas detrás de un login, donde el SEO no importa.
Qué es la hidratación. El HTML que llega del servidor se ve, pero todavía no responde: los botones no hacen nada. Hidratar es el momento en que Angular, ya en el navegador, reconoce ese HTML que está en pantalla y le conecta los eventos y el estado. No lo borra ni lo dibuja otra vez; antes de la versión 16 sí lo hacía, y la página parpadeaba.
Un beneficio extra. Como el contenido llega ya dibujado, la página se ve antes. Google también mide esa velocidad para posicionar.
Ojo: hay que activarlo (ng new --ssr al crear el proyecto, o
ng add @angular/ssr después). Sin eso, la aplicación se dibuja sólo en el
navegador y el HTML inicial llega casi vacío.
Mito 5: «Angular no soporta Redux»¶
Realidad: sí lo soporta. Redux es una forma de guardar todo el estado de la aplicación en un solo lugar, y en Angular se usa con librerías como NgRx o NGXS. Además, hoy Angular trae Signals, una opción nativa y más simple para manejar estado, que alcanza para muchas aplicaciones.
Mito 6: «En Angular sólo puedo usar TypeScript»¶
Realidad: también puedes usar librerías y código JavaScript, porque al final TypeScript se convierte en JavaScript para correr en el navegador.
- Si una librería JavaScript no trae tipos, se instalan aparte con
@types/nombre-libreria. Hoy la mayoría ya los incluye. - Puedes escribir archivos
.jspropios activandoallowJsentsconfig.json.
Es posible, pero se recomienda TypeScript: sin tipos pierdes el aviso de errores antes de ejecutar (ver la Introducción de la Parte 1).
Resumen¶
Angular no es mejor ni peor que las demás opciones, no hay que reaprenderlo cada seis meses, no es pesado por obligación, sí sirve para SEO, sí soporta Redux y sí acepta JavaScript. Casi todos estos mitos vienen de versiones antiguas del framework.
Módulo A1: Angular como framework y sus bloques fundamentales¶
Fuente: diapositivas del curso, sección 1 de Angular.
El problema que resuelve¶
Con TypeScript solo (Parte 1) puedes escribir lógica, pero una aplicación real necesita mucho más: dibujar pantallas que se actualizan solas, cambiar de página, pedir datos a un servidor, compartir información entre pantallas. Si cada equipo arma eso a su manera, juntando librerías sueltas, cada proyecto termina siendo distinto. Angular trae todas esas piezas y una forma acordada de usarlas.
Un framework: todo viene en la caja¶
Una librería es una herramienta que llamas cuando la necesitas (por ejemplo, una para formatear fechas). Un framework es la estructura completa de la casa: tú pones el contenido, pero él decide dónde va cada cosa y cuándo se ejecuta.
Angular es un framework "con baterías incluidas":
| Necesidad | Qué trae Angular | Paquete |
|---|---|---|
| Reactividad | Signals: valores que avisan cuando cambian | @angular/core |
| Gestor de estado | Servicios con signals (sin librería extra) | @angular/core |
| Enrutamiento | Cambiar de página sin recargar el navegador | @angular/router |
| Peticiones HTTP | HttpClient para hablar con un servidor |
@angular/common |
| Directivas | Cambiar el comportamiento de elementos HTML | @angular/core |
| Formularios | Validación y manejo de formularios | @angular/forms |
| Herramientas | Crear, ejecutar, probar y actualizar el proyecto | @angular/cli |
Comparación: React es una librería para dibujar la interfaz; para rutas, estado o formularios eliges otras librerías. Angular ya trae una opción oficial para cada cosa. Ni uno ni otro es mejor en general: ver Mitos sobre Angular.
Multiplataforma¶
Con el mismo conocimiento puedes llegar a varias plataformas:
Web: tres formas de entregar la página.
| Sigla | Nombre | Quién arma el HTML | Cuándo conviene |
|---|---|---|---|
| SPA | Aplicación de una sola página (Single Page App) | El navegador, con JavaScript | Paneles internos, apps detrás de un login |
| SSR | Renderizado en el servidor (Server-Side Rendering) | El servidor, en cada visita | Páginas públicas que deben aparecer en buscadores |
| SSG | Generación estática (Static Site Generation) | El build, una sola vez | Contenido que casi no cambia: blog, documentación |
- SPA: el servidor envía un HTML casi vacío y el JavaScript dibuja todo.
Es la opción por defecto de
ng new. - SSR: el usuario recibe la página ya armada, así que ve contenido antes. Después Angular "despierta" esa página para que responda a clics (hidratación).
- SSG (en Angular se llama prerender): el HTML se genera al hacer el build y se sirve como un archivo fijo, muy rápido.
Se pueden combinar: cada ruta elige su forma (ver Rutas).
Móvil y escritorio:
| Plataforma | Herramienta | Cómo funciona |
|---|---|---|
| Móvil | Ionic | Tu app web corre dentro de una app nativa, con componentes móviles |
| Móvil | NativeScript | Usa los controles nativos del teléfono en lugar de HTML |
| Escritorio | Electron | Empaqueta tu app web con un navegador propio, como VS Code |
Versiones: una mayor cada seis meses¶
Angular publica una versión mayor (20, 21, 22…) más o menos cada seis meses:
| Versión | Publicada |
|---|---|
| 20 | mayo de 2025 |
| 21 | noviembre 2025 |
| 22 | junio de 2026 |
- Todos los paquetes
@angular/*llevan el mismo número. Por eso la diapositiva marca todo con 20.0.0: router, HTTP, formularios y núcleo avanzan juntos. - El número tiene tres partes:
22.2.1= mayor (puede romper cosas), menor (agrega cosas sin romper), parche (corrige errores). - Cada versión mayor recibe soporte unos 18 meses: seis de desarrollo activo y doce de soporte a largo plazo (LTS).
- Lo viejo no desaparece de golpe: primero se marca como obsoleto
(deprecated) y se elimina varias versiones después. Por ejemplo,
*ngIfquedó obsoleto en la 20 y en la 22 todavía existe. - Para actualizar:
ng update @angular/core @angular/cli, de una versión mayor por vez, siguiendo la guía oficial (angular.dev/update-guide).
Qué cambió entre la versión de las diapositivas (20) y la actual (22), visto
en un proyecto nuevo de ng new:
- Ya no usa
zone.jspara detectar cambios: se apoya en los signals (explicado en Zoneless). - Los archivos no llevan sufijo:
app.tsen lugar deapp.component.ts. - Las pruebas usan Vitest en lugar de Karma.
Los seis bloques fundamentales¶
┌─────────────┬──────────┐
│ Componentes │ Rutas │ lo que ves y cómo navegas
├─────────────┼──────────┤
│ Directivas │ Servicios│ comportamiento y lógica compartida
├─────────────┼──────────┤
│ Módulos │ Pipes │ organización y formato de datos
└─────────────┴──────────┘
Componentes¶
Un componente es una pieza de la pantalla. Tiene tres partes:
| Parte | Qué es | Obligatoria |
|---|---|---|
| Lógica (TS) | La clase: datos y métodos | Sí |
| Plantilla (HTML) | Lo que se dibuja | Sí |
| Estilos (CSS, SCSS) | Cómo se ve; sólo afectan a este componente | No |
import { Component, input, output } from '@angular/core'
import { UpperCasePipe } from '@angular/common'
import type { Hero } from './hero'
@Component({
selector: 'app-hero-card',
imports: [UpperCasePipe],
template: `
<article>
<h3>{{ hero().name | uppercase }}</h3>
<p>Poder: {{ hero().power }}</p>
<button (click)="removed.emit(hero().id)">Eliminar</button>
</article>
`,
})
export class HeroCard {
readonly hero = input.required<Hero>()
readonly removed = output<number>()
}
@Componentle dice a Angular que la clase es una pieza de pantalla (Módulo 10).selector: 'app-hero-card'es la etiqueta con la que se usa:<app-hero-card />.input.required<Hero>(): el dato que entra desde el componente padre.output<number>(): el aviso que sale hacia el padre.- La plantilla puede ir en el mismo archivo (
template) o en uno aparte (templateUrl: './hero-card.html'), igual que los estilos.
De lo micro a lo macro. Una aplicación es un árbol de componentes. Los pequeños se combinan en otros más grandes, hasta formar una página:
App ← la aplicación entera
└── HeroesPage ← una página (la elige la ruta)
├── SearchBox ← una sección
└── HeroList
└── HeroCard (×N) ← una tarjeta, repetida por cada héroe
└── button ← un elemento HTML
Es composición (Módulo 8): cada componente tiene otros adentro.
Componentes inteligentes y presentacionales (smart y dumb).
| Tipo | Qué hace | Ejemplo |
|---|---|---|
| Inteligente (smart, contenedor) | Sabe de dónde salen los datos: usa servicios, decide qué hacer | HeroesPage |
| Presentacional (dumb) | Sólo muestra lo que recibe y avisa lo que pasa | HeroCard |
@Component({
selector: 'app-heroes-page',
imports: [HeroCard],
template: `
@for (hero of store.all(); track hero.id) {
<app-hero-card [hero]="hero" (removed)="store.remove($event)" />
}
`,
})
export class HeroesPage {
protected readonly store = inject(HeroStore)
}
HeroCard no sabe que existe HeroStore: recibe un héroe y avisa "me
pidieron eliminar el 2". HeroesPage decide qué hacer con ese aviso. Ventajas:
HeroCard se puede reutilizar en otra pantalla y probar sin servicios. La
regla práctica: pocas piezas inteligentes arriba (normalmente las páginas) y
muchas presentacionales abajo.
Rutas¶
Las rutas relacionan una dirección (/heroes) con un componente. Al
cambiar de ruta, Angular cambia la página sin recargar el navegador.
// app.routes.ts
export const routes: Routes = [
{ path: '', component: HomePage },
{
path: 'heroes',
loadComponent: () => import('./heroes/heroes-page').then(m => m.HeroesPage),
canActivate: [authGuard],
},
{ path: '**', redirectTo: '' },
]
<!-- app.html: aquí se dibuja la página de la ruta actual -->
<nav><a routerLink="/heroes">Héroes</a></nav>
<router-outlet />
Lo que dicen las diapositivas, en el Angular actual:
- Separar lógica: cada página es su propio componente, y con
loadComponentsu código se descarga sólo cuando alguien entra a esa ruta (carga bajo demanda, lazy loading). En el build se ve como un archivo aparte. - Control de acceso: un guard decide si se puede entrar. Hoy es una función simple:
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService)
const router = inject(Router)
return auth.isLoggedIn() ? true : router.parseUrl('/login')
}
Ojo: un guard es comodidad para el usuario, no seguridad. El código
llega al navegador igual; los datos se protegen en el servidor.
- Estrategias de renderizado: con SSR activo, cada ruta puede elegir si se
arma en el servidor, se genera en el build o se arma en el navegador
(RenderMode.Server, RenderMode.Prerender, RenderMode.Client, en el
archivo app.routes.server.ts).
Legacy: antes los guards eran clases que implementaban
CanActivate, y la carga bajo demanda se hacía con módulos (loadChildrenapuntando a unNgModule). Hoy se usan funciones yloadComponent.
Directivas¶
Una directiva cambia el comportamiento o el aspecto de un elemento HTML. Hay tres tipos:
| Tipo | Qué hace | Hoy se usa | Legacy |
|---|---|---|---|
| Componente | Una directiva con su propia plantilla | @Component |
— |
| Atributo | Cambia aspecto o comportamiento | [class.x], [style.x], directivas propias |
ngClass, ngStyle (siguen válidas) |
| Estructural | Agrega o quita elementos de la página | Control flow: @if, @for, @switch |
*ngIf, *ngFor, *ngSwitch (obsoletas) |
Control flow: lo nuevo. Desde Angular 17 la plantilla tiene bloques propios, sin importar nada:
@if (store.total() === 0) {
<p>No hay héroes.</p>
} @else {
<p>Hay {{ store.total() }} héroes.</p>
}
@for (hero of store.all(); track hero.id) {
<app-hero-card [hero]="hero" />
} @empty {
<p>Lista vacía.</p>
}
track hero.ides obligatorio: le dice a Angular cómo reconocer cada elemento para no redibujar toda la lista cuando algo cambia.@emptymuestra algo cuando la lista está vacía.@switchelige entre varios casos, y@defercarga una parte de la página más tarde (por ejemplo, cuando aparece en pantalla).
Legacy:
*ngIf,*ngFory*ngSwitchestán marcadas como obsoletas desde Angular 20. Las vas a ver en proyectos existentes:<li *ngFor="let hero of heroes">. En código nuevo, usa@ify@for. Angular trae una migración automática:ng generate @angular/core:control-flow.
Atributo: clases y estilos. Para una sola clase o estilo, la forma recomendada es el enlace directo:
<small [class.strong]="hero.power > 90">…</small>
<div [style.width.px]="size">…</div>
ngClass y ngStyle siguen funcionando y no están obsoletas, pero rara vez
hacen falta.
Directiva propia:
@Directive({
selector: '[appHighlight]',
host: {
'[style.backgroundColor]': 'color()',
},
})
export class Highlight {
readonly color = input('yellow', { alias: 'appHighlight' })
}
<h2 appHighlight="lightyellow">Héroes</h2>
host conecta propiedades del elemento con la directiva. Antes se hacía con
los decoradores @HostBinding y @HostListener (legacy).
Servicios¶
Un servicio es una clase que guarda lógica o datos que varios componentes necesitan. Los componentes se ocupan de mostrar; los servicios, de saber.
| Para qué | Ejemplo |
|---|---|
| Gestión de datos | Guardar la lista de héroes y modificarla |
| Reutilizar código | Una sola función de formato usada en diez pantallas |
| Inyección de dependencias | Angular crea el servicio y se lo entrega a quien lo pida |
Inyección de dependencias: el componente no hace new HeroStore(). Lo
pide con inject(HeroStore) y Angular se lo entrega. Con
providedIn: 'root' hay una sola instancia para toda la aplicación, así
que todos los componentes ven los mismos datos.
Gestión de estado con un servicio y signals:
@Injectable({ providedIn: 'root' })
export class HeroStore {
private readonly heroes = signal<Hero[]>([
{ id: 1, name: 'Geralt', power: 90 },
{ id: 2, name: 'Ciri', power: 95 },
])
readonly all = this.heroes.asReadonly()
readonly total = computed(() => this.heroes().length)
add(name: string) {
this.heroes.update(list => [...list, { id: Date.now(), name, power: 50 }])
}
remove(id: number) {
this.heroes.update(list => list.filter(hero => hero.id !== id))
}
}
- El signal que se puede modificar es privado. Afuera sólo se expone una
versión de lectura (
asReadonly()). - Los cambios pasan sólo por métodos (
add,remove). Así hay un solo lugar donde mirar cuando algo cambia mal. Es encapsulación (Módulo 8). computedcalcula valores derivados (el total) y se actualiza solo.updatecrea un array nuevo en vez de modificar el viejo. Angular detecta el cambio porque la referencia es distinta (ver Pipes).
Para casos simples alcanza con esto. Cuando el estado crece mucho, existen
librerías como NgRx (con su SignalStore), que siguen la misma idea con más
reglas.
Fachada (facade). Cuando una pantalla necesita hablar con varios servicios, una fachada los reúne detrás de una sola clase:
@Injectable({ providedIn: 'root' })
export class HeroesFacade {
private readonly store = inject(HeroStore)
private readonly notifications = inject(Notifications)
readonly heroes = this.store.all
readonly total = this.store.total
recruit(name: string) {
this.store.add(name)
this.notifications.show(`${name} se unió al grupo`)
}
}
El componente sólo conoce HeroesFacade y sus acciones con nombre de negocio
(recruit). Si mañana los héroes vienen de un servidor, cambia la fachada y no
los componentes. Es como la recepción de un hotel: pides "una habitación" y no
necesitas hablar con limpieza, cocina y reservas por separado.
Módulos¶
Un NgModule agrupaba piezas relacionadas (componentes, directivas,
pipes) y decía qué se podía usar fuera:
@NgModule({
declarations: [OldList],
imports: [CommonModule],
exports: [OldList],
})
export class LegacyModule {}
Las diapositivas le dan tres funciones: organizar la aplicación, encapsular dependencias y facilitar la carga bajo demanda. Hoy las tres se resuelven de otra forma:
| Función | Con NgModule (legacy) |
Hoy, con standalone |
|---|---|---|
| Organizar | Un módulo por funcionalidad | Carpetas por funcionalidad |
| Encapsular dependencias | imports y exports del módulo |
Cada componente declara sus imports |
| Carga bajo demanda | loadChildren con un módulo |
loadComponent o loadChildren con un archivo de rutas |
| Arrancar la app | AppModule y bootstrapModule |
bootstrapApplication(App, appConfig) |
Componentes standalone. Cada componente dice qué necesita en su propio
imports, sin un módulo intermedio. Es más fácil de leer: abres el archivo y
ves todas sus dependencias. Desde Angular 19, todo componente es standalone
por defecto; para usar el estilo antiguo hay que escribir
standalone: false.
Legacy: vas a encontrar
NgModuleen proyectos existentes. Los componentes standalone pueden importar unNgModuley al revés, así que se migra de a poco. Angular trae una migración:ng generate @angular/core:standalone.
No confundas estos módulos con los módulos de JavaScript del Módulo 7
(import/export entre archivos). Esos siguen siendo la base de todo; lo que
pasó de moda es NgModule.
Pipes¶
Un pipe transforma un valor sólo para mostrarlo, sin cambiar el dato
original. Se escribe con | en la plantilla:
{{ hero.name | uppercase }} <!-- GERALT -->
{{ price | currency: 'EUR' }} <!-- €1,500.00 -->
{{ today | date: 'dd/MM/yyyy' }} <!-- 06/10/2026 -->
{{ ratio | percent }} <!-- 75% -->
Otros que vienen con Angular: lowercase, titlecase, number, json,
slice, keyvalue y async.
Pipe propio:
@Pipe({ name: 'powerLevel' })
export class PowerLevelPipe implements PipeTransform {
transform(power: number): string {
if (power >= 90) return 'Legendario'
if (power >= 70) return 'Fuerte'
return 'Normal'
}
}
{{ hero.power | powerLevel }} <!-- Legendario -->
Puros e impuros:
| Tipo | Cuándo se vuelve a ejecutar | Costo |
|---|---|---|
| Puro (por defecto) | Sólo si el valor de entrada cambia | Bajo |
Impuro (pure: false) |
En cada revisión de cambios de la página | Alto |
Un pipe puro recuerda el último resultado: si recibe el mismo valor, no recalcula. Eso es la "optimización de rendimiento" de la diapositiva.
La trampa: con un array u objeto, "cambiar" significa ser otro array. Si
haces heroes.push(nuevo), es el mismo array con un elemento más, y un pipe
puro no se entera. Por eso los servicios usan
update(list => [...list, nuevo]): crea un array nuevo.
Un pipe impuro sí se entera de un push, pero se ejecuta muchísimas veces.
Ordenar y filtrar. La diapositiva lo menciona, pero hacerlo con pipes es
una mala costumbre: necesitarían ser impuros para ver cambios internos y se
ejecutarían todo el tiempo. Angular no trae pipes para filtrar u ordenar justo
por eso. Lo recomendado es un computed, que sólo recalcula cuando cambia la
lista:
protected readonly sorted = computed(() =>
[...this.facade.heroes()].sort((a, b) => a.name.localeCompare(b.name)),
)
Usa pipes para formatear (fechas, monedas, textos) y computed para
ordenar, filtrar o calcular.
Errores comunes¶
- Poner lógica de negocio en los componentes en vez de en servicios.
- Hacer que un componente presentacional inyecte servicios: deja de ser reutilizable.
- Escribir
*ngIfo*ngForen código nuevo. Usa@ify@for. - Olvidar
tracken@for, o usartrack $indexcuando la lista cambia de orden: Angular redibuja de más. - Exponer el signal modificable de un servicio: cualquier componente puede cambiar el estado sin pasar por los métodos.
- Modificar un array con
pushy esperar que un pipe puro o uncomputedse actualice. - Confiar en un guard como seguridad: los datos se protegen en el servidor.
- Confundir
NgModulecon los módulos de JavaScript.
Resumen¶
Angular es un framework: trae reactividad, rutas, HTTP, formularios y
herramientas, y saca una versión mayor cada seis meses con todos sus paquetes
en el mismo número. Se construye con seis bloques. Los componentes dibujan
la pantalla y se combinan de lo pequeño a lo grande; los inteligentes usan
servicios y los presentacionales sólo reciben y avisan. Las rutas cambian
de página, cargan código bajo demanda y controlan el acceso. Las
directivas cambian elementos; el control flow (@if, @for) reemplaza
a *ngIf y *ngFor. Los servicios guardan lógica y estado, con
signals y, si hace falta, una fachada. Los NgModule quedaron como
legacy frente a los componentes standalone. Los pipes formatean valores;
para ordenar o filtrar, mejor computed.
Preguntas de entrevista (senior)¶
1. ¿SPA, SSR o SSG? ¿Cómo decides?¶
Respuesta:
Según quién necesita ver la página y qué tan seguido cambia. Una app detrás de
un login (un panel de administración) no necesita aparecer en buscadores: SPA
es lo más simple. Páginas públicas con datos que cambian (un catálogo con
precios) se benefician de SSR: el usuario y los buscadores reciben contenido
de inmediato. Contenido que casi no cambia (documentación, una landing) va
mejor con SSG (prerender): se genera una vez y se sirve como archivo fijo,
sin costo de servidor por visita. En Angular no hay que elegir para toda la
app: cada ruta define su forma. El costo de SSR es operar un servidor y
cuidar el código que sólo funciona en el navegador (window, localStorage).
2. ¿Por qué separar componentes inteligentes y presentacionales?¶
Respuesta:
Porque separa qué se muestra de de dónde salen los datos. Un
componente presentacional recibe datos por input() y avisa por output():
se puede reutilizar en otra pantalla, probar pasándole datos de ejemplo, y
mostrar en un catálogo de componentes sin levantar servicios. El inteligente
concentra la conexión con servicios, así que cuando cambia la fuente de datos
se toca un solo lugar. No es una regla absoluta: un componente que sólo se usa
una vez y es pequeño puede inyectar un servicio sin problema. El riesgo
contrario es pasar datos por cinco niveles de input(); ahí conviene que un
componente intermedio inyecte el servicio.
3. ¿Qué ganas al pasar de *ngIf/*ngFor a @if/@for?¶
Respuesta:
Varias cosas. No hay que importar nada: los bloques son parte de la plantilla.
@for exige track, así que nadie olvida decirle a Angular cómo reconocer
cada elemento, que era un problema de rendimiento frecuente con *ngFor.
Trae @empty y @else sin trucos con ng-template. Funciona mejor con los
tipos: dentro de @if (hero(); as h), Angular sabe que h existe. Y
*ngIf, *ngFor y *ngSwitch están obsoletas desde la 20, así que el código
nuevo no debería usarlas. Para proyectos existentes hay una migración
automática.
4. ¿Cuándo alcanza con un servicio con signals y cuándo usarías NgRx?¶
Respuesta:
Un servicio con un signal privado, métodos para modificarlo y computed
para lo derivado alcanza para la mayoría de las apps: es simple y no agrega
dependencias. Una librería como NgRx tiene sentido cuando el estado es grande
y lo tocan muchas partes, cuando el equipo necesita reglas estrictas sobre
cómo se modifica, o cuando hacen falta herramientas para ver la historia de
cambios. La señal de alarma es repetir el mismo patrón a mano en muchos
servicios. Antes de saltar a una librería, conviene ordenar lo que hay:
estado privado, cambios sólo por métodos y una fachada por funcionalidad.
5. Un proyecto grande usa NgModule. ¿Lo migras a standalone?¶
Respuesta:
Sí, pero de a poco y sin frenar el trabajo. Los dos estilos conviven: un
componente standalone puede importar un NgModule y un NgModule puede
importar un componente standalone. Angular trae una migración
(ng generate @angular/core:standalone) que se ejecuta por partes:
convertir componentes, quitar módulos que ya no hacen falta y pasar el
arranque a bootstrapApplication. Conviene empezar por las piezas
compartidas (botones, tarjetas) y seguir por funcionalidad, con las pruebas
pasando en cada paso. Lo que se gana: dependencias visibles en cada
componente, menos archivos y carga bajo demanda más fina.
6. Un pipe puro no se actualiza después de agregar un elemento a la lista. ¿Por qué y cómo lo resuelves?¶
Respuesta:
Porque un pipe puro sólo se vuelve a ejecutar si el valor de entrada es
otro. Con heroes.push(nuevo) el array es el mismo (la misma referencia),
así que para Angular no cambió nada. La solución correcta es no modificar el
array: crear uno nuevo ([...heroes, nuevo], o signal.update(...) en un
servicio). Marcar el pipe como impuro "lo arregla", pero lo ejecuta en cada
revisión de cambios y puede volver lenta la pantalla. Si el pipe ordena o
filtra, lo mejor es reemplazarlo por un computed.
7. ¿Cómo actualizas un proyecto de Angular 18 a la 22?¶
Respuesta:
De una versión mayor por vez: 18 → 19 → 20 → 21 → 22. Para cada salto se
consulta la guía oficial (angular.dev/update-guide), se ejecuta
ng update @angular/core@19 @angular/cli@19 (y así con cada número), se
revisan los cambios que aplicaron las migraciones automáticas y se corren las
pruebas antes del siguiente salto. Las librerías de terceros también tienen
que soportar cada versión, así que conviene revisarlas antes de empezar. Saltar
varias versiones juntas mezcla todos los cambios y hace muy difícil saber qué
rompió qué.
Módulo A2: Crear un proyecto con ng new¶
Proyecto: 02-bases/, generado con Angular CLI 22.
El problema que resuelve¶
Un proyecto Angular necesita muchas piezas configuradas: el compilador, el
servidor para desarrollar, las pruebas, las rutas, el formato del código. Armar
todo a mano llevaría horas, y cada persona lo haría distinto. El comando
ng new lo genera en un minuto, con una configuración que ya es la
recomendada.
Antes de empezar¶
- Node.js: Angular 22 pide Node
22.22.3o superior de la rama 22,24.15.0o superior de la 24, o la 26 en adelante. - El CLI (Command Line Interface, la herramienta de línea de comandos de Angular) se instala una vez en tu máquina:
npm install -g @angular/cli
- Dentro de un proyecto,
ngusa la versión del proyecto. Si tienes el CLI 22.0.0 instalado en general, pero el proyecto trae el 22.2.2 en sunode_modules, al ejecutarngdentro del proyecto se usa el 22.2.2. Así cada proyecto se queda con su versión.
El comando¶
ng new bases
Crea una carpeta llamada bases con el proyecto adentro.
- El nombre
baseses el nombre del proyecto: aparece enpackage.jsony enangular.json. - La carpeta se puede llamar distinto con
--directory:ng new bases --directory 02-bases. En este repositorio la carpeta lleva número (02-bases) para quedar ordenada junto a01-typescript-intro, y el proyecto se llamabases. - Se ejecuta desde la raíz del repositorio, para que la carpeta nueva quede al lado de las otras.
Para ver qué haría sin crear nada, agrega --dry-run. Muestra la lista de
archivos y no escribe ninguno. Es útil para probar opciones.
El flujo: qué pasa al ejecutarlo¶
ng new bases
│
├─ 1. Preguntas (las que no pasaste como opciones)
├─ 2. Crea los archivos (verás una línea CREATE por cada uno)
├─ 3. Instala paquetes (npm install)
└─ 4. Git (sólo si la carpeta no está ya dentro de un repo)
- Preguntas. Si ya diste una respuesta con una opción
(
--style=css), esa pregunta no se hace. - Archivos. El CLI escribe la estructura del proyecto (la tienes más abajo).
- Instalación. Ejecuta
npm instally creanode_modules/ypackage-lock.json. Se evita con--skip-install. - Git. Si la carpeta no está dentro de un repositorio, ejecuta
git inity hace un primer commit. Si ya está dentro de uno, como02-basesdentro deangular-ufh, avisa que ya hay control de versiones y no hace nada. Por eso aquí no quedó un repositorio anidado. Se evita siempre con--skip-git.
Las preguntas y qué elegimos¶
| Pregunta | Opciones | Elegimos |
|---|---|---|
| Nombre del proyecto | El que quieras | bases |
| Sistema de estilos | CSS, Tailwind CSS, Sass (SCSS), Sass (indentado), Less | CSS |
| Renderizado en servidor (SSR) y estático (SSG) | Sí o no | No: SPA |
| Herramientas de IA (se pueden marcar varias) | Ninguna, Agents.md, Claude, Cursor, Gemini, GitHub Copilot, JetBrains AI, Windsurf | Agents.md, Claude y GitHub Copilot |
Sistema de estilos. Define el tipo de archivo de estilos de cada
componente: app.css con CSS, app.scss con Sass. CSS es lo más simple y no
necesita herramientas extra. Sass y Less agregan variables y anidación;
Tailwind es otra forma de trabajar, con clases ya hechas. Se puede cambiar
después, pero a mano.
SSR/SSG. Contestar no deja una SPA, la forma por defecto (ver Módulo
A1). Contestar sí agrega cuatro archivos (main.server.ts, server.ts,
app.config.server.ts y app.routes.server.ts), paquetes de servidor en
package.json y cambios en angular.json. Si empiezas sin SSR y lo necesitas
más tarde, se agrega con ng add @angular/ssr.
Herramientas de IA. Cada asistente lee un archivo con instrucciones del proyecto. Angular genera ese archivo con su lista de buenas prácticas, así el asistente escribe Angular moderno y no el de hace cinco años:
| Elección | Archivo que se crea |
|---|---|
| Agents.md | AGENTS.md |
| Claude | .claude/CLAUDE.md |
| GitHub Copilot | .github/copilot-instructions.md |
Los tres archivos tienen el mismo texto: componentes standalone, signals
para el estado, input() y output() en lugar de decoradores, @if y @for
en lugar de *ngIf y *ngFor, inject() para los servicios. Son copias:
si editas una, las otras no cambian. Si no usas asistentes de IA, elige
"Ninguna".
Aparte, el CLI siempre crea .vscode/mcp.json, aunque elijas "Ninguna": es la
configuración del servidor MCP de Angular, que le da a los asistentes
compatibles herramientas del CLI.
Lo que no pregunta, pero decide por ti. En Angular 22 estas opciones ya vienen resueltas y se cambian con una opción del comando:
| Qué decide | Valor por defecto | Cómo cambiarlo |
|---|---|---|
| Rutas | Activadas | --routing=false |
Detección de cambios sin zone.js |
Sí (zoneless) | --zoneless=false |
| Componentes sin módulos | Sí (standalone) | — |
| Herramienta de pruebas | Vitest | --test-runner=karma |
| Revisión estricta de tipos | Activada | --strict=false |
| Prefijo de las etiquetas | app |
--prefix=otro |
Plantilla y estilos en el .ts |
Archivos aparte | --inline-template, --inline-style |
Las preguntas pueden cambiar entre versiones. Las opciones disponibles en la
tuya se ven con ng new --help.
Todo en una sola línea. Con opciones, el comando no pregunta nada y se puede repetir igual en otra máquina:
ng new bases --directory 02-bases --style=css --ssr=false \
--ai-config=agents --ai-config=claude --ai-config=copilot
La estructura que queda¶
Una vista rápida; las carpetas se explican una por una en el Módulo A3 y el resto de las piezas en los módulos siguientes.
02-bases/
├── .angular/ caché del compilador (se regenera, no se sube a git)
├── .claude/CLAUDE.md instrucciones para Claude
├── .github/copilot-instructions.md instrucciones para GitHub Copilot
├── .vscode/ extensión recomendada, depuración, tareas y MCP
├── public/ archivos que se copian tal cual (favicon.ico)
├── src/
│ ├── app/
│ │ ├── app.ts componente raíz: la clase
│ │ ├── app.html su plantilla
│ │ ├── app.css sus estilos (vacío al principio)
│ │ ├── app.spec.ts su prueba
│ │ ├── app.config.ts configuración global de la aplicación
│ │ └── app.routes.ts las rutas (vacías al principio)
│ ├── index.html la única página HTML
│ ├── main.ts el punto de entrada
│ └── styles.css estilos globales
├── .editorconfig reglas de formato para el editor
├── .prettierrc reglas de formato para Prettier
├── .gitignore
├── AGENTS.md instrucciones para asistentes de IA
├── angular.json cómo construir, servir y probar
├── package.json paquetes y comandos
├── package-lock.json versiones exactas instaladas
├── tsconfig.json reglas de TypeScript comunes
├── tsconfig.app.json reglas para el código de la aplicación
├── tsconfig.spec.json reglas para las pruebas
└── README.md
Cómo arranca la aplicación:
Navegador pide la página
└─ index.html tiene la etiqueta <app-root></app-root> vacía
└─ main.ts bootstrapApplication(App, appConfig)
└─ App el componente con selector 'app-root'
dibuja su plantilla dentro de <app-root>
main.tses muy corto: le dice a Angular "arranca con este componente y esta configuración".app.config.tsguarda los proveedores globales (servicios y funciones que se activan para toda la aplicación). Ahora tiene dos: el manejo de errores del navegador y las rutas.app.routes.tses una lista de rutas vacía: la aplicación todavía tiene una sola pantalla.app.htmlviene con una página de bienvenida larga (unas 340 líneas de estilos y dibujos). Su propio comentario dice que se puede borrar completa. Ojo:app.spec.tsbusca el textoHello, basesen esa plantilla. Si la reemplazas, actualiza también la prueba.
Los comandos (a diferencia del proyecto 01, que usa Vite, aquí todo pasa por el CLI de Angular):
| Comando | Qué hace |
|---|---|
npm start (ng serve) |
Servidor de desarrollo en http://localhost:4200/, con recarga automática |
ng build |
Genera la versión final en dist/bases/ |
ng test |
Ejecuta las pruebas con Vitest |
ng generate component nombre |
Crea un componente con sus archivos |
Qué trae package.json: los paquetes @angular/* (el núcleo, las rutas, los
formularios), rxjs y tslib como dependencias; y como herramientas de
desarrollo, el CLI, el compilador, TypeScript, Vitest, jsdom (un navegador
simulado para las pruebas) y Prettier. No hay zone.js: la detección de
cambios se apoya en los signals. Qué es package.json y para qué sirve el
lockfile está en Configuración del proyecto.
.editorconfig: el mismo formato para todos¶
El problema. Dos personas editan el mismo archivo: una sangra con dos
espacios, la otra con tabuladores; una deja espacios al final de las líneas,
la otra no. En git eso aparece como cambios que no cambian nada. .editorconfig
es un archivo chico que le dice al editor cómo escribir, y funciona igual
en casi todos los editores.
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
Cómo se lee. Los corchetes dicen a qué archivos se aplican las reglas
de abajo: [*] es todos, [*.ts] sólo los TypeScript, [*.md] sólo los
Markdown. Si dos bloques dicen cosas distintas, gana el que está más abajo.
| Regla | Qué hace |
|---|---|
root = true |
Es la regla de más arriba: el editor no busca otros .editorconfig en carpetas superiores |
charset = utf-8 |
Guarda los archivos en UTF-8, para que las tildes y la ñ no se rompan |
indent_style = space |
Sangra con espacios, no con tabuladores |
indent_size = 2 |
Cada nivel de sangría son dos espacios |
insert_final_newline = true |
Termina cada archivo con una línea en blanco |
trim_trailing_whitespace = true |
Borra los espacios sobrantes al final de cada línea |
quote_type = single (en .ts) |
Prefiere comillas simples |
ij_typescript_use_double_quotes = false |
Lo mismo, pero para los editores de JetBrains (WebStorm, IntelliJ): el prefijo ij_ es de ellos |
max_line_length = off (en .md) |
En Markdown no hay largo máximo de línea |
trim_trailing_whitespace = false (en .md) |
En Markdown no borra espacios al final: dos espacios seguidos al final de una línea significan "salto de línea" |
La última regla es la razón por la que .md tiene su propio bloque: lo que en
código es basura, en Markdown puede tener significado.
Quién la lee. El editor, mientras escribes. Algunos la entienden solos
(los de JetBrains); otros necesitan una extensión: en VS Code es EditorConfig
for VS Code (editorconfig.editorconfig). Sin ella, el archivo está pero
nadie lo aplica. Ojo: .vscode/extensions.json de este proyecto sólo
recomienda la extensión de Angular, no esta.
¿Y .prettierrc? Es otra herramienta con una idea parecida. .editorconfig
guía al editor mientras escribes; Prettier reescribe el archivo con un
formato fijo cuando lo ejecutas (o al guardar, si el editor lo tiene
configurado). Este proyecto trae las dos y no se contradicen: las dos piden
comillas simples. .prettierrc agrega dos cosas: el largo de línea
(printWidth: 100) y que los .html se lean con el formato de Angular
(parser: "angular"), para que entienda @if y @for.
Qué ganas. Menos ruido en los commits y menos discusiones de estilo. Si cambias una regla, hazlo en el archivo y avisa al equipo: el formato de todo el proyecto cambia con ella.
angular.json: el panel de control del CLI¶
angular.json le dice al CLI cómo construir, servir y probar el proyecto.
Cuando escribes ng build, el CLI abre este archivo y busca la receta.
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": { "packageManager": "npm" },
"newProjectRoot": "projects",
"projects": {
"bases": {
"projectType": "application",
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": { "builder": "@angular/build:application", /* ... */ },
"serve": { "builder": "@angular/build:dev-server", /* ... */ },
"test": { "builder": "@angular/build:unit-test" }
}
}
}
}
Arriba del todo:
$schema: le da al editor la lista de opciones válidas, para autocompletar y avisar de errores de escritura.cli.packageManager: el gestor que usa el CLI (npm).newProjectRoot: dónde se crearían proyectos adicionales dentro de este mismo espacio de trabajo (projects/). Unangular.jsonpuede describir varios proyectos; en este repositorio cada carpeta numerada es su propio espacio con un solo proyecto.
Dentro de projects.bases:
| Campo | Valor | Qué significa |
|---|---|---|
projectType |
application |
Es una aplicación (la otra opción es una librería) |
root |
"" |
El proyecto está en la raíz de la carpeta |
sourceRoot |
src |
Dónde está el código |
prefix |
app |
Los selectores empiezan con app-, como app-root |
architect: las tareas. Cada tarea tiene un builder, que es el programa
que hace el trabajo. angular.json sólo le pasa las opciones.
| Tarea | Comando | Builder | Qué hace |
|---|---|---|---|
build |
ng build |
@angular/build:application |
Compila y empaqueta la aplicación |
serve |
ng serve |
@angular/build:dev-server |
Servidor de desarrollo |
test |
ng test |
@angular/build:unit-test |
Ejecuta las pruebas (Vitest) |
Opciones de build:
| Opción | Valor | Qué hace |
|---|---|---|
browser |
src/main.ts |
Por dónde empieza: el punto de entrada |
tsConfig |
tsconfig.app.json |
Qué reglas de TypeScript usar |
assets |
carpeta public |
Copia todo lo que hay en public/ a la salida, sin tocarlo |
styles |
src/styles.css |
Los estilos globales que se cargan en toda la aplicación |
Configuraciones: producción y desarrollo. Las opciones de arriba sirven para las dos; cada configuración agrega o cambia algunas.
| Configuración | Se usa con | Qué hace |
|---|---|---|
production |
ng build (por defecto) |
Código reducido y optimizado, nombres de archivo con huella, y límites de tamaño |
development |
ng serve (por defecto) |
Sin optimizar, con mapas del código para depurar en el navegador |
Para construir sin optimizar: ng build --configuration development.
outputHashing: all: agrega una huella al nombre de cada archivo (main-GW7WULDQ.js). Si el contenido cambia, la huella cambia, y el navegador descarga la versión nueva en lugar de usar una vieja guardada.budgets(presupuestos de tamaño): ponen un tope al peso de la aplicación. Con el proyecto recién creado, el código inicial pesa unos 216 kB (59 kB al transferirse). Los límites son:
| Qué mide | Avisa desde | Falla desde |
|---|---|---|
Código inicial (initial) |
500 kB | 1 MB |
| Estilos de un componente | 4 kB | 8 kB |
Pasar el primer límite muestra una advertencia. Pasar el segundo
hace fallar ng build. Sirve para que la aplicación no engorde sin que
nadie se dé cuenta.
Casi nunca editas angular.json a mano al principio: comandos como
ng generate y ng add lo modifican por ti. Lo que sí vas a tocar con el
tiempo: agregar estilos globales (styles), carpetas de archivos (assets) y
ajustar los presupuestos.
Comparación con el proyecto 01. 01-typescript-intro no tiene
angular.json: Vite funciona con sus valores por defecto y los comandos
vienen de scripts en package.json. En Angular, angular.json cumple ese
papel y los comandos pasan por ng.
Los tsconfig: tres archivos en lugar de uno¶
El proyecto 01 tiene un solo tsconfig.json. Aquí hay tres, y se relacionan
así:
tsconfig.json reglas comunes (la base)
├── tsconfig.app.json código de la aplicación → lo usa ng build y ng serve
└── tsconfig.spec.json pruebas → lo usa ng test
tsconfig.jsontiene las reglas que valen para todo. Por sí solo no revisa ningún archivo: dice"files": []y sólo apunta a los otros dos conreferences. Por eso unnpx tscen la raíz del proyecto no hace nada.tsconfig.app.jsonhereda de la base (extends) y agrega: revisarsrc/**/*.ts, excluir las pruebas (*.spec.ts) y"types": [], que significa "no cargues tipos globales de más".tsconfig.spec.jsonhereda de la base y agrega: revisar sólo las pruebas y cargarvitest/globals, que hace quedescribe,ityexpectexistan sin importarlos.
Separarlos tiene sentido: las pruebas necesitan palabras globales
(describe, it) que no deberían existir en el código de la aplicación, y el
código final no debe incluir las pruebas.
Para revisar los tipos sin construir: npx tsc -p tsconfig.app.json --noEmit.
La forma habitual es ng build, que además revisa las plantillas HTML (más
abajo).
Las reglas de la base, en simple:
| Opción | Qué hace |
|---|---|
noImplicitOverride |
Al sobrescribir un método del padre hay que escribir override (ver herencia en el Módulo 8) |
noPropertyAccessFromIndexSignature |
Si un objeto es un diccionario (Record<string, number>), se lee con d['x'] y no con d.x |
noImplicitReturns |
Una función no puede devolver un valor en unos caminos y nada en otros |
noFallthroughCasesInSwitch |
Igual que en el proyecto 01 |
skipLibCheck |
Igual que en el proyecto 01 |
isolatedModules |
Cada archivo debe poder compilarse solo, porque la herramienta los compila uno por uno |
experimentalDecorators |
Angular usa los decoradores clásicos (Módulo 10); aquí ya viene activado |
importHelpers |
Las funciones auxiliares que TypeScript necesita se toman de tslib en lugar de copiarse en cada archivo |
target: ES2022, module: preserve |
Deja los import y export como están: el empaquetador se encarga de unirlos |
angularCompilerOptions: sólo en Angular. Son reglas para el compilador de
Angular, que entiende las plantillas HTML:
strictInjectionParameters: error si Angular no sabe qué entregar en una inyección.strictInputAccessModifiers: error si desde una plantilla le pasas un dato a uninputmarcado como privado, protegido o de sólo lectura.enableI18nLegacyMessageIdFormat: false: para las traducciones (i18n) usa el formato nuevo de identificadores. No se toca.
Además hay una regla que no está escrita pero está activa:
strictTemplates. Con ella, Angular revisa los tipos dentro de las
plantillas: si escribes {{ user().nme }} en vez de name, ng build lo
marca como error. TypeScript solo no puede hacerlo: no sabe leer HTML.
Qué cambia respecto al proyecto 01:
| Tema | 01-typescript-intro (Vite) |
02-bases (Angular CLI) |
|---|---|---|
| Archivos | Uno | Tres: base, aplicación y pruebas |
| Quién compila | Vite; tsc sólo revisa (noEmit) |
El CLI de Angular, que además entiende las plantillas |
| Cómo se revisan los tipos | npx tsc |
ng build |
target |
es2023 |
ES2022 |
module |
esnext con moduleResolution: bundler |
preserve |
lib |
ES2023 y DOM, escrito a mano |
No se escribe: usa los valores por defecto |
types |
vite/client |
[] en la aplicación, vitest/globals en las pruebas |
| Importar tipos | verbatimModuleSyntax: import type es obligatorio |
No: import { Hero } from './hero' funciona |
| Reglas extra | noUnusedLocals, noUnusedParameters |
noImplicitOverride, noPropertyAccessFromIndexSignature, noImplicitReturns |
| Decoradores | experimentalDecorators agregado a mano para el Módulo 10 |
Ya viene activado |
| Opciones de Angular | No hay | angularCompilerOptions y strictTemplates |
Qué significa en la práctica para los ejemplos: en 02-bases no hace falta
import type, una variable sin usar no rompe el build, y hay que escribir
override al sobrescribir. Ninguno de los dos proyectos declara strict:
TypeScript 6.0 lo activa por defecto.
Errores comunes¶
- Ejecutar
ng newdentro de una carpeta que ya está en un repositorio y esperar que cree otro: no lo hace. - Cambiar el nombre de la carpeta y creer que cambió el del proyecto: el nombre
que usan los comandos está en
angular.json(bases), no en la carpeta. - Activar SSR "por si acaso": agrega un servidor y más archivos que mantener.
Si hace falta después, se agrega con
ng add @angular/ssr. - Editar sólo uno de los tres archivos de instrucciones para IA y creer que los demás se actualizaron.
- Ejecutar
npx tscen la raíz y creer que se revisó todo: no revisa nada. Usang build. - Borrar
app.htmly olvidar queapp.spec.tsbuscaHello, bases. - Ignorar una advertencia de presupuesto hasta que se vuelve un error y rompe el build.
Resumen¶
ng new bases pregunta, crea los archivos, instala los paquetes y, si no hay
repositorio, lo inicia. Aquí elegimos CSS, SPA y archivos de instrucciones
para Claude, GitHub Copilot y AGENTS.md; lo demás (rutas, sin zone.js,
Vitest, revisión estricta) viene resuelto. El proyecto arranca desde
index.html y main.ts, que levanta el componente App. angular.json
define las tareas (build, serve, test), qué herramienta ejecuta cada una
y sus configuraciones de producción y desarrollo, con límites de tamaño.
Los tres tsconfig separan las reglas comunes, las de la aplicación y las de
las pruebas, y se suman las reglas propias de Angular para revisar plantillas.
Preguntas de entrevista (senior)¶
1. ¿Qué hace ng new y qué decisiones tomas al crear un proyecto?¶
Respuesta:
Genera el espacio de trabajo y la aplicación inicial, instala los paquetes y,
si la carpeta no está en un repositorio, inicia git con un primer commit. Las
decisiones que pesan después son tres. SSR o SPA: SPA es más simple y
suficiente detrás de un login; SSR conviene para páginas públicas que deben
aparecer en buscadores, y se puede agregar después con ng add @angular/ssr.
Estilos: CSS plano o Sass; Tailwind si el equipo ya trabaja así. Pruebas:
Vitest es el valor por defecto actual; Karma sigue disponible. Para que la
creación se pueda repetir en otra máquina, o en un script, se pasan todas las
respuestas como opciones, y con --dry-run se revisa antes de escribir nada.
2. ¿Qué es un builder y cómo se relaciona angular.json con ng build?¶
Respuesta:
angular.json no compila nada: describe tareas. Cada tarea (build, serve,
test) apunta a un builder, que es el programa que hace el trabajo
(@angular/build:application para construir), y le pasa opciones y
configuraciones. Al escribir ng build, el CLI busca la tarea build del
proyecto, mezcla las opciones base con las de la configuración elegida
(production por defecto) y llama al builder. Eso permite cambiar de
herramienta sin cambiar los comandos del equipo, y agregar tareas propias.
También por eso ng serve y ng build pueden usar la misma base con
configuraciones distintas.
3. ¿Para qué sirven los budgets y qué pasa cuando se superan?¶
Respuesta:
Son límites de tamaño que el build revisa. Cada uno tiene dos umbrales: al
superar el de advertencia, ng build avisa; al superar el de error,
el build falla. Por defecto, el código inicial avisa a los 500 kB y falla a
1 MB, y los estilos de un componente, a los 4 kB y 8 kB. Su valor está en la
integración continua: una dependencia que sube el peso a escondidas rompe el
build en lugar de llegar a producción. Conviene ajustarlos a la realidad de la
aplicación (un límite que siempre se ignora no sirve), y recordar que el
presupuesto initial sólo mide el código que se descarga al abrir la página:
lo que se carga bajo demanda no cuenta ahí.
4. ¿Por qué hay tres archivos tsconfig y qué pasa si ejecutas tsc a secas?¶
Respuesta:
Porque el código de la aplicación y el de las pruebas necesitan reglas
distintas. La base tiene lo común; tsconfig.app.json revisa src/ sin las
pruebas y no carga tipos globales extra; tsconfig.spec.json revisa las
pruebas y agrega los globales de Vitest. Así describe o it no se pueden
usar por accidente en la aplicación, y el build no incluye pruebas. La base
declara "files": [] con referencias, de modo que tsc en la raíz no revisa
nada y termina sin quejarse, lo que puede dar una falsa tranquilidad. Se usa
ng build, o tsc -p tsconfig.app.json --noEmit.
5. ¿Qué revisa el compilador de Angular que TypeScript solo no revisa?¶
Respuesta:
Las plantillas. TypeScript sólo entiende archivos .ts; el HTML de un
componente es para él un texto. El compilador de Angular lee la plantilla y,
con strictTemplates (activo por defecto), revisa que las propiedades
existan, que los tipos de los input() coincidan con lo que se les pasa y que
los eventos reciban lo esperado. Un {{ user().nme }} falla al construir, no
cuando un usuario abre la pantalla. Además compila las plantillas por
adelantado, antes de entregar la aplicación, en lugar de hacerlo en el
navegador. Por eso en un proyecto Angular el chequeo de tipos de verdad es
ng build, no tsc.
Módulo A3: Estructura de carpetas por defecto¶
Proyecto: 02-bases/.
El problema que resuelve¶
Abres un proyecto Angular recién creado y ves veinte carpetas y archivos.
¿Cuáles escribes tú? ¿Cuáles genera la herramienta y no se tocan? ¿Cuáles se
suben a git? Este módulo ordena la carpeta en tres grupos y explica qué hay en
cada una. Los archivos de la raíz (angular.json, package.json,
tsconfig*.json, .editorconfig) están en el Módulo A2.
Los tres grupos¶
| Grupo | Carpetas | Quién las crea | A git |
|---|---|---|---|
| Lo que escribes tú | src/, public/ |
Tú (el CLI deja un punto de partida) | Sí |
| Lo que genera una herramienta | .angular/, dist/, node_modules/ |
El CLI y npm, al ejecutar comandos | No |
| La configuración del entorno | .vscode/, .github/, .claude/ |
El CLI, una sola vez | Sí (ver nota) |
En VS Code, las carpetas que git ignora se ven atenuadas en el explorador.
En la captura del curso, dist y node_modules se ven más grises: es esa
razón.
Un detalle: los nombres de archivo del curso¶
En el curso, el componente raíz se ve como app.component.ts,
app.component.html, app.component.css y app.component.spec.ts. En
02-bases son app.ts, app.html, app.css y app.spec.ts.
Es la misma estructura con otro nombre. Las versiones recientes de Angular
dejaron de poner .component en el nombre del archivo; las anteriores lo
ponían siempre. Nada cambia en cómo funciona: no hace falta renombrar nada. Si
quieres los nombres antiguos en un proyecto nuevo, se pide con
ng new --file-name-style-guide=2016.
Las carpetas generadas¶
.angular/: la memoria del compilador.
Guarda resultados de construcciones anteriores para que la siguiente sea más
rápida. Lo que hay adentro se ordena por versión del CLI
(cache/22.2.2/bases/) y pesa unos 5 MB.
- No se edita nunca.
- Se puede borrar sin miedo: se vuelve a crear en la próxima construcción.
- Git la ignora (
/.angular/cacheen el.gitignore).
node_modules/: los paquetes instalados.
Es donde npm install deja todo lo que pide package.json, incluidas las
herramientas de Angular. Es enorme, no se edita y no se sube a git. Se
recrea con npm install (ver Configuración del proyecto).
dist/: lo que publicas.
No existe hasta que ejecutas ng build. Adentro queda la aplicación
terminada, lista para subir a un servidor:
dist/bases/
├── 3rdpartylicenses.txt licencias de los paquetes que se incluyeron
└── browser/
├── index.html la página, ya con los <script> y <link> puestos
├── main-XXXXXXXX.js tu código (el nombre lleva una huella)
├── chunk-XXXXXXXX.js código compartido que Angular separó
├── styles-XXXXXXXX.css los estilos globales
└── favicon.ico lo que venía de public/
Fíjate en lo que no hay: ningún .html de componente. Las plantillas se
compilan y pasan a formar parte del JavaScript. Tampoco hay .ts: todo se
tradujo. Se puede borrar y volver a generar cuando quieras. Git la ignora
(/dist).
La configuración del entorno¶
.vscode/: ajustes de VS Code para este proyecto.
| Archivo | Para qué sirve |
|---|---|
extensions.json |
Recomienda instalar angular.ng-template, el servicio de lenguaje de Angular (autocompleta y marca errores dentro de las plantillas) |
launch.json |
Dos configuraciones para depurar en Chrome: ng serve y ng test |
tasks.json |
Las tareas npm: start y npm: test, que el depurador ejecuta antes de abrir Chrome |
mcp.json |
La configuración del servidor MCP de Angular, para asistentes de IA |
La configuración ng test del depurador apunta a una página de Karma (puerto
9876). Este proyecto usa Vitest, así que ese lanzador no aplica tal cual.
Nota para este repositorio: el .gitignore de la raíz ignora .vscode/, así
que estos archivos no se suben a git aunque el .gitignore del propio proyecto
los dejaría pasar.
.github/ y .claude/ guardan las instrucciones para GitHub Copilot y
para Claude (Módulo A2).
La carpeta public/¶
Todo lo que pones aquí se copia tal cual a la raíz del resultado, sin
compilar ni cambiar el nombre. Por eso favicon.ico queda junto a
index.html en dist/bases/browser/, y index.html lo pide con
href="favicon.ico", sin public/ en la ruta.
Regla práctica: si el archivo no pasa por el compilador (imágenes, fuentes,
robots.txt, íconos), va en public/. Si es código o estilos que se importan,
va en src/.
La carpeta src/¶
src/
├── app/
│ ├── app.ts el componente raíz (la clase)
│ ├── app.html su plantilla
│ ├── app.css sus estilos
│ ├── app.spec.ts su prueba
│ ├── app.config.ts configuración global
│ └── app.routes.ts las rutas
├── index.html la página
├── main.ts el punto de entrada
└── styles.css los estilos globales
Dentro de src/app/ va todo tu código: componentes, servicios, pipes. Los
módulos siguientes lo van llenando.
index.html¶
Es la única página HTML de la aplicación:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Bases</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>
<app-root></app-root>está vacía: es el lugar donde Angular dibuja la aplicación. Su nombre viene delselector: 'app-root'del componenteApp.<base href="/">es la dirección de partida para todos los enlaces relativos. Las rutas la necesitan para funcionar.- No hay ningún
<script>ni<link>de estilos: los agregang buildal construir, con los nombres con huella. lang="en"indica que la página está en inglés. Si la aplicación es en español, cámbialo aes: lo usan los lectores de pantalla y los buscadores.
main.ts: el punto de entrada¶
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));
Es el primer archivo que se ejecuta. Bootstrap quiere decir "arrancar", y
bootstrapApplication hace tres cosas:
- Toma el componente
Appcomo raíz. - Lo dibuja dentro de la etiqueta
<app-root>deindex.html. - Aplica
appConfig, la configuración global (rutas, manejo de errores).
Devuelve una promesa; el .catch imprime en la consola si el arranque falla.
Todo lo demás se ejecuta porque main.ts lo importa, directa o
indirectamente. Un archivo que nadie importa no se ejecuta.
Es el mismo mecanismo de import del Módulo 7. El CLI sabe que éste es el
punto de entrada por angular.json ("browser": "src/main.ts").
En el proyecto 01 pasa algo parecido: index.html carga src/main.ts. La
diferencia es que allí lo escribe uno a mano y aquí lo agrega el CLI al
construir.
Legacy: antes de los componentes standalone,
main.tsarrancaba un módulo:platformBrowserDynamic().bootstrapModule(AppModule). Hoy se arranca un componente conbootstrapApplication.
Dentro de src/app/¶
| Archivo | Qué contiene |
|---|---|
app.ts |
La clase App, con @Component: su selector, qué importa y dónde están plantilla y estilos |
app.html |
La plantilla. Viene con una página de bienvenida que se puede borrar |
app.css |
Los estilos de este componente. Empieza vacío |
app.spec.ts |
Su prueba |
app.config.ts |
Los proveedores globales: rutas y manejo de errores |
app.routes.ts |
La lista de rutas, vacía al principio |
app.config.ts y app.routes.ts: cómo se conectan las rutas¶
Una SPA muestra una sola página HTML y cambia lo que hay adentro según la
dirección (/, /about). Tres archivos se reparten ese trabajo:
// app.routes.ts: la lista de rutas (vacía al crear el proyecto)
export const routes: Routes = []
// app.config.ts: activa el enrutador con esa lista
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
],
}
<!-- app.html: el hueco donde se dibuja la página de la ruta actual -->
<router-outlet />
| Pieza | Qué hace |
|---|---|
app.routes.ts |
Une una dirección con un componente. Vacía, la aplicación sólo muestra App |
app.config.ts |
Lista de providers: funciones que activan cosas para toda la aplicación. main.ts se la pasa a bootstrapApplication |
provideRouter(routes) |
Activa el enrutador con la lista de app.routes.ts |
provideBrowserGlobalErrorListeners() |
Envía a Angular los errores del navegador que nadie capturó, para que los trate su manejador de errores |
RouterOutlet y <router-outlet /> |
El componente se importa en app.ts y la etiqueta se escribe en app.html: marcan dónde aparece cada página |
Con dos páginas, la lista se ve así:
export const routes: Routes = [
{ path: '', component: Home },
{ path: 'about', component: About },
{ path: '**', component: NotFound },
]
''es la dirección raíz (/).'about'es/about.'**'significa "cualquier otra dirección" y sirve para la página de "no encontrado".
El orden importa. El enrutador recorre la lista de arriba hacia abajo y usa
la primera ruta que coincide. Por eso '**' va siempre al final: una
ruta escrita después de ella nunca se alcanza.
El recorrido:
Cambia la dirección (/about)
└─ El enrutador busca, de arriba a abajo, la primera ruta que coincide
└─ Dibuja ese componente justo después de <router-outlet />
(sin recargar la página del navegador)
Para ir de una página a otra se usan enlaces con routerLink en lugar de
href; así Angular cambia la vista sin recargar:
<a routerLink="/about">Acerca de</a>
<router-outlet />
RouterLink se importa en el componente igual que RouterOutlet:
imports: [RouterOutlet, RouterLink]. Angular convierte ese enlace en un
<a href="/about"> normal, así que el botón derecho y "abrir en una pestaña
nueva" siguen funcionando.
Ojo con app.html: la página de bienvenida que genera ng new termina con
<router-outlet /> (en la línea 344). Si la borras entera, deja esa
etiqueta, o ninguna ruta tendrá dónde dibujarse.
Los parámetros en la dirección (/heroes/:id), las rutas hijas, los guards y
la carga bajo demanda se ven en el módulo de rutas; la sección Rutas del
Módulo A1 ya
adelanta algunos.
Estilos globales y estilos de componente¶
Hay dos lugares para escribir CSS, y se comportan distinto.
| Tema | Globales | De componente |
|---|---|---|
| Archivo | src/styles.css |
app.css (uno por componente) |
| Cómo se conecta | angular.json, en styles |
styleUrl: './app.css' o styles en @Component |
| A quién afectan | A toda la aplicación | Sólo a la plantilla de ese componente |
| Para qué se usa | Base común: tipografía, colores, variables | Cómo se ve cada pieza |
Estilos globales. Lo que escribas en styles.css vale en todas las
pantallas y en todos los componentes. Es el lugar para:
- Variables CSS compartidas:
:root { --color-principal: #6b21a8 }. Cualquier componente las puede usar convar(--color-principal). - Tipografía y márgenes generales del
body. - Estilos de una librería externa: se agrega su archivo a la lista
stylesdeangular.json.
Estilos de componente. Lo que escribas en app.css sólo afecta a la
plantilla de App. Si en otro componente tienes un <p>, no se ve afectado.
Esto es lo que hace seguro escribir p { color: red } sin miedo a cambiar
medio proyecto.
Cómo lo logra Angular. Mira qué pasa con un componente que escribe
p { color: red }. Angular marca los elementos de ese componente con un
atributo con un nombre único, y cambia el CSS para exigirlo:
Tú escribes: p { color: red }
Angular deja en la página: p[_ngcontent-xxx] { color: red }
<p _ngcontent-xxx> ← de este componente: recibe el estilo
<p> ← de otro componente, sin la marca: no lo recibe
El atributo (_ngcontent-xxx) lo recibe cada elemento de la plantilla del
componente, y su final es distinto en cada componente. La regla queda atada a
él.
:host: el elemento del propio componente. Las reglas del
componente sólo alcanzan a los elementos de su plantilla; la etiqueta del
propio componente (<app-root>) queda afuera. Para darle estilo se usa
:host:
:host {
display: block;
}
Angular lo traduce a [_nghost-xxx] { display: block }, con otro atributo que
sólo lleva ese elemento.
¿Y si chocan? Si una regla global y una de componente apuntan al mismo elemento, gana la del componente: su selector es más específico, porque lleva el atributo extra.
Los estilos de un componente no llegan adentro de sus hijos. Los elementos
de un componente hijo llevan la marca del hijo, no la del padre. Existe
::ng-deep para saltarse ese aislamiento, pero lo rompe a propósito: úsalo
sólo como último recurso.
Tres modos de aislamiento. Se elige con encapsulation en @Component:
Modo (ViewEncapsulation) |
Qué hace |
|---|---|
Emulated (por defecto) |
Lo que acabas de ver: atributos únicos y CSS reescrito |
None |
Sin aislamiento: p { color: red } queda tal cual y afecta a todos los <p> de la aplicación |
ShadowDom |
Usa el aislamiento del propio navegador (Shadow DOM) |
Con None, Angular deja p { color: red } sin marcas y los <p> no reciben
ningún atributo. Por eso None es como escribir en styles.css pero desde un
componente: funciona, pero sus estilos se escapan.
Conexión con angular.json. El límite anyComponentStyle (4 kB de aviso
y 8 kB de error) del Módulo A2 mide justamente el CSS de un componente.
Cuándo usar cada uno.
- Lo que debe verse igual en toda la aplicación (colores, tipografía, variables): global.
- Lo que sólo importa a una pieza (el borde de una tarjeta, el espaciado de una lista): de componente. Es el valor por defecto: empieza por aquí.
- Si te encuentras copiando el mismo estilo en cinco componentes, sube la parte
común a una variable en
styles.css.
Plantilla en el componente o en un archivo aparte¶
Un componente puede llevar su HTML y su CSS escritos adentro o en archivos aparte. Las dos formas hacen lo mismo:
// Adentro (inline)
@Component({
selector: 'app-badge',
template: `<span class="badge">{{ label() }}</span>`,
styles: `.badge { padding: 2px 8px; }`,
})
export class Badge {
readonly label = input.required<string>()
}
// En archivos aparte
@Component({
selector: 'app-hero-list',
templateUrl: './hero-list.html',
styleUrl: './hero-list.css',
})
export class HeroList {}
templateystylesllevan el contenido.templateUrlystyleUrlllevan la ruta a un archivo, relativa al.tsdel componente (./).- Se pueden mezclar: plantilla adentro y estilos en un archivo, o al revés.
ng newyng generate componentcrean archivos aparte por defecto. Para pedir lo contrario:--inline-template(-t) y--inline-style(-s).
No hay diferencia de rendimiento. Al construir, Angular compila la
plantilla y los estilos y los pone dentro del JavaScript, vengan de donde
vengan. Por eso dist/ no tiene ningún .html de componente. Es una decisión
de comodidad, no de velocidad.
Cuándo conviene dentro del componente:
| Situación | Por qué |
|---|---|
| Plantilla corta, unas pocas líneas | La ves junto a la clase, sin cambiar de archivo |
| Componente pequeño y presentacional (botón, etiqueta, tarjeta) | Todo el componente cabe en una pantalla |
| Estilos mínimos o ninguno | Un archivo .css casi vacío no aporta nada |
| Ejemplos, pruebas y documentación | Se copian y se leen enteros (esta guía lo hace así) |
Cuándo conviene un archivo aparte:
| Situación | Por qué |
|---|---|
Plantilla larga o con muchos @if y @for anidados |
Dentro de un texto del .ts cuesta leerla |
| Mucho CSS | La clase queda enterrada entre estilos |
| Alguien trabaja el HTML y el CSS por separado de la lógica | Cada persona toca su archivo |
| Quieres que los cambios de vista se vean limpios en git | Un cambio de HTML no aparece mezclado con la clase |
| Tu equipo ya lo definió | Lo importante es que sea igual en todo el proyecto |
Regla rápida. Si al abrir el .ts tienes que bajar la pantalla para ver
dónde termina la plantilla, sácala a un archivo. Si cabe cómoda junto a la
clase, déjala adentro.
Las instrucciones para asistentes de IA que genera Angular (AGENTS.md)
dicen lo mismo: preferir plantillas inline para componentes pequeños y, al
usar archivos externos, rutas relativas al .ts.
Un ejemplo: el contador¶
02-bases/ ya tiene un componente sencillo para ver las piezas juntas: un
número y tres botones. Aquí no se resuelve ningún problema; sólo se muestra
cómo encajan las partes que acabas de ver. Vive en src/app/pages/counter/,
con tres archivos que comparten el nombre base:
src/app/pages/counter/
├── counter-page.component.ts la clase
├── counter-page.component.html la plantilla
└── counter-page.component.css sus estilos (vacío: los pone Bootstrap)
@Component({
selector: 'app-counter',
templateUrl: './counter-page.component.html',
styleUrls: ['./counter-page.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CounterPageComponent {
public counter: number = 1
public counterSignal: WritableSignal<number> = signal(1)
public title: string = 'Counter Component'
public incrementBy(value: number = 1): void {
this.counter += value
this.counterSignal.update((current) => current + value)
}
public decrementBy(value: number = 1): void {
this.counter -= value
this.counterSignal.update((current) => current - value)
}
reset(): void {
this.counter = 1
this.counterSignal.set(1)
}
}
El contador guarda el mismo número dos veces a propósito: counter es una
propiedad normal y counterSignal es un signal. Así se ven lado a lado en la
página (más abajo se explica la diferencia). styleUrls recibe una lista de
archivos; con uno solo basta styleUrl, y las dos formas funcionan.
<div class="card page-card mx-auto my-5 text-center">
<div class="card-body">
<h1 class="card-title h4 mb-3">{{ title }}</h1>
<p class="fs-3 fw-bold mb-2">Counter: {{ counter }}</p>
<p class="fs-3 fw-bold mb-3">Counter Signal: {{ counterSignal() }}</p>
<div class="d-flex justify-content-center gap-2">
<button class="btn btn-primary" (click)="incrementBy()">+1</button>
<button class="btn btn-outline-secondary" (click)="reset()">Reset</button>
<button class="btn btn-primary" (click)="decrementBy()">-1</button>
</div>
</div>
</div>
Las clases (card, btn, fs-3, gap-2) son de Bootstrap; se explican en el
último párrafo de esta sección.
| Pieza | Dónde | Qué hace |
|---|---|---|
counter, title |
Clase | Propiedades: los datos que guarda el componente |
counterSignal |
Clase | Un signal: un dato que avisa cuando cambia (se explica abajo) |
incrementBy(), reset() |
Clase | Métodos: lo que el componente sabe hacer |
value: number = 1 |
Clase | Valor por defecto: si llamas incrementBy() sin nada, suma 1 |
{{ counter }} |
Plantilla | Interpolación: escribe el valor de la propiedad en la página |
{{ counterSignal() }} |
Plantilla | Lo mismo con un signal: se lee con paréntesis |
(click)="incrementBy()" |
Plantilla | Evento: cuando se hace clic en el botón, Angular llama al método |
El recorrido de un clic:
Clic en "+1"
└─ Angular llama a incrementBy()
└─ counter y counterSignal pasan de 1 a 2
└─ Angular vuelve a dibujar la página con el 2
Cómo llega a la pantalla. El componente no se escribe en ningún HTML:
la ruta de la raíz lo muestra. app.html tiene la barra de navegación
(<app-navbar>, se explica en el Módulo A4) y, debajo, un <section> con el
<router-outlet />.
// app.routes.ts
export const routes: Routes = [
{
path: '',
loadComponent: () =>
import('./pages/counter/counter-page.component').then((m) => m.CounterPageComponent),
},
{
path: 'hero',
loadComponent: () =>
import('./pages/hero/hero-page.component').then((m) => m.HeroPageComponent),
},
{
path: 'dragonball',
loadComponent: () =>
import('./pages/dragonball/dragonball-page.component').then(
(m) => m.DragonballPageComponent,
),
},
{
path: 'dragonball-super',
loadComponent: () =>
import('./pages/dragonball-super/dragonball-super-page.component').then(
(m) => m.DragonballSuperPageComponent,
),
},
{ path: '**', redirectTo: '' },
]
Se usa loadComponent en lugar de component: el componente se descarga
sólo cuando alguien visita la ruta (carga diferida, lazy loading). Al
ejecutar ng build, cada página sale como un archivo aparte (el contador,
de poco más de 1 kB) y no dentro del archivo principal. La ruta / muestra el
contador, /hero el héroe (el ejemplo de abajo) y /dragonball y
/dragonball-super las listas del Módulo A4. La última ruta, '**', redirige a / lo que no existe.
Estilos: Bootstrap y un poco de CSS global. En lugar de escribir CSS en
cada componente, el proyecto usa Bootstrap, una librería que ya trae
estilos listos y se usa poniendo clases en el HTML. Se carga con una
etiqueta <link> en index.html, y por eso los CSS de los componentes quedaron
vacíos.
| Clase | Qué hace |
|---|---|
card, card-body |
Una tarjeta con borde, y su interior con espacio |
mx-auto my-5 |
Centrada (margen automático a los lados) y separada de arriba y abajo |
text-center |
Texto centrado |
fs-3, fw-bold |
Tamaño de letra grande y negrita |
btn btn-primary |
Botón azul. btn-outline-secondary es uno con sólo borde |
d-flex gap-2 |
Pone los hijos en fila, con espacio entre ellos |
Sólo hace falta una regla propia, y va en styles.css porque la usan varias
páginas: .page-card { max-width: 360px; }, ya que una tarjeta de Bootstrap
ocupa todo el ancho. La tipografía también está ahí. Lo que sirve a un solo
componente va en su CSS; lo que sirve a varios, en el global.
Otro ejemplo: el héroe¶
src/app/pages/hero/ repite las mismas piezas con dos signals (name y
age), dos valores derivados de ellos y cuatro botones. Aquí se ve un uso
un poco distinto de cada parte:
@Component({
imports: [UpperCasePipe],
// ...
})
export class HeroPageComponent {
private readonly initialName: string = 'Geralt of Rivia'
private readonly initialAge: number = 120
private readonly otherName: string = 'Ciri'
private readonly otherAge: number = 21
public name: WritableSignal<string> = signal('Geralt of Rivia')
public age: WritableSignal<number> = signal(120)
heroDescription: Signal<string> = computed(
() => `${this.name()} is ${this.age()} years old.`,
)
capitalizedHeroName: Signal<string> = computed(() => this.name().toUpperCase())
getHeroDescription(): string {
return `${this.name()} is ${this.age()} years old.`
}
changeHero(): void {
this.name.set(this.otherName)
this.age.set(this.otherAge)
}
changeAge(): void {
this.age.set(60)
}
resetHero(): void {
this.name.set(this.initialName)
this.age.set(this.initialAge)
}
increaseAge(): void {
this.age.update((age) => age + 1)
}
}
<dl class="row mb-4">
<dt class="col-5">Method:</dt>
<dd class="col-7">{{ getHeroDescription() }}</dd>
<dt class="col-5">Computed:</dt>
<dd class="col-7">{{ heroDescription() }}</dd>
<dt class="col-5">Capitalized:</dt>
<dd class="col-7">{{ capitalizedHeroName() }}</dd>
<dt class="col-5">Capitalized with Pipe:</dt>
<dd class="col-7">{{ name() | uppercase }}</dd>
</dl>
- Un método también puede leer signals.
getHeroDescription()leename()yage(). Si la plantilla llama al método, la pantalla se actualiza cuando cualquiera de los dos cambia: Angular se da cuenta de qué signals se leyeron mientras se dibujaba la página. setoupdate.changeHero()yresetHero()usanset: ya saben el valor nuevo.increaseAge()usaupdate: la edad nueva depende de la actual.- Un valor que sale de otros:
computed.heroDescriptionycapitalizedHeroNameno se modifican a mano: se calculan a partir denameyage. Por eso su tipo esSignal(sólo lectura) y noWritableSignal, y no tienensetniupdate. Se actualizan solos cuando cambia alguno de los signals que leen, y se leen igual que cualquier signal, con paréntesis:heroDescription(). No hace falta crear un signal nuevo y acordarse de actualizarlo. computedo método. Los dos dan el mismo texto, pero no trabajan igual: el método se ejecuta cada vez que se vuelve a dibujar la plantilla, aunque haya cambiado otra cosa que no tiene nada que ver. Elcomputedguarda el resultado y sólo lo recalcula cuando cambianameoage. Para un texto corto da igual; con un cálculo pesado, conviene elcomputed.computedo pipe. Para mayúsculas hay tres caminos que se ven igual:name().toUpperCase()escrito en la plantilla, el pipeuppercase(imports: [UpperCasePipe]) y uncomputed. El pipe es lo más corto para mostrar un dato con otro formato; elcomputedconviene cuando el valor derivado se usa en varios sitios o también lo necesita la clase.- Valores fijos con
private readonly. Los nombres y edades que usan varios métodos se guardan una sola vez. Son privados porque la plantilla no los necesita, yreadonlyporque nunca cambian. <dl>,<dt>,<dd>. Es la lista de definiciones de HTML:dtes el término yddsu valor. Conrowycol-5/col-7de Bootstrap quedan en dos columnas.
Signals: un dato que avisa cuando cambia¶
En el contador hay dos números que parecen iguales y no lo son:
counteres una propiedad normal. Angular no sabe cuándo cambia: sólo sabe que "pasó algo" (un clic) y entonces vuelve a mirar la plantilla.counterSignales un signal: una caja que guarda un valor y avisa a Angular cuando ese valor cambia.
El problema que resuelven. Con propiedades normales, Angular tiene que adivinar cuándo redibujar. Funciona con un clic, porque el evento le avisa, pero falla cuando el dato cambia por otro camino. Por ejemplo, después de una espera:
async cargarDespues(): Promise<void> {
await Promise.resolve() // algo asíncrono (una petición, un temporizador)
this.counter++ // la propiedad cambia, pero la pantalla sigue igual
}
La propiedad ya vale 2 y la página sigue mostrando 1. El valor aparece de golpe más tarde, cuando otra cosa provoca un redibujado, y por eso es un error difícil de rastrear. Con un signal no pasa: cada vez que cambia, Angular se entera y actualiza sólo los sitios de la plantilla que lo leen.
Cómo se usa:
public counterSignal: WritableSignal<number> = signal(1) // crear, con valor inicial
this.counterSignal.set(5) // poner un valor nuevo
this.counterSignal.update((current) => current + 1) // calcularlo desde el anterior
<p>{{ counterSignal() }}</p> <!-- leer: con paréntesis -->
| Operación | Código | Qué hace |
|---|---|---|
| Leer | counterSignal() |
Devuelve el valor actual. Un signal es una función |
| Escribir | counterSignal.set(5) |
Pone directamente un valor nuevo |
| Calcular | counterSignal.update(c => c + 1) |
Recibe el valor actual y devuelve el nuevo |
setoupdate. Usasetcuando ya sabes el valor nuevo (resetvuelve a 1). Usaupdatecuando el valor nuevo depende del anterior (sumar, restar, agregar a una lista).- Los paréntesis no son opcionales. Si escribes
{{ counterSignal }}sin ellos, la página muestra el texto[Signal (counterSignal): 1]en lugar del número, y Angular lo avisa al compilar. signalyWritableSignal.signal(1)crea unWritableSignal<number>: un signal que se puede leer y cambiar. El tipoSignal<number>es el que sólo se puede leer. Un componente o servicio suele guardar elWritableSignalcomo privado y mostrar hacia afuera una versión de sólo lectura con.asReadonly(); es el patrón de Servicios.
Cuándo usar cada uno: signal, computed, set y update¶
Una pregunta resuelve casi todo: ¿este valor lo decido yo, o sale de otros?
signal (que se puede cambiar) o computed. Un carrito de compras:
price = signal(10) // lo cambia alguien: el precio
quantity = signal(1) // lo cambia el usuario: la cantidad
total = computed(() => this.price() * this.quantity()) // sale de los dos
<p>Total: {{ total() }}</p>
Si quantity pasa a 3, total pasa solo a 30; con 4, a 40. Nadie lo
actualiza a mano.
| El valor... | Usa | Ejemplos |
|---|---|---|
| Lo cambia el usuario, el servidor o un evento | signal |
cantidad, texto de un campo, si un menú está abierto |
| Se puede calcular a partir de otros signals | computed |
total, nombre en mayúsculas, "¿hay elementos?", filtrar |
El error típico es guardar en un signal algo que se puede calcular:
total = signal(10) // mal: hay que acordarse de actualizarlo
quantity.set(3) // total sigue en 10 si se olvida
Con computed ese olvido no existe. Además, computed es de sólo lectura:
no tiene set ni update, y escribir total.set(5) es un error al compilar.
Eso es lo que se quiere: un valor derivado no se pisa, se cambia el origen.
set o update. Un menú que se abre y se cierra, y una lista:
isOpen = signal(false)
items = signal<string[]>(['manzana'])
quantity = signal(1)
// set: ya sé el valor nuevo
close(): void {
this.isOpen.set(false)
}
reset(): void {
this.quantity.set(1)
}
// update: el valor nuevo depende del anterior
toggle(): void {
this.isOpen.update((open) => !open)
}
increase(): void {
this.quantity.update((q) => q + 1)
}
add(item: string): void {
this.items.update((list) => [...list, item])
}
| Situación | Usa | Ejemplo |
|---|---|---|
| El valor viene de fuera o es fijo | set |
name.set('Ciri'), quantity.set(1) |
| Volver al valor inicial | set |
reset() |
| Sumar, restar, contar | update |
quantity.update(q => q + 1) |
| Cambiar de verdadero a falso y al revés | update |
isOpen.update(open => !open) |
| Agregar o quitar de una lista | update |
items.update(list => [...list, x]) |
set(this.quantity() + 1)también funciona. Da el mismo resultado queupdate. Se prefiereupdateporque dice con claridad "el nuevo valor sale del anterior" y no hay que leer el signal por separado.- En una lista, crea una lista nueva. El signal avisa cuando recibe un
valor distinto. Si haces
this.items().push('pera'), la lista cambia por dentro pero sigue siendo la misma, y la pantalla no se entera: se queda mostrando 1 elemento aunque ya haya 2. Con[...list, item]es una lista nueva y la pantalla se actualiza.
Zoneless: Angular sin zone.js¶
La pregunta de fondo: ¿cómo sabe Angular cuándo redibujar la página?
Antes: zone.js. Era una librería que "vigilaba" el navegador. Modificaba
por debajo funciones como setTimeout, los eventos y las promesas, para
enterarse de que algo terminó. Cada vez que eso pasaba, Angular revisaba
todos los componentes, por si alguno había cambiado. Funcionaba sin que el
programador hiciera nada, pero tenía costos:
- Es un paquete más que se descarga junto con la aplicación.
- Revisa componentes que no cambiaron, sólo porque "pasó algo".
- Es magia: cuesta entender por qué algo se redibuja (o no), y los errores salen con rastros difíciles de leer.
Ahora: zoneless (sin zonas). No hay zone.js. Angular no vigila el
navegador: redibuja cuando alguien le avisa. Y le avisan cuatro cosas:
| Qué ocurre | Ejemplo |
|---|---|
| Cambia un signal que la plantilla lee | counterSignal.update(...) |
| Se dispara un evento escrito en la plantilla | (click)="incrementBy()" |
Llega un valor nuevo por el pipe async |
{{ datos$ \| async }} |
| Pides el redibujado a mano | ChangeDetectorRef.markForCheck() |
Eso explica lo que se ve en el contador. counter se actualiza con un clic
porque el evento avisó, aunque sea una propiedad normal. En cambio, el
this.counter++ de cargarDespues() no avisa a nadie, y la pantalla no se
entera. counterSignal funciona siempre, venga el cambio de donde venga:
un signal es la forma de avisar.
Cómo está en este proyecto:
package.jsonno tienezone.js.app.config.tsno tiene nada especial: en Angular 22 zoneless es lo normal. Para volver al modo anterior habría que crear el proyecto con--zoneless=false(ver la tabla deng new, en el Módulo A2).
Por qué se usa:
- Menos peso: una librería menos que descargar.
- Menos trabajo: Angular actualiza sólo lo que cambió, no todo el árbol.
- Más claro: el redibujado tiene una causa visible (un signal, un evento), no una vigilancia invisible.
- Mejor encaje: los signals y zoneless se diseñaron juntos.
¿Y OnPush? El contador también declara
changeDetection: ChangeDetectionStrategy.OnPush. Es otra decisión, distinta
de zoneless:
- Zoneless decide quién avisa a Angular: ya no hay vigilancia automática.
OnPushdecide qué componentes se revisan: sólo los que recibieron un aviso (un evento de su plantilla, un signal que su plantilla lee, un valor nuevo en uninputomarkForCheck()). Los demás se saltan.
Una no depende de la otra: OnPush ya existía cuando había zone.js. Pero se
llevan bien: el signal avisa y OnPush revisa sólo lo avisado. Con OnPush
sigue pasando lo mismo que se vio arriba: el this.counter++ después de una
espera no se refleja en la pantalla.
Regla práctica: si un dato aparece en la plantilla y puede cambiar con el tiempo (no sólo con un clic), guárdalo en un signal.
Errores comunes¶
- Subir
node_modules/,dist/o.angular/a git. Ya están en el.gitignore: no los saques de ahí. - Editar algo dentro de
dist/creyendo que cambia la aplicación: se sobrescribe en el próximong build. - Poner en
src/un archivo que no se compila (una imagen) y no encontrarlo después: va enpublic/. - Escribir estilos globales de una pieza en
styles.css: afectan a toda la aplicación. Van en el CSS del componente. - Esperar que el CSS de un componente padre cambie lo de adentro de un hijo.
- Usar
ViewEncapsulation.None"para que funcione" y que los estilos se escapen a otros componentes. - Poner
templateUrl: 'hero-list.html'sin./: la ruta es relativa al.tsy conviene escribirla con./. - Dejar
lang="en"en una aplicación en español.
Resumen¶
La carpeta del proyecto tiene tres grupos: lo que escribes (src/, public/),
lo que generan las herramientas (.angular/, dist/, node_modules/; git los
ignora y se pueden borrar) y la configuración del entorno (.vscode/,
.github/, .claude/). index.html tiene la etiqueta <app-root> vacía;
main.ts arranca el componente App dentro de ella con
bootstrapApplication. public/ se copia tal cual a la salida. Los estilos
globales (styles.css) afectan a toda la aplicación; los de componente
(app.css) sólo a su plantilla, porque Angular marca sus elementos y reescribe
el CSS. La plantilla va dentro del componente si es corta y en un archivo si
es larga; el rendimiento es el mismo.
Preguntas de entrevista (senior)¶
1. ¿Qué carpetas de un proyecto Angular se suben a git y cuáles no?¶
Respuesta:
Se suben las que escribes o configuras: src/, public/, angular.json,
package.json, package-lock.json, los tsconfig*.json, .editorconfig y
.prettierrc. No se suben las que se regeneran: node_modules/ (se recrea con
npm install), dist/ (se recrea con ng build) y .angular/cache (la
caché del compilador). El package-lock.json sí se sube a propósito: asegura
que todo el equipo y la integración continua instalen las mismas versiones. La
regla general: se sube lo que no se puede reconstruir a partir de otra cosa.
2. ¿Cómo logra Angular que los estilos de un componente no afecten a otros?¶
Respuesta:
Con el modo Emulated, el predeterminado. Angular marca cada elemento de la
plantilla del componente con un atributo único (_ngcontent-xxx) y reescribe
cada regla del CSS para exigir ese atributo: p { … } queda como
p[_ngcontent-xxx] { … }. Así sólo alcanza a los elementos de ese componente.
El elemento de la propia etiqueta se marca con otro atributo, que es el que
usa :host. No usa nada especial del navegador: es sólo CSS con atributos.
Consecuencias: una regla de componente gana a una global del mismo elemento
por ser más específica, y los estilos de un padre no llegan al interior de un
hijo. ViewEncapsulation.None desactiva todo esto y ShadowDom lo reemplaza
por el aislamiento del propio navegador.
3. ¿Qué va en styles.css y qué en el CSS de un componente?¶
Respuesta:
En styles.css, lo que define la identidad común de la aplicación: variables
CSS (colores, espaciados, tipografías), un reinicio básico y los estilos de
las librerías externas. En el CSS del componente, lo que sólo importa a esa
pieza: así se puede cambiar o borrar el componente sin efectos secundarios en
otro lado, y el aislamiento evita choques de nombres de clase. Un buen patrón
es declarar las variables en :root dentro de styles.css y usarlas con
var(...) desde los componentes: se obtiene aislamiento y consistencia a la
vez. Si hay CSS repetido en muchos componentes, suele ser señal de que falta
una variable o un componente compartido.
4. ¿Qué pasa desde que el navegador abre index.html hasta que se ve la aplicación?¶
Respuesta:
El navegador descarga index.html, que tiene la etiqueta <app-root> vacía y,
agregado por el build, el <script> de main-XXXX.js y el <link> de los
estilos globales. Al ejecutar el script, corre main.ts: llama a
bootstrapApplication(App, appConfig), que crea el componente raíz, aplica la
configuración global (rutas, manejo de errores) y dibuja su plantilla dentro de
<app-root>. Si hay rutas, el router mira la dirección y dibuja el componente
que corresponda en el <router-outlet>. En una SPA el HTML llega casi vacío y
el contenido lo dibuja el JavaScript; con SSR, el servidor ya manda <app-root>
con contenido (Módulo A1).
5. ¿Plantilla dentro del componente o en un archivo aparte? ¿Cambia algo en el resultado?¶
Respuesta:
No cambia el resultado: al construir, Angular compila la plantilla y los
estilos y los incluye en el JavaScript, así que no hay diferencia de
rendimiento ni de tamaño por esa decisión. Es de legibilidad y de trabajo en
equipo. Dentro del componente conviene para plantillas cortas y componentes
pequeños, donde ver la vista junto a la clase ayuda; en un archivo aparte
conviene cuando la plantilla o el CSS son largos, cuando varias personas tocan
vista y lógica por separado, o cuando se quieren diffs de git limpios. Se puede
mezclar (plantilla adentro, estilos afuera) y el generador de componentes lo
permite con --inline-template y --inline-style. Lo que sí importa es la
coherencia: elegir un criterio, por ejemplo "inline si cabe en pantalla", y
aplicarlo en todo el proyecto.
Módulo A4: Temas puntuales¶
Este módulo recorre temas sueltos que se necesitan para armar una aplicación
de varias páginas: moverse entre páginas, repetir y mostrar elementos según una
condición, y poner clases según el estado. Se va llenando a medida que avanza
el curso; por ahora cubre la navegación, el nuevo control flow, la
comunicación entre componentes (input() y output()), los servicios y los
efectos con localStorage.
El código sale de 02-bases/: la barra de navegación en
src/app/components/shared/navbar/, la página dragonball en
src/app/pages/dragonball/ y su versión repartida en componentes,
dragonball-super, con los hijos en src/app/components/dragonball/ y el
servicio en src/app/services/.
El problema que resuelve¶
Una aplicación de una sola página (SPA) tiene varias "páginas", pero el
navegador nunca recarga: Angular cambia lo que hay dentro de <router-outlet />.
Hace falta una forma de ir de una página a otra sin recargar y de marcar
en qué página estás. Y dentro de cada página, hace falta repetir una lista y
mostrar u ocultar cosas según los datos.
RouterLink: enlaces que no recargan¶
Un enlace normal (<a href="/hero">) hace que el navegador pida la página otra
vez y la aplicación arranque de cero. routerLink hace lo mismo para el
usuario, pero le dice a Angular que cambie la ruta, sin recargar:
<a [routerLink]="['hero']">Hero</a>
['hero']es una lista porque la dirección se puede armar por partes:['heroes', id]da/heroes/5. Para una dirección fija también sirverouterLink="hero".- El componente tiene que importar
RouterLink, porque los componentes standalone no ven nada que no importen:
@Component({
imports: [RouterLink, RouterLinkActive],
selector: 'app-navbar',
templateUrl: './navbar.component.html',
})
export class NavbarComponent {}
- No hace falta escribir
href. Angular lo pone solo en la etiqueta<a>(por ejemplohref="/hero"), así que el clic derecho y "abrir en una pestaña nueva" siguen funcionando. Si escribes unhrefa mano, Angular lo sobrescribe.
RouterLinkActive: marcar la página actual¶
routerLinkActive agrega una clase al enlace cuando su ruta es la actual.
Con esa clase se cambia el aspecto:
<a [routerLink]="['']" routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }">Home</a>
<a [routerLink]="['hero']" routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }">Hero</a>
nav a.active {
color: red;
}
Por qué exact: true. Sin esa opción, Angular marca el enlace si la
dirección empieza con la suya. La dirección de Home es '', y todas las
direcciones empiezan con ''. Resultado: estando en /hero, Home y Hero
quedan marcados a la vez. Con exact: true se exige que la dirección sea
exactamente esa.
| Estás en | Home sin exact |
Home con exact |
Hero |
|---|---|---|---|
/ |
marcado | marcado | no |
/hero |
marcado | no | sí |
Rutas que no existen: **¶
La última ruta de la lista, '**', atrapa cualquier dirección que no coincidió
con ninguna. En lugar de mostrar una página, puede redirigir:
export const routes: Routes = [
{ path: '', loadComponent: () => import('./pages/counter/counter-page.component').then((m) => m.CounterPageComponent) },
{ path: 'hero', loadComponent: () => import('./pages/hero/hero-page.component').then((m) => m.HeroPageComponent) },
{ path: 'dragonball', loadComponent: () => import('./pages/dragonball/dragonball-page.component').then((m) => m.DragonballPageComponent) },
{ path: 'dragonball-super', loadComponent: () => import('./pages/dragonball-super/dragonball-super-page.component').then((m) => m.DragonballSuperPageComponent) },
{ path: '**', redirectTo: '' },
]
Si alguien escribe /algo-que-no-existe, termina en /. Va al final,
porque el enrutador usa la primera ruta que coincide (ver el recorrido en el
Módulo A3).
El nuevo control flow: @for, @if y @let¶
Las plantillas ahora tienen bloques propios, escritos con @ y llaves. Se
usan para repetir y para mostrar según una condición (los legacy *ngFor y
*ngIf están en el Módulo A1).
<ul>
@for (character of sortedCharacters(); track character.id; let idx = $index) {
@let isOverpowered = character.powerLevel !== null && character.powerLevel > 9000;
<li>
{{ idx + 1 }} - <strong>{{ character.name }}</strong>
@if (isOverpowered) {
<strong class="overpowered-text">Is Over 9000!</strong>
}
</li>
}
</ul>
| Pieza | Qué hace |
|---|---|
@for (x of lista; ...) { } |
Repite el bloque una vez por elemento |
track character.id |
Obligatorio. Dice qué identifica a cada elemento (ver abajo) |
let idx = $index |
Guarda la posición (0, 1, 2…) en una variable. Por eso se muestra idx + 1 |
@let nombre = expresión; |
Declara una variable local y evita repetir la misma expresión |
@if (condición) { } |
Muestra el bloque sólo si la condición es verdadera |
track: para que Angular no dibuje todo de nuevo. Cuando la lista cambia,
Angular necesita saber qué elemento es cuál. Con track character.id, si se
agrega un personaje, Angular reutiliza los elementos que ya estaban en
pantalla y sólo crea el nuevo. Sin una identidad, tendría que tirar todo y
dibujarlo otra vez. El valor de track debe ser único por elemento: un id,
no la posición.
@let: no repetir la condición. isOverpowered se calcula una vez por
personaje y se usa dos veces (en el @if y, si quieres, en una clase). Es
una variable de la plantilla: no existe en la clase y no se puede usar
fuera del bloque donde se declara (aquí, dentro del @for).
Clases según una condición: alternativas a ngClass¶
Para poner una clase sólo cuando se cumple algo, no hace falta ngClass.
El enlace de clase (class binding) lo hace directo:
<li [class.overpowered]="isOverpowered" [class.weakling]="isYamcha">
[class.overpowered]="isOverpowered"pone la claseoverpoweredsiisOverpoweredes verdadero, y la quita si es falso. Las demás clases del elemento no se tocan.- Para varias clases a la vez, también se puede pasar un objeto:
[class]="{ big: on, bold: !on }". - Lo mismo para estilos sueltos, en lugar de
ngStyle:[style.color]="'red'"o con unidad,[style.width.px]="50".
| Quieres | Con |
|---|---|
| Una clase según una condición | [class.nombre]="condición" |
| Varias clases según condiciones | [class]="{ a: x, b: y }" |
| Un estilo con un valor | [style.color]="valor" |
Un estilo con unidad (px, %, rem) |
[style.width.px]="50" |
Estos enlaces no necesitan importar nada: vienen con Angular. ngClass y
ngStyle siguen existiendo, pero hay que importarlos desde @angular/common,
y para estos casos no aportan nada.
Una lista que se ordena sola¶
La página dragonball junta lo anterior con lo de signals (Módulo A3):
characters: WritableSignal<Character[]> = signal<Character[]>([ /* ... */ ])
sortedCharacters: Signal<Character[]> = computed(() =>
[...this.characters()].sort((a, b) => (b.powerLevel ?? 0) - (a.powerLevel ?? 0)),
)
addCharacter(): void {
// ...
this.characters.update((current) => [...current, newCharacter])
this.resetFields()
}
- La plantilla recorre
sortedCharacters(), nocharacters(): la lista ordenada es uncomputed, así que se recalcula sola cuando se agrega un personaje y el nuevo aparece en su lugar. sortmodifica el arreglo que recibe; por eso se ordena una copia ([...this.characters()]) y no el original.- Para agregar se crea una lista nueva con
update; unpushno avisaría. powerLevelpuede sernull(campo vacío). El?? 0dice "si esnull, cuéntalo como 0" para poder comparar.
Leer lo que escribe el usuario, sin formularios. Cada <input> guarda lo
que se escribe en un signal:
<input type="text" #nameInput [value]="characterName()"
(change)="characterName.set(nameInput.value)"
(input)="characterName.set(nameInput.value)">
<input type="number" #powerLevelInput [value]="characterPowerLevel() ?? ''"
(change)="characterPowerLevel.set(powerLevelInput.value === '' ? null : +powerLevelInput.value)">
#nameInputes una variable de plantilla: una referencia al elemento.nameInput.valuees el texto que tiene en ese momento.[value]="characterName()"hace que el campo muestre el signal. Por eso al hacercharacterName.set('')el campo se vacía.inputochange.inputocurre con cada tecla;changeocurre al terminar (cuando el campo pierde el foco o se pulsa Enter). El nombre usa los dos para que{{ characterName() }}se vea actualizado mientras se escribe.+powerLevelInput.valueconvierte el texto en número (un<input>siempre entrega texto). Ojo: un campo vacío da0, nonull, y0es un poder válido. Por eso se comprueba antes si el campo está vacío y, en ese caso, se guardanull.[value]="characterPowerLevel() ?? ''"muestranullcomo un campo vacío.- Esa conversión está escrita en la plantilla porque esta página es un solo
componente, pero no es lo ideal: la lógica se repite en cada evento y no
se puede probar. En
character-add(más abajo) se hace de otra forma.
Dividir una página en componentes: input() y output()¶
La página dragonball hacía todo en un solo componente. dragonball-super es
la misma página repartida en tres:
DragonballSuperPageComponent la lista de personajes y agregar a la lista
├── CharacterAddComponent el formulario: nombre, poder y el botón Add
└── CharacterListComponent sólo muestra la lista
La regla es que cada componente es dueño de lo suyo: quien tiene el dato lo guarda, y los demás lo reciben o avisan.
El padre le pasa datos al hijo: input().
export class CharacterListComponent {
characters = input.required<Character[]>()
listName = input<string>()
}
<dragonball-character-list
listName="List of DBZ Characters"
[characters]="sortedCharacters()" />
input.required<T>()es obligatorio: si el padre no lo manda, no compila.input<T>()es opcional, y su valor esundefinedsi no lo mandan (por eso su tipo esT | undefined).- Se lee como cualquier signal, con paréntesis:
characters(). - Es de sólo lectura: el hijo no tiene
set. Escribircharacters.set(...)sobre un input no compila. - En el padre, sin corchetes es un texto fijo (
listName="...") y con corchetes es una expresión ([characters]="sortedCharacters()"). Se pasa el valor (con paréntesis), no el signal.
El hijo le avisa al padre: output().
export class CharacterAddComponent {
name = signal('')
powerLevel = signal<number | null>(null)
newCharacter = output<Omit<Character, 'id'>>()
addCharacter(): void {
if (!this.name() || this.powerLevel() === null) {
return
}
this.newCharacter.emit({ name: this.name(), powerLevel: this.powerLevel() })
this.resetFields()
}
}
<dragonball-character-add (newCharacter)="addCharacter($event)" />
// en la página
addCharacter(newCharacter: Omit<Character, 'id'>): void {
const updatedCharacter = { id: this.characters().length + 1, ...newCharacter }
this.characters.update((list) => [...list, updatedCharacter])
}
output<T>()declara un evento propio del componente yemit(valor)lo dispara. En el padre se escucha con paréntesis, y$eventes lo que se pasó aemit.Omit<Character, 'id'>es el tipoCharactersin la propiedadid. El hijo no sabe quéidtoca; lo pone el padre, que es el que conoce la lista.- No le pongas a un
outputel nombre de un evento del navegador (click,input,change): se presta a confusión.
Quién es dueño de qué:
| Dato o lógica | Dónde | Por qué |
|---|---|---|
| Nombre y poder mientras se escriben, validar y vaciar el formulario | character-add |
Es asunto del formulario; el padre no lo necesita |
La lista, ordenarla, agregar con su id |
La página (o un servicio, ver más abajo) | Sólo ella conoce la lista |
| Mostrar la lista | character-list |
No guarda nada: sólo recibe |
El recorrido de un personaje nuevo:
El usuario escribe → character-add guarda en sus signals (name, powerLevel)
Pulsa Add → valida y emite newCharacter
└─ la página recibe $event → characters.update(...) y sortedCharacters se recalcula
└─ character-list recibe la lista nueva por su input y se redibuja
Si el hijo necesita cambiar un dato del padre. Un input no se puede
cambiar desde el hijo. Hay dos caminos: un output por cada cambio (el padre
hace el set) o model(), que es un input que el hijo sí puede cambiar y
que se enlaza en los dos sentidos con [(valor)]="signal". Aquí no hace falta,
porque el formulario es del hijo y al padre sólo le llega el resultado.
Estilos de un hijo. Los estilos de la página no llegan a la plantilla de
sus hijos. Las clases overpowered y weakling se usan dentro de la lista, así
que están en character-list-component.css, y cualquier página que use
<dragonball-character-list> las obtiene. Existe ::ng-deep para forzar la
entrada desde el padre, pero está marcado como obsoleto y aquí no hace falta.
La conversión va en el componente, no en la plantilla. Fíjate en cómo
character-add maneja el campo del poder:
<input type="number" #powerLevelInput [value]="powerLevel() ?? ''"
(change)="characterPowerLevelChanged(powerLevelInput.value)"
(input)="characterPowerLevelChanged(powerLevelInput.value)">
characterPowerLevelChanged(value: string): void {
this.powerLevel.set(value === '' ? null : +value)
}
La plantilla sólo pasa el texto del campo, tal cual; el método decide qué
hacer con él: lo convierte en número y, si está vacío, guarda null (porque
+'' sería 0, un poder real). Así la regla vive en un solo sitio, se escribe
una vez aunque haya dos eventos (input y change) y se puede probar sin la
plantilla. Una plantilla debería leer datos y llamar métodos; las conversiones y
validaciones van en la clase.
Con null bien guardado, la validación del botón es simple
(!this.name() || this.powerLevel() === null), un 0 escrito a propósito sí
cuenta, y el título puede mostrar Power: con un @if (powerLevel() !== null)
sin comprobaciones extra. El título se actualiza mientras se escribe porque el
campo usa (input) además de (change).
Servicios: estado que sobrevive a la navegación¶
El problema. Abre /dragonball, agrega un personaje, ve a Home y vuelve:
el personaje ya no está. Al navegar, Angular destruye el componente de la
página, y con él sus signals. Si una lista tiene que seguir viva al cambiar de
página, no puede guardarse en el componente: necesita un dueño que dure más que
las páginas. Ese dueño es un servicio.
@Injectable({
providedIn: 'root',
})
export class DragonballService {
characters: WritableSignal<Character[]> = signal<Character[]>([
{ id: 1, name: 'Goku', powerLevel: 9001 },
{ id: 2, name: 'Vegeta', powerLevel: 8500 },
])
addCharacter(newCharacter: Omit<Character, 'id'>): void {
const updatedCharacter = { id: this.characters().length + 1, ...newCharacter }
this.characters.update((list) => [...list, updatedCharacter])
}
}
La página dragonball-super ya no guarda la lista: se la pide al servicio.
export class DragonballSuperPageComponent {
public dragonballService = inject(DragonballService)
characters: WritableSignal<Character[]> = this.dragonballService.characters
sortedCharacters: Signal<Character[]> = computed(() =>
[...this.characters()].sort((a, b) => (b.powerLevel ?? 0) - (a.powerLevel ?? 0)),
)
addCharacter(newCharacter: Omit<Character, 'id'>): void {
this.dragonballService.addCharacter(newCharacter)
}
}
@Injectable({ providedIn: 'root' })convierte la clase en un servicio. Con'root'hay una sola instancia para toda la aplicación (un singleton): se crea la primera vez que alguien la pide y vive mientras la aplicación siga abierta.inject(DragonballService)le pide esa instancia a Angular. La forma antigua era el constructor (constructor(private dragonballService: DragonballService) {}); las dos funcionan, peroinject()es la actual y la que se recomienda en componentes standalone.- El reparto. El servicio guarda los datos y las operaciones sobre
ellos (
addCharacter). La página se queda con lo que es de la pantalla: ordenar la lista para mostrarla y pasar los eventos del formulario.
Por qué el estado sobrevive. No es magia: es una cuestión de cuánto vive cada cosa.
| Dónde está el dato | Instancias | Cuánto vive |
|---|---|---|
En el componente (characters = signal()) |
Una por cada componente | Hasta que se destruye (al navegar) |
Servicio con providers: [X] en un componente |
Una por cada componente | Muere con el componente |
Servicio con providedIn: 'root' |
Una para toda la aplicación | Hasta cerrar o recargar la página |
En el proyecto se ve la diferencia: /dragonball guarda su lista en el
componente y se reinicia al volver; /dragonball-super la guarda en el servicio
y se conserva. Un servicio en providers de un componente no sirve para
compartir: cada componente recibe su propia copia, que se destruye con él.
Ojo: así todavía no es persistencia. El servicio guarda el estado en memoria. Si recargas la página (F5) o cierras la pestaña, la aplicación arranca de cero y la lista vuelve a Goku y Vegeta. Lo más exacto es decir "estado compartido que dura mientras la aplicación esté abierta". Para que sobreviva a una recarga hay que guardarlo fuera de la aplicación:
| Opción | Dura | Sirve para |
|---|---|---|
localStorage |
Hasta que se borre | Datos que deben volver al abrir el navegador |
sessionStorage |
Mientras dure la pestaña | Datos temporales de una sesión |
| IndexedDB | Hasta que se borre | Muchos datos u objetos grandes |
| Backend (API) | Siempre | Datos que se comparten entre dispositivos o personas |
¿Y un signal store? Un signal store (por ejemplo el SignalStore de
NgRx) sirve para organizar el estado compartido: es un servicio con más
estructura. Pero también vive en memoria, así que por sí solo tampoco
persiste. La forma habitual es combinarlo con una de las opciones de arriba:
el estado vive en un servicio o store, y un effect lo copia al
localStorage cada vez que cambia. Eso es lo que se hace a continuación.
Efectos y localStorage: persistir de verdad¶
Qué es un efecto secundario. Una función normal recibe datos y devuelve un
resultado. Un efecto secundario (side effect) es cuando, además, hace algo
fuera de sí misma, en el mundo exterior: escribir en localStorage, mostrar
algo en la consola, pedir datos a un servidor, tocar el DOM a mano.
effect() es la herramienta de Angular para eso: una función que se
ejecuta sola cada vez que cambia algún signal que lee.
effect(() => {
localStorage.setItem('characters', JSON.stringify(this.characters()))
})
Como lee this.characters(), Angular la vuelve a ejecutar cada vez que esa lista
cambia. No hay que llamarla a mano.
computed o effect. Se parecen, pero sirven para cosas distintas:
| Quieres... | Usa | Ejemplo |
|---|---|---|
| Un valor que sale de otros signals | computed |
total = computed(() => precio() * cantidad()) |
| Una acción cuando algo cambia | effect |
Guardar el total en localStorage |
computed es puro: calcula y devuelve, sin tocar nada de afuera, y se lee
como un signal. effect no devuelve nada: hace algo. Si te descubres usando un
effect para calcular un valor, lo que querías era un computed.
El servicio con persistencia. Así queda DragonballService:
const loadFromLocalStorage = (): Character[] => {
const characters: string | null = localStorage.getItem('characters')
return characters
? JSON.parse(characters)
: [
{ id: 1, name: 'Goku', powerLevel: 9001 },
{ id: 2, name: 'Vegeta', powerLevel: 8500 },
]
}
@Injectable({
providedIn: 'root',
})
export class DragonballService {
characters: WritableSignal<Character[]> = signal<Character[]>(loadFromLocalStorage())
addCharacter(newCharacter: Omit<Character, 'id'>): void {
const updatedCharacter = { id: this.characters().length + 1, ...newCharacter }
this.characters.update((list) => [...list, updatedCharacter])
}
saveToLocalStorage = effect(() => {
localStorage.setItem('characters', JSON.stringify(this.characters()))
})
}
Son dos mitades que se complementan:
Al arrancar → loadFromLocalStorage() lee lo guardado (o usa la lista inicial)
Cada vez que cambia → el effect guarda la lista completa en localStorage
- Lo guardado vuelve tras recargar. Si agregas un personaje y recargas la
página (F5), el servicio nace de nuevo, lee el
localStoragey la lista sigue ahí, con Gohan incluido. - El efecto corre una vez al crearse. Por eso la lista inicial se escribe en
localStorageaunque nadie haya agregado nada. Después corre con cada cambio. - Sólo reacciona a lo que lee. Si dentro del efecto no se lee un signal, sus cambios no lo disparan.
- Se limpia solo. Un efecto creado dentro de un servicio o componente se detiene cuando su dueño se destruye. El servicio es de la raíz, así que vive mientras viva la aplicación.
- Un efecto, una responsabilidad. Este sólo guarda. Los
console.logque puedas poner dentro sirven para ver cuándo corre, pero en una aplicación real se quitan.
Cómo funciona localStorage:
| Detalle | Qué significa |
|---|---|
| Sólo guarda texto | Por eso se usa JSON.stringify al guardar y JSON.parse al leer |
| Es síncrono y de poco espacio | Sirve para cosas pequeñas, no para grandes cantidades de datos |
| Es del navegador y del sitio | Cada navegador y cada dirección tiene el suyo: no viaja entre dispositivos |
| No es seguro | Cualquier script de la página o cualquiera con las herramientas del navegador puede leerlo |
Nunca guardes contraseñas ni datos personales sensibles en localStorage.
Un riesgo: datos dañados o manipulados. Lo que se lee del localStorage es
información que no controlas: puede estar rota (un error, una versión
anterior de la aplicación que guardaba otra forma) o puede haber sido
modificada a propósito, porque cualquiera con las herramientas del navegador
puede editarla. El servicio de arriba no se protege. Dos ejemplos de lo que
podría llegar:
- Texto roto (por ejemplo
{roto).JSON.parselanza un error al crear el servicio, y la parte de la aplicación que lo usa deja de funcionar. El texto dañado sigue en ellocalStoragehasta que alguien lo borra a mano. - JSON válido, pero que no es una lista (por ejemplo
{}).JSON.parseno falla, pero el error aparece después, cuando la página intenta recorrer o copiar algo que no es una lista.
Son sólo ejemplos. Lo importante es la regla: normalmente se valida todo lo
que viene del localStorage antes de usarlo, y no sólo que no esté roto.
También que tenga la forma esperada (una lista, y que cada personaje tenga id
numérico, name de texto y powerLevel numérico o null) y, si hace falta,
valores razonables. Un texto que se puede leer sin error puede seguir siendo
inválido o haber sido manipulado.
En una aplicación real conviene leerlo con un try/catch, comprobar la forma de
lo leído y, si algo falla, volver a la lista inicial:
const isCharacter = (value: unknown): value is Character => {
const c = value as Character
return (
typeof value === 'object' &&
value !== null &&
typeof c.id === 'number' &&
typeof c.name === 'string' &&
(c.powerLevel === null || typeof c.powerLevel === 'number')
)
}
const loadFromLocalStorage = (): Character[] => {
try {
const characters = localStorage.getItem('characters')
if (characters) {
const parsed = JSON.parse(characters)
if (Array.isArray(parsed) && parsed.every(isCharacter)) {
return parsed
}
}
} catch {
// datos dañados: se ignoran y se usa la lista inicial
}
return [
{ id: 1, name: 'Goku', powerLevel: 9001 },
{ id: 2, name: 'Vegeta', powerLevel: 8500 },
]
}
Así, un texto roto, una forma distinta o un personaje a medias terminan en la
lista inicial, y el effect la guarda encima la primera vez que corre,
dejando datos buenos en el localStorage.
Validar aquí no es seguridad. Esta validación protege a la aplicación de romperse con datos raros; no impide que el usuario modifique sus propios datos, porque están en su navegador. Si un dato importa de verdad (precios, permisos, saldos), la verificación definitiva se hace en el servidor.
Y con SSR (el servidor arma la página) localStorage es del navegador y no
siempre está disponible en el servidor, así que habría que protegerlo; en esta
aplicación, que es una SPA, no hace falta.
Errores comunes¶
- Usar
<a href="/hero">en lugar derouterLink: la página se recarga y se pierde el estado de la aplicación. - Olvidar
imports: [RouterLink]en un componente standalone: el enlace no funciona. - Poner
routerLinkActiveen el enlace de Home sinexact: true: Home queda marcado en todas las páginas. - Escribir la ruta
'**'antes de otras: las que vienen después nunca se alcanzan. - Usar la posición (
$index) comotrack: al reordenar la lista, Angular cree que cada posición sigue siendo el mismo elemento. - Olvidar el
tracken un@for: no compila. - Ordenar con
sort()el arreglo del signal, sin copiarlo. - Convertir con
+input.valuey no darse cuenta de que el vacío se vuelve0. - Escribir conversiones o validaciones dentro de la plantilla
(
valor === '' ? null : +valor): se repiten en cada evento y no se pueden probar. Van en un método del componente, que reciba el texto del campo. - Guardar en un componente algo que debe sobrevivir a la navegación: el componente se destruye al cambiar de página y se lleva sus signals.
- Poner el servicio en el
providersde un componente esperando compartirlo entre páginas: cada componente crea su propia copia. - Creer que el estado de un servicio sobrevive a recargar la página: vive en memoria, y la recarga lo borra, a menos que se guarde aparte.
- Usar un
effectpara calcular un valor: eso es uncomputed. Eleffectes para acciones (guardar, avisar, registrar). - Guardar contraseñas o datos sensibles en
localStorage: cualquiera con acceso al navegador puede leerlos. - Confiar en lo que viene del
localStoragesin validarlo: puede estar roto o haber sido manipulado. Hay que protegerse del texto roto y comprobar que tiene la forma esperada. - Escribir
algo.set(...)sobre uninput(): no compila, los inputs son de sólo lectura. Usa unoutput(omodel()). - Pasar el signal en lugar de su valor a un input (
[characters]="lista"en vez de[characters]="lista()"): el tipo no coincide. - Esperar que el CSS de la página llegue a la plantilla de un componente hijo: las clases que usa el hijo van en el CSS del hijo.
Resumen¶
routerLink cambia de página sin recargar y routerLinkActive marca la actual;
Home necesita exact: true. La ruta '**' al final redirige lo que no existe.
@for repite (con track obligatorio, mejor un id), @if muestra según una
condición y @let evita repetir una expresión. Para clases y estilos según una
condición bastan [class.x] y [style.x], sin ngClass ni ngStyle. Una
lista ordenada es un computed sobre una copia, y lo que se escribe en un
<input> se guarda en un signal con (input) o (change). Al repartir una
página en componentes, el padre pasa datos con input() (sólo lectura) y el
hijo avisa con output(); cada componente es dueño de lo suyo. Lo que debe
sobrevivir a la navegación se guarda en un servicio providedIn: 'root', que
es único y no se destruye con las páginas. Vive en memoria, así que una
recarga lo borra; para persistirlo, un effect (una acción que corre cuando
cambia un signal que lee) lo guarda en localStorage, y el servicio lo lee al
arrancar.
Preguntas de entrevista (senior)¶
1. ¿Qué diferencia hay entre <a href> y <a routerLink> en una SPA?¶
Respuesta:
Con href, el navegador hace una petición nueva: descarga el documento, vuelve
a arrancar Angular y se pierde el estado en memoria. Con routerLink, Angular
intercepta el clic, cambia la ruta con el enrutador y dibuja el componente que
corresponde dentro de <router-outlet />, sin recargar. Además Angular escribe
el href real en la etiqueta, así que se conserva el comportamiento normal del
navegador (abrir en otra pestaña, copiar el enlace) y los buscadores ven un
enlace válido.
2. En una barra de navegación, el enlace a Home queda siempre marcado como activo. ¿Por qué y cómo se arregla?¶
Respuesta:
routerLinkActive marca el enlace cuando la dirección actual empieza con la
del enlace. La de Home es la vacía (''), y todas las direcciones empiezan con
ella. Se arregla con [routerLinkActiveOptions]="{ exact: true }" en ese enlace,
que exige coincidencia exacta. En los demás enlaces no suele hacer falta; en los
que tienen rutas hijas conviene dejarlo sin exact para que el padre siga
marcado.
3. ¿Por qué @for exige track y qué conviene poner?¶
Respuesta:
Porque Angular necesita identificar cada elemento para saber, cuando la lista
cambia, cuáles reutilizar, cuáles crear y cuáles mover. Con una identidad
estable (un id) agregar o reordenar sólo toca los elementos afectados y
conserva el estado de los demás (por ejemplo, el texto escrito en un campo de
cada fila). Con $index como identidad, al insertar al principio todas las
posiciones cambian de dueño y se rehace casi todo, o se mezcla el estado entre
filas. La regla: track item.id si hay un identificador único; $index sólo si
la lista no cambia de orden.
4. ¿Cómo reemplazas ngClass y ngStyle en una plantilla actual?¶
Respuesta:
Con enlaces nativos de Angular: [class.nombre]="condición" para una clase,
[class]="{ a: x, b: y }" para varias, [style.color]="valor" para un estilo y
[style.width.px]="n" cuando lleva unidad. No necesitan importar nada, son más
cortos y no dejan clases de más: las clases fijas del elemento (class="fixed")
se conservan junto a las condicionales. ngClass y ngStyle siguen
funcionando, pero obligan a importar la directiva y para estos casos no aportan
ventaja.
5. Quieres mostrar una lista ordenada que cambia cuando el usuario agrega elementos. ¿Cómo la modelas?¶
Respuesta:
Los datos viven en un signal y la lista ordenada es un computed que lee ese
signal. Dentro del computed se ordena una copia ([...lista()].sort(...))
porque sort modifica el arreglo original sin avisar. Para agregar se crea una
lista nueva con update ([...actual, nuevo]); un push cambiaría el arreglo
por dentro y el signal no se enteraría. La plantilla recorre el computed, y
al agregar un elemento el computed se recalcula solo y el elemento aparece en
su lugar, sin código extra de sincronización.
6. ¿Cómo se comunican un componente padre y uno hijo en Angular actual?¶
Respuesta:
Los datos bajan con input() y los avisos suben con output(). Un
input es un signal de sólo lectura: el hijo lo lee (valor()) pero no lo
cambia. Puede ser obligatorio (input.required) u opcional. Un output declara
un evento propio; el hijo lo dispara con emit(valor) y el padre lo escucha con
(evento)="manejar($event)". Cuando el hijo necesita editar un dato del padre
hay dos caminos: un output por cada cambio, donde el padre hace el set, o
model(), un input que el hijo también puede cambiar y que se enlaza en los dos
sentidos con [(valor)]. Con esto el dato siempre tiene un solo dueño y el flujo
es fácil de seguir.
7. Al separar una página en componentes, ¿dónde va la lógica de "agregar un elemento"?¶
Respuesta:
Se reparte según quién es dueño de cada cosa. La parte del formulario (leer
lo que escribe el usuario, validar y vaciar los campos) va en el componente del
formulario, con sus propios signals; el padre no necesita ver cada tecla. La
parte de la lista (agregar, ordenar, asignar el id) va en quien guarda la
lista: la página, o un servicio si la comparten varias páginas. El formulario
emite un solo evento con el elemento listo, sin el id (Omit<Character, 'id'>),
y el dueño de la lista lo agrega con update y una lista nueva. Así el
formulario se reutiliza con otra lista y la lista se muestra sin saber de dónde
vienen los datos. Sólo conviene dejar el estado del formulario en el padre si el
padre necesita verlo mientras se escribe.
8. ¿Por qué el estado de un servicio sobrevive a la navegación y el de un componente no? ¿Es persistencia?¶
Respuesta:
Porque tienen distinta vida. Al navegar, el enrutador destruye el componente de
la página y con él sus propiedades y signals. Un servicio con
providedIn: 'root' es un singleton del inyector raíz, que vive tanto como la
aplicación, así que su estado sigue ahí cuando se crea la página siguiente. Si
en cambio se declara en el providers de un componente, cada instancia del
componente crea su propia copia y la destruye al irse, y no sirve para compartir.
No es persistencia: es estado en memoria, y una recarga o cerrar la pestaña lo
pierde. Para persistir de verdad hay que escribirlo en localStorage,
sessionStorage, IndexedDB o un backend, normalmente con un effect que copia
el estado cada vez que cambia. Un signal store ayuda a organizar ese estado
compartido, pero tampoco persiste por sí solo.
9. ¿Qué es un efecto secundario y cuándo usas effect() en lugar de computed()?¶
Respuesta:
Un efecto secundario es una acción que sale de la función y toca algo externo:
escribir en localStorage, registrar en consola, llamar a un servidor o tocar el
DOM. effect() ejecuta una función cada vez que cambia algún signal que lee
(corre una vez al crearse y después con cada cambio), y se limpia solo cuando su
dueño se destruye. computed() sirve para calcular un valor derivado: es
puro, devuelve un signal y no debe tocar nada de afuera. La regla es: si
necesitas un valor, computed; si necesitas una acción, effect. Usar un
effect para derivar un valor obliga a escribirlo en otro signal y a mantener
ambos sincronizados, que es justo lo que computed evita. Para persistir, un
effect en el servicio que guarda el estado en localStorage (con
JSON.stringify) y un valor inicial que lo lee al arrancar (con JSON.parse y
un try/catch), sin guardar nunca datos sensibles.
Módulo A5: Desplegar en Coolify¶
Este módulo no sale de una clase del curso: cuenta cómo se publicó 02-bases
en internet, en https://angular-basics.reskyon.com, con Coolify. Los
archivos están en 02-bases/: el Dockerfile, el nginx.conf y el
.dockerignore. El README.md del proyecto tiene la misma configuración en
forma de lista de pasos.
El problema que resuelve¶
ng serve sirve para trabajar, pero sólo vive en tu computadora y compila en
memoria cada vez que guardas. Para que otra persona abra la aplicación hace
falta construirla una vez (ng build) y dejar los archivos resultantes en
un servidor que los entregue por HTTPS, con un dominio propio.
Qué se publica en realidad¶
Una SPA de Angular, una vez construida, son archivos estáticos: un
index.html, varios .js y .css y lo que haya en public/. Quedan en
dist/bases/browser/. No hay un programa de Angular corriendo en el servidor:
el servidor sólo entrega archivos, y toda la lógica (rutas, signals,
servicios) corre en el navegador de quien visita la página.
Por eso dist/ no se sube al repositorio (está en el .gitignore):
Coolify ejecuta el build en cada despliegue.
El camino de un despliegue y de una visita¶
git push a main
|
v
Coolify clona Reskyon/angular-ufh y entra en /02-bases
|
v
Etapa 1: node:24-alpine npm ci + npm run build -> dist/bases/browser
|
v
Etapa 2: nginx:alpine sólo los archivos estáticos, puerto 80
|
v
Contenedor en marcha
Visita: navegador -> Cloudflare -> Traefik (proxy de Coolify) -> Nginx
Cloudflare resuelve el dominio y protege el tráfico; Traefik recibe la petición en el servidor, pone el certificado HTTPS y la manda al contenedor correcto; Nginx entrega los archivos.
Construir en dos etapas: el Dockerfile¶
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist/bases/browser /usr/share/nginx/html
EXPOSE 80
La primera etapa tiene Node y todas las dependencias, y sólo sirve para
compilar. La segunda parte de una imagen limpia de Nginx y copia sólo el
resultado. La imagen final no tiene Node, ni node_modules, ni el código
fuente: es más chica y tiene menos cosas que atacar.
Se copia primero package.json y package-lock.json y después el resto para
aprovechar la caché de Docker: si sólo cambió un componente, no reinstala las
dependencias.
Nginx y las rutas de Angular¶
Las rutas de Angular, como /dragonball-super, existen sólo en el navegador:
las resuelve el enrutador de Angular dentro de <router-outlet />. En el
servidor no hay ningún archivo dragonball-super. Si recargas esa página, el
navegador se la pide a Nginx, y sin configuración extra Nginx responde que no
existe.
La solución es el fallback: si el archivo pedido no existe, se entrega
index.html y Angular se encarga de la ruta.
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
Es la misma idea que la ruta ** del enrutador, pero un nivel antes: **
atrapa lo que Angular no conoce; try_files atrapa lo que el servidor no
conoce y se lo pasa a Angular. Donde la comparación deja de ser exacta: con el
fallback el servidor responde siempre con éxito, aunque la ruta no exista;
quien muestra la página de "no encontrado" es la ruta ** de Angular.
Los .js y .css llevan un código en el nombre que cambia en cada build, así
que se guardan en caché un año. index.html no se guarda nunca, para que cada
visita vea el último despliegue.
Por qué no el modo por defecto de Coolify¶
Coolify trae Nixpacks, que detecta que es un proyecto de Node y arma la
imagen solo. El primer intento falló: Nixpacks instaló Node 22.11.0 y
Angular 22 pide Node 22.22.3, 24.15.0 o más nuevo, así que ng build se
detuvo antes de compilar. La variable NIXPACKS_NODE_VERSION sólo elige la
versión mayor dentro de un paquete fijo que va atrasado. Con un Dockerfile
propio la versión de Node queda escrita en el repositorio.
La configuración en Coolify¶
| Campo | Valor | Por qué |
|---|---|---|
| Repositorio | Reskyon/angular-ufh |
El repositorio entero, no el enlace tree/main/... |
| Rama | main |
Cada push a main despliega |
| Build pack | Dockerfile | Para controlar la versión de Node |
| Base directory | /02-bases |
Coolify trabaja dentro de esa carpeta |
| Dockerfile location | /Dockerfile |
Relativo a la base directory |
| Ports exposes | 80 |
El puerto donde escucha Nginx dentro del contenedor |
| Dominio | https://angular-basics.reskyon.com |
Traefik pide el certificado para este nombre |
Pendientes opcionales: poner 02-bases/** en Watch paths para que un push
que sólo toca otras carpetas no redespliegue, y borrar la variable
NIXPACKS_NODE_VERSION, que quedó del primer intento y ya no se usa.
DNS y HTTPS con Cloudflare¶
En Cloudflare hay un registro A llamado angular-basics que apunta a
<SERVER_IP>, con el proxy activado (nube naranja). El modo SSL de
Cloudflare es Full (strict): exige que el servidor tenga un certificado
válido, y ese certificado lo pide Traefik a Let's Encrypt cuando el
contenedor ya está corriendo.
Si el despliegue falla, no hay contenedor ni ruta para el dominio. Traefik responde con un certificado propio que nadie reconoce y Cloudflare muestra Invalid SSL certificate. El arreglo es corregir el despliegue, no tocar Cloudflare.
¿Se puede atacar el servidor desde el formulario?¶
No. La página no tiene backend: el formulario de dragonball-super guarda los
personajes en el localStorage del navegador de cada visitante y nunca envía
nada al servidor. Si alguien escribe código raro, queda en su propio
navegador, y además Angular escapa lo que se muestra con {{ }}. El
contenedor sólo tiene Nginx sirviendo archivos, sin claves ni acceso a otros
servicios. Esto cambia cuando la aplicación empiece a llamar a una API: ahí
la validación del servidor es la que importa.
Errores comunes¶
- Pegar en Coolify la URL
tree/main/02-bases. Va el repositorio y la carpeta se indica en Base directory. - Subir
dist/al repositorio. Se genera en cada despliegue. - Repetir la carpeta en las rutas: con base directory
/02-bases, el Dockerfile es/Dockerfile, no/02-bases/Dockerfile. - Olvidar el fallback de Nginx: la página carga desde el inicio, pero al recargar una ruta da "no encontrado".
- Crear el dominio en Coolify antes del registro DNS: Let's Encrypt no puede validar un nombre que no existe.
- Buscar el problema en Cloudflare cuando aparece un error de certificado: casi siempre el despliegue falló y no hay contenedor.
Resumen¶
- Una SPA construida son archivos estáticos; el servidor sólo los entrega.
- El
Dockerfilecompila con Node en una etapa y sirve con Nginx en otra. try_files ... /index.htmldeja que Angular resuelva sus rutas al recargar.- En Coolify: repositorio entero, base directory
/02-bases, build pack Dockerfile, puerto 80. - Cloudflare en modo estricto necesita que el despliegue funcione para que haya certificado.
Preguntas de entrevista (senior)¶
1. ¿Por qué una SPA necesita un fallback a index.html en el servidor?¶
Respuesta:
Porque las rutas de una SPA las resuelve el enrutador en el navegador, no el
servidor. Al navegar con enlaces, Angular cambia la URL sin pedir nada al
servidor. Pero si el usuario recarga o abre un enlace directo como
/dragonball-super, el navegador pide ese camino al servidor, que no tiene
ningún archivo con ese nombre. El fallback (try_files $uri $uri/ /index.html
en Nginx) entrega index.html para cualquier camino que no sea un archivo
real; Angular arranca, lee la URL y muestra la página. La consecuencia es que
el servidor responde con éxito incluso para rutas inexistentes, así que la
página de "no encontrado" la tiene que poner Angular con la ruta **. Si se
necesita que los buscadores reciban un "no encontrado" real, hace falta
renderizado en el servidor o prerenderizado.
2. ¿Qué ganas con un build en dos etapas?¶
Respuesta:
Separas las herramientas de construcción de lo que se ejecuta. La primera
etapa necesita Node, el CLI y cientos de dependencias de desarrollo; la
segunda sólo necesita un servidor de archivos. Copiando únicamente
dist/bases/browser a una imagen de Nginx, la imagen final pesa mucho menos,
arranca más rápido y no lleva compiladores, node_modules ni código fuente,
así que hay menos cosas que puedan tener una vulnerabilidad. Además, copiar
primero los archivos de dependencias y luego el código permite que Docker
reutilice la capa de npm ci mientras package-lock.json no cambie.
3. El despliegue falló y el dominio muestra un error de certificado de Cloudflare. ¿Qué miras primero?¶
Respuesta:
El despliegue, no Cloudflare. Con el modo Full (strict), Cloudflare exige un certificado válido en el servidor. Ese certificado lo emite Traefik cuando existe un contenedor con ese dominio. Si el build falló, no hay contenedor ni ruta: Traefik contesta con su certificado por defecto, que no es de confianza, y Cloudflare lo rechaza. Primero se revisa el log del despliegue en Coolify y que el contenedor esté corriendo. Después, que el registro DNS exista, porque sin él Let's Encrypt no puede validar el dominio. Bajar el modo SSL de Cloudflare taparía el síntoma y dejaría el tramo entre Cloudflare y el servidor sin verificar.
4. ¿Por qué fijar la versión de Node en el Dockerfile en lugar de dejar que la plataforma la elija?¶
Respuesta:
Porque cada versión de Angular exige un rango concreto de Node, y si la
plataforma elige la versión, el build depende de algo que no está en el
repositorio. Aquí Nixpacks instaló un Node 22 más viejo que el mínimo de
Angular 22 y el build se cortó. Con FROM node:24-alpine la versión queda
escrita junto al código, el mismo Dockerfile se puede probar en local con
docker build, y cambiar de versión es un cambio revisable en git. Fijarla
aún más (por ejemplo node:24.15-alpine) da builds más repetibles a cambio de
actualizar a mano.
5. ¿Un formulario de una SPA sin backend puede servir para atacar el servidor?¶
Respuesta:
No directamente, porque los datos nunca llegan al servidor: en este proyecto
se guardan en el localStorage del propio visitante. Lo peor que puede hacer
alguien es ensuciar su propio navegador, y Angular escapa lo que se muestra
con interpolación, así que el texto no se ejecuta como HTML. Los riesgos
reales pasan a ser otros: usar innerHTML o bypassSecurityTrust* con datos
del usuario, guardar datos sensibles en localStorage (cualquier script de la
página puede leerlos) y, el día que haya una API, confiar en la validación del
cliente. La validación que protege al servidor siempre tiene que estar en el
servidor.