Repository navigation
03 gifs app
Esta página es una parte de la guía de estudio; allí están el
mapa general y la ruta recomendada. La Parte 2, sobre las bases de Angular, está
en 02-bases. Proyecto: 03-gifs-app/.
Es una aplicación Angular 22 creada con ng new, con SCSS para los estilos de
cada componente y Tailwind CSS para las clases de utilidad. Tiene un dashboard
con menú lateral y dos pantallas: Trending, que muestra una lista de imágenes
de prueba, y Search, que todavía es una página vacía.
Quieres escribir class="text-3xl font-bold" en el HTML y que funcione, sin
crear un archivo de estilos para cada detalle. Eso es Tailwind CSS: una colección
de clases pequeñas (utility classes), cada una con un solo trabajo. El proyecto
ya usa SCSS, y la pregunta es si pueden convivir. Sí pueden, con un cuidado.
Tailwind v4 se instala como un plugin de PostCSS (la herramienta que procesa el CSS después de escribirlo). Angular ya sabe leer su configuración.
-
Instala los paquetes dentro de
03-gifs-app/:npm install tailwindcss @tailwindcss/postcss postcss
-
Crea
.postcssrc.jsonen la raíz del proyecto, para decirle a Angular que use el plugin:{ "plugins": { "@tailwindcss/postcss": {} } } -
Crea
src/tailwind.csscon una sola línea:@import "tailwindcss";
-
Cárgalo en
angular.json, antes de tu SCSS:"styles": [ "src/tailwind.css", "src/styles.scss" ]
-
Reinicia
ng servey prueba una clase enapp.html:<h1 class="text-3xl text-blue-500 font-bold">Welcome to the GIFs App</h1>
La primera idea es escribir @import "tailwindcss"; dentro de styles.scss.
Funciona, pero Sass cree que ese @import es suyo y avisa que esa forma está
obsoleta (deprecated) y desaparecerá en una versión futura. Para evitar el
aviso, el import de Tailwind va en un archivo .css, que Sass no toca, y
styles.scss queda para tus estilos propios.
angular.json
│
├── src/tailwind.css ──► PostCSS + Tailwind ──┐
│ ├──► styles.css final
└── src/styles.scss ───► Sass ────────────────┘
- Las clases de Tailwind se usan directo en el HTML. No hace falta tocar el
.scssdel componente. - Dentro del
.scssde un componente,@applyy@themeno funcionan solos, porque ese archivo no sabe qué es Tailwind. Si los necesitas, empieza el archivo con@reference "tailwindcss";. - Después de cambiar
.postcssrc.jsonoangular.json, reiniciang serve: Angular los lee sólo al arrancar.
- Poner
@import "tailwindcss"enstyles.scss: da el aviso de Sass. - Olvidar agregar
src/tailwind.cssastylesenangular.json: las clases no hacen nada y no hay error. - No reiniciar
ng servetras crear.postcssrc.json.
Tailwind v4 = tres paquetes + .postcssrc.json + un .css con su import,
cargado desde angular.json. SCSS sigue igual para lo propio de cada componente.
Respuesta:
Porque Sass interpreta el @import antes de que Tailwind lo vea, y esa sintaxis
está obsoleta en Sass (se eliminará en Dart Sass 3). En un .css, el import
llega intacto a PostCSS, que es quien lo entiende. Por eso los dos archivos se
listan por separado en styles de angular.json.
Respuesta:
Es donde Angular busca los plugins de PostCSS que debe aplicar al CSS. Ahí se
registra @tailwindcss/postcss, que convierte @import "tailwindcss" en las
clases de utilidad. Sin ese archivo, Tailwind no se ejecuta y las clases quedan
sin efecto.
Respuesta:
No directamente. Cada .scss de componente se compila por separado y no conoce
Tailwind. Hay que declarar @reference "tailwindcss"; al inicio del archivo
para que sepa qué clases existen, y aun así lo más simple es poner las clases en
el HTML.
Respuesta:
La ventaja es la velocidad y la consistencia: los valores (espacios, colores, tamaños) salen de una escala común y no inventas nombres de clases. El costo es que el HTML se llena de clases largas, y repetirlas en varios lugares obliga a extraer un componente. SCSS sigue siendo mejor para estilos muy específicos de un componente.
La app tiene tres pantallas: el dashboard, Trending y Search. Si el navegador descargara el código de las tres al abrir la página, el arranque sería más pesado de lo necesario, sobre todo cuando la app crezca. La carga perezosa (lazy loading) hace que cada pantalla se descargue sólo cuando el usuario entra a ella.
// app.routes.ts
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () => import('./gifs/pages/dashboard/dashboard-page.component'),
children: [
{
path: 'trending',
loadComponent: () => import('./gifs/pages/trending/trending-page.component'),
},
{
path: 'search',
loadComponent: () => import('./gifs/pages/search/search-page.component'),
},
{ path: '**', redirectTo: 'trending' },
],
},
{ path: '**', redirectTo: 'dashboard' },
]-
loadComponentrecibe una función que hace unimport(): le dice a Angular "descarga este archivo sólo cuando se visite esta ruta". -
childrenson las rutas que se dibujan dentro de la página padre. El dashboard tiene un<router-outlet />en su plantilla, y ahí aparece Trending o Search. Por eso las URLs son/dashboard/trendingy/dashboard/search. -
**significa "cualquier otra URL". Aquí no muestra una página: redirige.
Al compilar con ng build, Angular genera un archivo aparte por cada página
(dashboard-page-component, trending-page-component, search-page-component)
además del archivo principal.
import() devuelve el archivo completo como un objeto. Con una exportación
nombrada, hay que decirle a Angular cuál de sus partes es el componente:
// export class DashboardPageComponent {}
loadComponent: () =>
import('./dashboard-page.component').then((m) => m.DashboardPageComponent)Con export default, Angular ya sabe cuál tomar y basta con el import():
// export default class DashboardPageComponent {}
loadComponent: () => import('./dashboard-page.component')| Componente | Exportación |
|---|---|
Página que se carga por una ruta (loadComponent) |
export default class |
Componente que se usa en la plantilla de otro (imports) |
export class |
Las páginas del proyecto (DashboardPageComponent, TrendingPageComponent,
SearchPageComponent) llevan default. Los componentes de pieza
(SideMenuComponent, GifListComponent, etc.) llevan nombre.
Angular recorre las rutas de arriba hacia abajo y se queda con la primera que
coincide. ** coincide con todo, así que si va primero, tapa a las demás. Este
proyecto tiene dos:
- Dentro de
dashboard: cualquier URL desconocida va atrending. - En la raíz: cualquier URL desconocida va a
dashboard.
Así, /cualquier-cosa termina en /dashboard/trending.
- Poner
**antes que otras rutas: las demás nunca se alcanzan. - Usar
export defaulten un componente que luego se mete enimportsde otro: en este proyecto no lo reconoció, y se arregló volviendo aexport class. - Olvidar el
<router-outlet />en la página padre: las rutas hijas cambian la URL, pero no se ve nada.
loadComponent + import() = la pantalla se descarga al visitarla. children
anida rutas dentro de un <router-outlet />. export default evita el
.then(...), pero sólo para páginas de ruta.
Respuesta:
Es descargar el código de una pantalla sólo cuando el usuario la visita, en vez
de meter todo en el archivo inicial. Gana un arranque más rápido, porque el
navegador baja menos código al principio. En rutas con componentes standalone se
logra con loadComponent y un import() dinámico.
Respuesta:
Porque loadComponent sabe tomar la exportación default del archivo que
devuelve import(). Con una exportación nombrada, el archivo trae varias cosas
posibles y hay que indicar cuál es el componente.
Respuesta:
No. Sólo las páginas que se cargan por ruta. Los componentes que se usan en la
plantilla de otro se importan por nombre en su imports, y ahí export class
con nombre es lo esperado y lo que funciona en este proyecto.
Respuesta:
Una ruta con children dibuja las hijas dentro de la plantilla del padre, en su
<router-outlet />, así que el padre (aquí el dashboard con su menú) se queda
en pantalla y sólo cambia la parte interior. Con rutas planas, cada ruta
reemplaza la página completa.
Respuesta:
Porque Angular usa la primera ruta que coincide, y ** coincide con cualquier
URL. Al final actúa como "si nada más coincidió": en este proyecto, redirige a
una página válida.
El diseño del dashboard salió de una plantilla ya hecha en la web: un solo HTML largo con el encabezado, el menú y las opciones mezclados. Difícil de leer y de cambiar. La solución es partirlo en componentes pequeños, cada uno responsable de una sola parte de la pantalla.
dashboard-page
├── gifs-side-menu
│ ├── gifs-side-menu-header logo, eslogan y perfil
│ └── gifs-side-menu-options enlaces del menú
└── router-outlet aquí entra Trending o Search
La plantilla del dashboard queda corta:
<gifs-side-menu />
<div class="ml-55 px-4 flex-col flex-1 h-full text-slate-800">
<router-outlet />
</div>Un componente standalone es su propio "módulo": para usar la etiqueta de otro
componente en su plantilla, tiene que traer su clase en imports.
// dashboard-page.component.ts
imports: [RouterOutlet, SideMenuComponent]
// side-menu.component.ts
imports: [SideMenuHeaderComponent, SideMenuOptionsComponent]Si la clase no está en imports, Angular no sabe qué es <gifs-side-menu /> y
se queja de que no conoce esa etiqueta.
-
Prefijo
gifs-en los selectores (gifs-side-menu,gifs-side-menu-header): evita choques con etiquetas de HTML u otras librerías, y deja claro de dónde viene el componente. -
Menú fijo y contenido con margen: el menú es
fixedcon anchow-55y el contenido llevaml-55, el mismo valor. Si cambias uno, cambia el otro. - Un componente, una responsabilidad: el menú no sabe qué página se muestra, y las páginas no saben cómo se dibuja el menú.
- Usar una etiqueta sin agregar el componente a
imports. - Cambiar el ancho del menú y olvidar el margen del contenido: el menú tapa el contenido.
Partir una plantilla grande en componentes = HTML más corto, cada pieza con un
solo trabajo y dependencias visibles en imports.
Respuesta:
Porque cada pieza queda pequeña y con una sola responsabilidad: es más fácil de leer, de probar y de reutilizar, y un cambio en el menú no obliga a tocar la página. Se paga con más archivos, pero cada uno es corto.
Respuesta:
Angular no reconoce la etiqueta y falla al compilar. En componentes standalone,
cada plantilla sólo conoce lo que aparece en su propio imports: nada se hereda
del componente padre.
Respuesta:
Para evitar choques de nombre con etiquetas HTML nativas y con componentes de librerías, y para ubicar de dónde viene la etiqueta al leer una plantilla.
El menú tenía un <a> escrito a mano por cada opción, todos casi iguales. Agregar
una opción significaba copiar y pegar HTML. La idea es guardar las opciones en un
arreglo y dejar que la plantilla dibuje un enlace por cada una.
interface MenuOption {
icon: string
label: string
route: string
subLabel: string
}
menuOptions: MenuOption[] = [
{ icon: 'fa-solid fa-chart-line', label: 'Trending', route: 'trending', subLabel: 'Popular gifs' },
{ icon: 'fa-solid fa-magnifying-glass', label: 'Search', route: 'search', subLabel: 'Find gifs' },
]La interface define la forma de cada opción: TypeScript avisa si falta un campo
o si se escribe mal.
@for (option of menuOptions; track option.route) {
<a [routerLink]="option.route"
routerLinkActive="bg-blue-800"
[routerLinkActiveOptions]="{ exact: true }">
<i [class]="option.icon"></i>
<span>{{ option.label }}</span>
<span>{{ option.subLabel }}</span>
</a>
}-
@forrepite el bloque por cada opción.track option.routele da a Angular una identificación única de cada elemento, para no redibujar todo cuando algo cambia. -
[routerLink]convierte el<a>en un enlace de Angular: navega sin recargar la página. -
routerLinkActive="bg-blue-800"agrega esa clase al enlace de la página actual, y así el menú muestra dónde estás. -
exact: truepide que el enlace esté activo sólo si la URL coincide completa. Aquí no cambia el resultado, pero evita que un enlace "padre" quede marcado en todas sus páginas hijas.
imports: [RouterLink, RouterLinkActive]Se importan las dos directivas que se usan, no RouterModule completo.
route: 'trending' // relativa: queda en /dashboard/trending
route: '/trending' // desde la raíz: apunta a /trending, que no existeEl menú vive dentro del dashboard, así que 'trending' se cuenta desde
/dashboard. Con barra al inicio, la ruta se cuenta desde la raíz de la app.
El arreglo es fijo: no cambia mientras la app corre, así que una propiedad
normal alcanza. Si las opciones cambiaran (por ejemplo, según el usuario), el
arreglo iría dentro de un signal para que la plantilla se actualice sola.
- Poner la barra inicial en
route. - Importar
RouterModuleen vez deRouterLinkyRouterLinkActive. - Olvidar
routerLinkActive: el menú no marca la página actual.
Datos en un arreglo + @for + [routerLink] = un menú que crece agregando una
línea, no copiando HTML. Más sobre @for y navegación en el
Módulo A4.
Respuesta:
Le dice a Angular cómo identificar cada elemento de la lista. Así, cuando la lista cambia, reutiliza los elementos que siguen igual y sólo crea o quita los que cambiaron, en vez de dibujar todo de nuevo. Debe ser un valor único y estable, como un id.
Respuesta:
routerLinkActive agrega una clase CSS al enlace cuando su ruta es la actual,
lo que permite resaltar la opción activa. Con exact: true, el enlace sólo se
considera activo si la URL coincide por completo, no si apenas empieza igual.
Respuesta:
Sin barra, la ruta es relativa a donde está el enlace (aquí, dentro de
/dashboard). Con barra, es absoluta y se cuenta desde la raíz. Es un error
típico y la app deja de ir a la página que esperabas.
Respuesta:
Un href pide la página nueva al servidor y recarga toda la app. routerLink
deja que el enrutador de Angular cambie la vista sin recargar, conservando el
estado y siendo mucho más rápido.
Hay valores que cambian según dónde corre la app: el nombre de la empresa, el eslogan, y más adelante las URLs de una API o sus claves. No conviene escribirlos sueltos por todo el código. Los environments son archivos que guardan esos valores en un solo lugar, uno por cada modo (desarrollo y producción).
El comando de Angular CLI los crea y deja la configuración lista:
ng generate environmentObtienes:
-
src/environments/environment.ts: valores de producción. -
src/environments/environment.development.ts: valores de desarrollo. - En
angular.json, dentro de la configuracióndevelopment, un reemplazo de archivos (fileReplacements).
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]import { environment } from '@environments/environment'
export class SideMenuHeaderComponent {
envs = environment
}<h1>{{ envs.companyName }}<span>{{ envs.companyNameShort }}</span>.</h1>
<p>{{ envs.companySlogan }}</p>Siempre se importa environment.ts. En un build de desarrollo, Angular cambia
ese archivo por environment.development.ts por ti. Por eso nunca se importa el
de desarrollo directamente: el código quedaría atado a ese modo.
Sin alias, el import sube carpetas a mano:
import { environment } from '../../../../environments/environment'Con alias, la ruta sale desde un nombre fijo:
import { environment } from '@environments/environment'Se configura en tsconfig.json:
"compilerOptions": {
"paths": {
"@environments/*": ["./src/environments/*"]
}
}Antes, paths iba acompañado de "baseUrl": ".". TypeScript 6 marca baseUrl
como obsoleto. Ya no hace falta: paths funciona solo, siempre que cada destino
empiece con ./ (la ruta se cuenta desde el tsconfig.json). El precio es que
ya no puedes importar "desde la raíz" con rutas tipo src/app/...; con alias no
se nota.
- Dejar
baseUrlen eltsconfig.json: TypeScript avisa que está obsoleto. - Escribir el destino del alias sin
./: sinbaseUrl, el alias no resuelve. - Importar
environment.development.tsdirecto: el modo producción quedaría usando valores de desarrollo. - Guardar claves secretas en estos archivos: terminan dentro del código que baja el navegador.
Los environments agrupan los valores que cambian por modo, y Angular elige el
archivo correcto al compilar. El alias @environments/* acorta el import y se
configura sólo con paths.
Respuesta:
Con fileReplacements en angular.json. Tú siempre importas environment.ts;
en el build de desarrollo, Angular reemplaza ese archivo por
environment.development.ts antes de compilar. El código no cambia, sólo el
archivo que entra al paquete final.
Respuesta:
No. Los archivos de environment se compilan dentro del código que se descarga en el navegador, y cualquiera puede leerlos. Sólo van valores públicos (URLs, nombres, banderas). Lo secreto debe vivir en un servidor.
Respuesta:
Es un nombre corto para una carpeta, definido en paths de tsconfig.json
(@environments/* apunta a ./src/environments/*). Evita cadenas de ../../..,
que son difíciles de leer y se rompen si mueves el archivo de carpeta.
Respuesta:
Quitarlo y dejar sólo paths, con cada destino relativo al tsconfig.json
(empezando por ./). Los alias siguen funcionando igual.
La página Trending tiene que mostrar una cuadrícula de imágenes. Si el HTML de la cuadrícula y de cada imagen vive dentro de la página, no se puede reutilizar (la página Search va a necesitar la misma lista). La solución es separar quién tiene los datos de quién los dibuja.
trending-page tiene los datos: gifs = signal([...])
└── gif-list recibe la lista y repite un elemento por imagen
└── gif-list-item recibe una URL y dibuja una imagen
Los datos bajan, de padre a hijo, por entradas (input):
// trending-page.component.ts
gifs = signal(imageUrls)<!-- trending-page.component.html -->
<gif-list [gifs]="gifs()" />// gif-list.component.ts
public readonly gifs = input.required<string[]>()<!-- gif-list.component.html -->
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
@for (gif of gifs(); let idx = $index; track idx) {
<gif-list-item [imageUrl]="gif" />
}
</div>// gif-list-item.component.ts
public readonly imageUrl = input.required<string>()<!-- gif-list-item.component.html -->
<div>
<img class="h-auto max-w-full rounded-base" [src]="imageUrl()" alt="">
</div>Por ahora la lista usa doce imágenes de prueba escritas en el propio archivo; la idea es sustituirlas después por los GIFs de una API.
input<T>() crea una entrada opcional. input.required<T>() crea una que el
padre tiene que entregar: si usas <gif-list /> sin [gifs], el proyecto no
compila. Como imageUrl y gifs no tienen sentido vacíos, required es lo
correcto.
Una entrada es un signal de sólo lectura: se lee con paréntesis
(imageUrl(), gifs()) y no se asigna desde el hijo.
grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4Tailwind parte del móvil: grid-cols-1 vale para todas las pantallas, y cada
prefijo (sm:, md:) lo cambia desde ese ancho hacia arriba. Resultado:
una columna en pantallas pequeñas, dos desde sm y tres desde md.
track idx identifica cada imagen por su posición en la lista. Con una lista
fija es suficiente. Cuando los datos vengan de una API, es mejor usar un valor
propio de cada elemento (por ejemplo, el id del GIF): si la lista se reordena,
Angular puede mover los elementos en vez de reconstruirlos.
- Leer una entrada sin paréntesis (
imageUrlen vez deimageUrl()): se obtiene la función, no el valor. - Olvidar la entrada obligatoria al usar el componente.
- Poner la cuadrícula dentro de la página: no se puede reutilizar.
La página guarda los datos en un signal; gif-list y gif-list-item sólo
reciben y dibujan, cada uno con una entrada obligatoria. Más sobre input() y
señales en el Módulo A4.
Respuesta:
input() es una entrada opcional (puede quedar sin valor o con uno por
defecto). input.required() obliga al padre a pasarla: si falta, el compilador
lo avisa. Sirve para datos sin los que el componente no tiene sentido.
Respuesta:
Porque así gif-list es un componente de presentación: recibe una lista y la
dibuja, sin saber de dónde viene. Se puede reutilizar con otra fuente de datos
(la página de búsqueda) sin cambiarlo. Quien conoce el origen de los datos es la
página.
Respuesta:
Que Angular asocia cada elemento con su posición. Si la lista se reordena o se quita un elemento del medio, los elementos que siguen cambian de posición y Angular los trata como "otros" y los vuelve a dibujar, o mezcla estado (por ejemplo, un campo de texto). Con un id propio, Angular sigue a cada elemento aunque cambie de lugar.
Respuesta:
Porque las clases sin prefijo valen para todos los anchos, y los prefijos
(sm:, md:) sólo aplican a partir de cierto ancho. Así defines primero el
caso más simple (móvil) y vas agregando cambios para pantallas más grandes, sin
escribir reglas para cada tamaño.