Parte 8: Maps App¶
Una aplicación para dibujar mapas con Mapbox GL JS, una librería que pinta
el mapa dentro de la página usando WebGL (el motor gráfico del navegador). Es
una aplicación Angular 22 creada con ng new, con Tailwind CSS y daisyUI (tema
cyberpunk), igual que la Parte 5: Pipes App y la
Parte 7: Lifecycle Hooks. Es la primera que usa una
librería de afuera que no es de Angular: no hay un componente listo, así que
hay que conectarla a mano con el ciclo de vida del componente y con las
señales. Tiene tres rutas (fullscreen, markers y houses); por ahora sólo
está hecha fullscreen: un mapa a pantalla completa con un pin y un control de
zoom.
Proyecto: 08-maps-app/. Sitio publicado: pendiente, la app todavía no
está desplegada (falta crearla en Coolify). Mientras tanto se corre en local con
npm start dentro de la carpeta (http://localhost:4200/), después de crear el
.env con la clave de Mapbox (se explica en el
Módulo MP1).
Esta página va creciendo: cada módulo nuevo se agrega al final.
Qué vas a aprender:
- La clave de Mapbox. Por qué aquí no sirve el proxy de las partes anteriores, qué token usar y cómo llega al navegador sin subirlo a git (Módulo MP1).
- El mapa en pantalla. Cargar el CSS de la librería, crear el mapa cuando el
divya existe, poner un pin y liberar el mapa al salir (Módulo MP2). - El zoom. Una sola señal que mantiene de acuerdo al deslizador y al mapa,
con un
effecty un evento de Mapbox (Módulo MP3). - El título de la página. Mostrar el título de la ruta en un navbar que
vive fuera del
router-outlet, conTitleStrategy(Módulo MP4).
Cada módulo sigue el mismo orden: el problema que resuelve, el código del repositorio, cómo funciona por dentro, errores comunes y preguntas de entrevista con su respuesta.
Esta página es una parte de la guía de estudio; allí están el
mapa general y la ruta recomendada. Conviene haber visto antes la
Parte 7: Lifecycle Hooks (ngAfterViewInit y effect) y
el Módulo P5
sobre cómo proteger las claves.
Módulo MP1: La clave de Mapbox y el script set-envs¶
El problema¶
Para dibujar el mapa, Mapbox pide un token: un texto largo que dice de qué cuenta es la petición (y a quién cobrarle). Hay que resolver dos cosas a la vez: que el token no se suba a git y que llegue al navegador, porque Mapbox lo necesita allí.
En las partes anteriores la clave nunca llegaba al navegador: un proxy la agregaba en el servidor (se explica en el Módulo P5). Aquí no se puede: Mapbox GL JS pide los pedazos del mapa (los tiles) directo desde el navegador, con el token en cada petición. Si pasara por un proxy, habría que reenviar también el estilo, las fuentes y los íconos.
Dos tipos de token¶
Mapbox tiene dos tipos y se distinguen por el principio del texto:
| Token | Empieza con | ¿Puede ir en el navegador? |
|---|---|---|
| Público | pk. |
Sí, está hecho para eso |
| Secreto | sk. |
Nunca |
Como un token público acaba dentro del JavaScript que baja cualquiera que abra
la página, no se protege escondiéndolo, sino limitándolo: en tu cuenta de
Mapbox puedes restringir el token a ciertas direcciones (localhost:4200 y tu
dominio) y poner alertas de uso. Así, si alguien lo copia, no le sirve en otro
sitio.
El código del repositorio¶
El token vive en un archivo .env (que git ignora). La plantilla
.env.template
sólo dice qué variable existe, sin valor:
MAPBOX_KEY=your_mapbox_key_here
Un script de Node,
set-envs.mjs,
lee esa variable y genera los archivos de environments:
import { existsSync, writeFileSync, mkdirSync } from 'fs'
// Node loads .env by itself (no dotenv needed); in CI the variables come from the environment
if (existsSync('.env')) process.loadEnvFile()
const envDir = './src/environments'
const targetPath = `${envDir}/environment.ts`
const targetPathDev = `${envDir}/environment.development.ts`
const mapboxKey = process.env['MAPBOX_KEY']
if (!mapboxKey) {
throw new Error('MAPBOX_KEY is not defined in the environment variables.')
}
const envFileContent = `export const environment = {
mapboxKey: '${mapboxKey}'
}
`
// Create the environments directory if it doesn't exist and write the environment files
mkdirSync(envDir, { recursive: true })
writeFileSync(targetPath, envFileContent)
writeFileSync(targetPathDev, envFileContent)
El resultado, src/environments/environment.ts, es un objeto con el token, que
el componente importa con el alias @environments/environment:
export const environment = {
mapboxKey: '<el token>'
}
Como esos archivos contienen el token, no van en git. El .gitignore de la
carpeta lo dice:
# Generated by scripts/set-envs.mjs from .env (they contain the Mapbox key)
/src/environments/
Para no tener que acordarse de correr el script, package.json lo engancha a
los comandos de siempre:
"set-envs": "node ./scripts/set-envs.mjs",
"prestart": "npm run set-envs",
"start": "ng serve",
"prebuild": "npm run set-envs",
"build": "ng build",
"prewatch": "npm run set-envs",
"watch": "ng build --watch --configuration development",
Y el Dockerfile recibe la variable para que el build en Coolify también pueda
generar los archivos:
# The build generates src/environments/ from this variable (see scripts/set-envs.mjs).
ARG MAPBOX_KEY
ENV MAPBOX_KEY=$MAPBOX_KEY
RUN npm run build
Cómo funciona por dentro¶
.env ──► scripts/set-envs.mjs ──► src/environments/environment.ts
(local) (lo corre prestart (no va en git) │
o prebuild) ▼
import { environment } from '@environments/environment'
pre…en npm. Si existe un script llamadoprestart, npm lo ejecuta solo, antes destart. Por esonpm startynpm run buildgeneran los archivos sin que lo pidas. Para generarlos sin arrancar nada:npm run set-envs.process.loadEnvFile(). Node sabe leer un.envpor sí mismo, así que no hace falta instalardotenv. Elif (existsSync('.env'))evita el error cuando no hay archivo: en Docker y en CI la variable llega directo del entorno.- El script falla a propósito si no hay
MAPBOX_KEY: es mejor que se corte el build a publicar un mapa sin token. - En Coolify la variable
MAPBOX_KEYse crea con la opción Available at Buildtime. En las partes 3, 4 y 6 es al revés (sin esa opción), porque allí la usa Nginx al arrancar; aquí la usa el build.
Esta app es la excepción a la regla de las otras: en 03, 04 y 06 los
environment*.ts sí van en git, porque no llevan ninguna clave.
Errores comunes¶
- Usar un token
sk.en el navegador. Es un secreto y quedaría a la vista de todos. Usa siempre unpk.. - Dejar el token público sin restricciones. Funciona, pero cualquiera que lo copie lo puede usar en su propio sitio y gastar tu cuota. Restríngelo por dirección.
- Correr el script desde otra carpeta. Lee
.envy escribesrc/environments/respecto a la carpeta donde estás. Ejecútalo dentro de08-maps-app/, connpm run set-envs. - Escribir
set-envs.jsen vez deset-envs.mjs. El archivo usaimport, así que es un módulo de ES y se llama.mjs; con otro nombre, Node no lo encuentra. - Olvidar Available at Buildtime en Coolify. Sin esa opción, el build no ve la variable y se corta con el mensaje del script.
- Crear la variable después del primer despliegue. Hay que crearla antes: sin ella el build falla y queda publicada la versión anterior.
Resumen¶
- El token de Mapbox va en el navegador, así que debe ser público (
pk.) y estar restringido por dirección. .env(ignorado) →set-envs.mjs→src/environments/(ignorado).prestart,prebuildyprewatchlo generan solos.- En Coolify, la variable se crea con Available at Buildtime.
Preguntas de entrevista¶
1. ¿Por qué esta app no usa un proxy para la clave, como las partes 3 y 4?¶
Respuesta:
Porque Mapbox GL JS pide los tiles directo desde el navegador con el token en
cada petición, así que el token tiene que estar en el navegador de todos modos.
Un proxy obligaría a reenviar también estilos, fuentes e íconos. Lo que protege
aquí es usar un token público (pk.) restringido por dirección, no esconderlo.
2. ¿Qué diferencia hay entre un token pk. y uno sk., y cómo proteges el público?¶
Respuesta:
El pk. está pensado para ir en el navegador; el sk. da permisos de
administración y nunca debe salir del servidor. Al pk. se le restringe la
dirección desde la que puede usarse (localhost:4200 y el dominio de la app) y
se configuran alertas de uso, así que copiarlo no le sirve a un tercero.
3. ¿Por qué src/environments/ está en el .gitignore aquí y en otras apps no?¶
Respuesta:
Porque aquí esos archivos contienen el token y en las otras no llevan ninguna
clave (la clave va por el proxy). Lo que se sube es la receta para generarlos
(set-envs.mjs y .env.template), no el resultado.
4. ¿Cómo llega el token al build en Coolify?¶
Respuesta:
La variable MAPBOX_KEY se crea en Coolify con Available at Buildtime.
Coolify se la pasa al Dockerfile como argumento (ARG), el Dockerfile la
convierte en variable de entorno (ENV) y prebuild ejecuta set-envs.mjs, que
genera src/environments/ antes de ng build.
5. ¿Para qué sirve el prefijo pre en prestart y prebuild?¶
Respuesta:
npm ejecuta automáticamente el script preX justo antes del script X. Así
npm start y npm run build generan los archivos de environments sin que
nadie tenga que recordarlo.
Módulo MP2: El mapa en pantalla¶
El problema¶
Mapbox no se puede usar como un componente de Angular: es una librería que necesita un elemento real de la página donde dibujar. Eso trae tres preguntas: ¿cuándo existe ese elemento?, ¿cómo lo encuentro? y ¿quién limpia el mapa cuando el usuario se va?
El código del repositorio¶
Primero, el CSS de la librería. Sin él, los controles y los pines se ven rotos.
Se carga en angular.json, antes de los estilos de la app:
"styles": [
"node_modules/mapbox-gl/dist/mapbox-gl.css",
"src/tailwind.css",
"src/styles.scss"
]
La plantilla de
fullscreen-map-page.component.html
tiene un div vacío (el control de zoom se explica en el
Módulo MP3):
<!-- Mapbox draws the map inside this div; it needs a height, or the map is invisible -->
<div #map class="h-screen w-full"></div>
El .scss del componente le da el tamaño: todo el ancho y el alto de la ventana
menos el navbar de 64 px:
div {
width: 100%;
// Subtract the height of the header (64px) from the viewport height
height: calc(99vh - #{$controls-header-height});
}
Y el componente,
fullscreen-map-page.component.ts
(aquí se omite el zoom, que es del módulo siguiente):
export class FullscreenMapPageComponent implements AfterViewInit {
// The <div #map> where Mapbox draws the map. It must exist in the DOM before the map is created
private readonly mapContainer = viewChild.required<ElementRef<HTMLDivElement>>('map')
private readonly destroyRef = inject(DestroyRef)
// ... las señales del zoom
// The view is ready here, so the map container already exists
ngAfterViewInit(): void {
// The public token (pk.) generated by scripts/set-envs.mjs from MAPBOX_KEY
mapboxgl.accessToken = environment.mapboxKey
const mapContainer = this.mapContainer().nativeElement
if (!mapContainer) return
const map = new mapboxgl.Map({
container: mapContainer,
style: MAP_CONFIG.style,
center: MAP_CONFIG.center, // [lng, lat]
zoom: this.zoomLevel(),
})
// The pin: it is only visible once it is added to the map
const marker = new mapboxgl.Marker({
color: MARKER_CONFIG.color,
draggable: MARKER_CONFIG.draggable,
})
.setLngLat(MARKER_CONFIG.position)
.addTo(map)
this.mapListeners(map)
// Free the map (and its WebGL context) when the user leaves the page
this.destroyRef.onDestroy(() => map.remove())
}
}
Los valores iniciales no están en el componente, sino en
map.constants.ts,
con sus tipos en
mapboxCustomOptions.ts:
// A point on the map: [longitude, latitude]
export type Coordinates = [number, number]
// Initial map: Mapbox streets style, centered on COORDINATES; zoom goes from 0 (the world) to 22 (a building)
export const MAP_CONFIG: MapOptions = {
style: 'mapbox://styles/mapbox/streets-v12',
center: COORDINATES,
zoom: 15,
}
export const MARKER_CONFIG: MarkerOptions = {
color: 'red',
position: COORDINATES,
draggable: false,
}
Cómo funciona por dentro¶
Piensa en una televisión: Mapbox es la señal y el div es la pantalla. Sin
pantalla no hay dónde dibujar, y una pantalla sin alto no se ve. La comparación
se queda corta en algo: la «pantalla» la crea Angular, no tú, así que tienes que
esperar a que exista.
| Pieza | Para qué sirve |
|---|---|
#map y viewChild.required |
Angular te entrega el div de la plantilla. .required avisa si no existe, y .nativeElement es el elemento real del navegador |
ngAfterViewInit |
Es el momento en que la vista ya está dibujada y el div existe (el orden de los hooks está en el Módulo LH1). En el constructor todavía no |
mapboxgl.accessToken |
Le dice a la librería con qué token pedir los datos |
new mapboxgl.Map({...}) |
Crea el mapa dentro de container, con un estilo, un centro y un zoom |
new mapboxgl.Marker(...) |
Crea el pin; no se ve hasta que le haces .addTo(map) |
map.remove() |
Destruye el mapa y libera su memoria gráfica |
destroyRef.onDestroy(...) registra una función que Angular llama justo antes
de destruir el componente. Sin ella, cada vez que el usuario sale de la página y
vuelve quedaría un mapa más en memoria.
El orden de las coordenadas es [longitud, latitud], al revés que en Google
Maps. La longitud va primero.
Errores comunes¶
- No cargar el CSS de Mapbox. El mapa se dibuja, pero con los controles desordenados.
- Un contenedor sin alto. El mapa existe pero mide cero y no se ve. Por eso la
plantilla tiene
h-screeny el.scssfija el alto. - Poner la latitud primero. Un pin en Los Ángeles aparece en medio del
océano: Mapbox espera
[lng, lat]. - No llamar a
map.remove(). El navegador limita cuántos contextos gráficos puede tener abiertos y la página termina fallando después de navegar varias veces. - Que el mapa quede vacío al guardar un estilo. Con
ng serve, Angular parchea la página en caliente (HMR) sin recargarla; el mapa se crea una sola vez, al cargar, y a veces no se vuelve a crear. Se nota porque eldivsigue ahí pero sin el<canvas>adentro. Se arregla arrancando connpx ng serve --no-hmr: cada cambio recarga la página completa y el mapa se crea siempre. - Cada recarga cuenta como una carga de mapa. Mapbox cobra por cada mapa que se crea y el plan gratuito tiene un tope mensual (el número vigente está en tu cuenta). Para desarrollar sobra, pero conviene poner una alerta de uso.
Resumen¶
- Un
divcon alto +viewChild+ngAfterViewInit+new mapboxgl.Map. - El CSS de la librería va en
angular.json. - Las coordenadas son
[lng, lat]. map.remove()al destruir el componente.
Preguntas de entrevista¶
1. ¿Por qué el mapa se crea en ngAfterViewInit y no en el constructor o en ngOnInit?¶
Respuesta:
Porque Mapbox necesita el div ya dibujado en la página. En el constructor y en
ngOnInit la vista todavía no existe, así que viewChild no tiene el elemento.
ngAfterViewInit es el primer momento en que está garantizado.
2. ¿Qué te devuelve viewChild.required y por qué hace falta .nativeElement?¶
Respuesta:
Devuelve una señal con un ElementRef, que es el envoltorio que Angular pone
alrededor del elemento. .nativeElement es el elemento real del navegador (un
HTMLDivElement), que es lo que Mapbox necesita como container. .required
lanza un error si el elemento no existe, en vez de devolver undefined.
3. ¿Qué pasa si no llamas a map.remove() al salir de la página?¶
Respuesta:
El mapa sigue vivo en memoria con su contexto gráfico (WebGL). Cada vez que se
entra a la página se crea otro y el navegador acaba por no poder abrir más:
fugas de memoria y mapas en blanco. DestroyRef.onDestroy es el lugar para
limpiarlo.
4. ¿En qué orden se pasan las coordenadas a Mapbox?¶
Respuesta:
[longitud, latitud]: longitud primero, al revés que Google Maps. En el
repositorio el tipo Coordinates = [number, number] lo deja anotado para no
confundirse.
5. ¿Qué es el HMR y por qué puede dejar el mapa vacío?¶
Respuesta:
HMR (Hot Module Replacement) es la función de ng serve que, al guardar un
archivo, cambia sólo esa pieza en la página sin recargarla. El mapa se crea una
sola vez al cargar la página, así que, si el componente se parchea sin
recargar, a veces no se vuelve a crear. Es un asunto del desarrollo y no afecta
al sitio publicado; con ng serve --no-hmr desaparece.
Módulo MP3: El zoom: una señal, un effect y los eventos del mapa¶
El problema¶
El zoom lo puede cambiar el usuario de dos maneras: moviendo el deslizador de la pantalla o haciendo zoom en el mapa (rueda del ratón, botones o doble clic). Los dos tienen que mostrar el mismo número. Si cada uno guardara su propio valor, acabarían contradiciéndose.
La solución es tener un solo valor, una señal, y dos caminos:
deslizador ──► zoomLevel (señal) ──► effect ──► map.zoomTo(...) el mapa se mueve
mapa (zoomend) ──► zoomLevel (señal) ──► el deslizador y el número se actualizan
El código del repositorio¶
En
fullscreen-map-page.component.ts,
las dos señales y el effect (de deslizador a mapa):
// The map lives in a signal so the effect below can react when it is ready (null until then)
map = signal<mapboxgl.Map | null>(null)
// Single source of truth for the zoom: the slider and the map both read and write it
zoomLevel = signal(MAP_CONFIG.zoom)
// Slider -> map: every time zoomLevel changes, move the map to that zoom
zoomEffect = effect(() => {
if (!this.map()) return
// zoomTo is a Mapbox GL JS method to smoothly animate the map to a specified zoom level
this.map()!.zoomTo(this.zoomLevel())
})
El método que recibe el valor del deslizador y el que escucha al mapa (de mapa a deslizador):
// Called by the slider: saves the new value (an <input> gives a string, hence Number)
zoomLevelChange(value?: number) {
if (value !== undefined) {
this.zoomLevel.set(Number(value))
}
return this.zoomLevel()
}
mapListeners(map: mapboxgl.Map) {
// Map -> slider: when the user zooms with the wheel or the +/- buttons, update the signal.
// If the new value equals the current one the signal does not notify, so there is no loop
map.on('zoomend', (event) => {
const newZoomLevel = map.getZoom()
this.zoomLevel.set(newZoomLevel)
})
// Shows the real reason in the console when the map does not load
// (invalid token, blocked request, style not found...)
map.on('error', (event) => console.error('Mapbox error:', event.error))
// Publish the map so the effect can start using it
this.map.set(map)
}
Y el deslizador en la plantilla:
<!-- #txtZoom is a reference to this input, so (input) can read its current value -->
<input
type="range"
min="1"
max="19"
[value]="zoomLevel()"
(input)="zoomLevelChange(+txtZoom.value)"
class="range range-primary"
#txtZoom
/>
El número que se ve junto al título sale de {{ zoomLevel() | number: '1.0-0' }}:
el pipe number lo muestra sin decimales (con la rueda el zoom puede valer 15,37).
Cómo funciona por dentro¶
¿Por qué el mapa va dentro de una señal? Porque un effect sólo se entera de
lo que cambia si lee una señal. El mapa no existe hasta ngAfterViewInit, así
que empieza en null; cuando mapListeners hace this.map.set(map), el
effect se vuelve a ejecutar y ya puede usarlo. (Los effect se explican en el
Módulo LH1.)
Qué lee el effect cada vez. La primera vez, this.map() es null y el
effect sale con el return: sólo se suscribió a map. Cuando el mapa llega,
corre otra vez, lee también zoomLevel() y desde entonces reacciona a los dos.
Por qué no hay un bucle infinito. Mover el deslizador cambia zoomLevel, el
effect hace zoomTo, el mapa termina de moverse y dispara zoomend, que
vuelve a escribir zoomLevel. Pero ese valor ya es el mismo, y una señal no
avisa cuando se le escribe lo que ya tiene. Ahí se corta.
zoomend y no zoom. El evento zoom se dispara muchas veces mientras el
mapa se anima; zoomend sólo cuando el zoom terminó, sea por la rueda, por los
botones o por nuestro propio zoomTo.
El deslizador. [value]="zoomLevel()" lleva la señal hacia el input, y
(input) lleva el valor del input hacia la señal. #txtZoom es una
referencia a ese elemento para leer txtZoom.value; como un input entrega
texto, se convierte a número con + y con Number(...).
Errores comunes¶
- Escuchar
zoomen vez dezoomend. Actualizas la señal decenas de veces por segundo y eleffectempieza a pelearse con la animación. - Guardar el mapa en una propiedad normal. El
effectno se entera cuando llega y el deslizador no mueve el mapa. - Olvidar convertir a número. El
inputentrega"10"(texto) yzoomToespera un número. - Quitar el
if (!this.map()) return. Antes de que exista el mapa eleffectintentaría usarnully fallaría. El!dethis.map()!es seguro sólo por eseif.
Resumen¶
- Una señal (
zoomLevel) es la única fuente de verdad del zoom. - Deslizador → mapa:
effect+zoomTo. - Mapa → deslizador: el evento
zoomend+zoomLevel.set(...). - El mapa vive en una señal para que el
effectse entere de cuándo llega.
Preguntas de entrevista¶
1. ¿Por qué el mapa se guarda en una señal y no en una propiedad normal?¶
Respuesta:
Porque el effect sólo reacciona a las señales que lee. El mapa se crea después
(en ngAfterViewInit); si estuviera en una propiedad normal, el effect nunca
se enteraría de que ya existe. Con una señal que empieza en null, el effect
vuelve a correr cuando se publica el mapa.
2. ¿Por qué no se genera un bucle entre el effect y el evento zoomend?¶
Respuesta:
Porque una señal no notifica cuando se le escribe un valor igual al actual. El
effect mueve el mapa a ese zoom, zoomend escribe ese mismo valor y la señal
no cambia, así que el effect no vuelve a ejecutarse.
3. ¿Por qué se escucha zoomend y no zoom?¶
Respuesta:
zoom se dispara en cada fotograma de la animación; zoomend, una sola vez al
terminar. Escuchar zoom actualizaría la señal muchas veces por segundo y haría
que el effect interrumpiera la animación con nuevos zoomTo.
4. ¿Qué lee el effect la primera vez que corre?¶
Respuesta:
Sólo this.map(). Como todavía es null, sale con el return antes de llegar a
this.zoomLevel(), así que en esa primera ejecución depende únicamente del mapa.
Cuando se publica el mapa, corre de nuevo, lee las dos señales y desde entonces
reacciona a ambas.
Módulo MP4: El título de la página con TitleStrategy¶
El problema¶
El navbar muestra el título de la página donde estás («Fullscreen Map», «Markers
Map»…). Cada ruta ya lo trae en app.routes.ts:
{
path: 'fullscreen',
component: FullscreenMapPageComponent,
title: 'Fullscreen Map',
},
La pregunta es cómo leerlo desde el navbar. En la
Parte 5 un
componente de página lo leía con this.route.snapshot.title. En el navbar eso
no funciona.
Piensa en un edificio. Las páginas son las salas y el <router-outlet> es
la puerta por donde se cambia de sala. El navbar está en el vestíbulo, fuera
de las salas. El ActivatedRoute que se inyecta en un componente es el de la
sala donde vive; el navbar, que vive en el vestíbulo, recibe la ruta raíz, que no
tiene título. Además, snapshot es una foto tomada una vez, y el navbar no se
vuelve a crear al navegar, así que la foto quedaría vieja.
Tres maneras de resolverlo¶
| Manera | Cómo | Cuándo conviene |
|---|---|---|
snapshot.title |
Una foto de la ruta al crear el componente | Un componente de página, que se crea al entrar a la ruta |
| Escuchar los eventos del router | router.events + NavigationEnd, y leer la ruta activa |
Un componente fijo fuera del outlet. Funciona, pero hay que escribir el recorrido a mano |
TitleStrategy |
El router le avisa a una clase propia en cada navegación | Lo más limpio: el título ya viene calculado y se comparte con quien lo necesite |
Las dos primeras quedan comentadas en
navbar.component.ts
como referencia (la primera, que busca el título comparando la URL con la lista
de rutas, es frágil: falla con parámetros y con rutas hijas). La que se usa es
la tercera.
El código del repositorio¶
app-title-strategy.service.ts
es una clase que extiende TitleStrategy:
@Service()
export class AppTitleStrategy extends TitleStrategy {
// Angular service that writes the browser tab title (document.title).
private readonly title = inject(Title)
// Title of the current page ("Home"), so other components (the navbar) can read it.
readonly pageTitle = signal(DEFAULT_TITLE)
// The router calls this after every navigation.
override updateTitle(snapshot: RouterStateSnapshot) {
// buildTitle returns the `title` of the active route, or undefined if it has none.
const routeTitle = this.buildTitle(snapshot) ?? DEFAULT_TITLE
// The navbar shows only the page title; the browser tab adds the app name in front
const fullTitle = `${DEFAULT_TITLE} - ${routeTitle}`
this.pageTitle.set(routeTitle)
this.title.setTitle(fullTitle)
}
}
Se le avisa al router que use esta clase en
app.config.ts:
// TitleStrategy is the router's base class that decides the document title on every navigation.
// Here we tell the router to use our AppTitleStrategy instead of the default one,
// so the same instance (and its pageTitle signal) is shared with the rest of the app.
{ provide: TitleStrategy, useExisting: AppTitleStrategy },
Y el navbar sólo lee la señal:
// 3. Title strategy (the one in use): the router already computes the title on each navigation,
// so we just read the signal that AppTitleStrategy keeps up to date. No router events needed.
pageTitle = inject(AppTitleStrategy).pageTitle
<a class="btn btn-ghost text-xl">{{ pageTitle() }}</a>
Cómo funciona por dentro¶
TitleStrategyes una clase abstracta del router: una plantilla base que no se usa directamente. Tú creas una clase que la extiende y completas lo que falta,updateTitle.- Angular ya trae una por defecto (
DefaultTitleStrategy) que pone eltitlede la ruta en la pestaña del navegador. Por eso tus títulos ya aparecían en la pestaña sin hacer nada. updateTitle(snapshot)es el método que el router llama después de cada navegación. Aquí hace dos cosas: guarda el título de la ruta en la señalpageTitle(para el navbar) y escribe «Angular Maps - Título» en la pestaña.buildTitle(snapshot)ya viene en la clase base: recorre las rutas hasta la activa y devuelve sutitle, también cuando es un título calculado. Si la ruta no tiene, devuelveundefined, y por eso el?? DEFAULT_TITLE.Titlees un servicio de Angular que escribe la pestaña del navegador (document.title).{ provide: TitleStrategy, useExisting: AppTitleStrategy }dice: «cuando el router pida unaTitleStrategy, dale la instancia deAppTitleStrategy».useExistingreutiliza la misma instancia que se inyecta en el navbar, así que el router y el navbar comparten la misma señal.@Service()(Angular 22) declara una clase como servicio y Angular lo provee solo, sin tener que registrarlo.
Errores comunes¶
- Usar
this.route.snapshot.titleen el navbar. Recibe la ruta raíz y no tiene título. - Buscar el título comparando la URL con la lista de rutas. Se rompe con
parámetros de ruta (
/casa/5), con parámetros de consulta (?x=1) y con rutas hijas. - Crear la clase y olvidar el
provide: TitleStrategy. El router sigue usando la de por defecto,updateTitlenunca se llama y la señal se queda conDEFAULT_TITLE. - Una ruta sin
title. No falla:buildTitledevuelveundefinedy se usaDEFAULT_TITLE.
Resumen¶
- El navbar vive fuera del outlet:
snapshot.titleno le sirve. - Una clase que extiende
TitleStrategyrecibe el aviso en cada navegación. - Guarda el título en una señal que el navbar lee, y escribe la pestaña con un prefijo.
- Se registra con
{ provide: TitleStrategy, useExisting: ... }.
Preguntas de entrevista¶
1. ¿Por qué this.route.snapshot.title no sirve en un navbar que está fuera del router-outlet?¶
Respuesta:
Porque el ActivatedRoute que se inyecta es el de la ruta donde vive el
componente, y el navbar vive fuera del outlet, así que recibe la ruta raíz, que
no tiene título. Además snapshot es una foto tomada una sola vez, y el navbar no
se vuelve a crear al navegar.
2. ¿Cuándo sí conviene snapshot.title?¶
Respuesta:
En un componente de página, que se crea al entrar a la ruta y se destruye al
salir: la foto es correcta y no hay que escuchar nada. Si la ruta tiene
parámetros y el componente puede reutilizarse sin recrearse (de /casa/1 a
/casa/2), la foto queda vieja y hace falta un observable.
3. ¿Qué es TitleStrategy y qué hace buildTitle?¶
Respuesta:
Es la clase base con la que el router decide el título del documento tras cada
navegación. Tú la extiendes e implementas updateTitle(snapshot). buildTitle
viene hecho en la clase base y devuelve el título de la ruta activa, o
undefined si no tiene.
4. ¿Para qué sirve useExisting en el provide?¶
Respuesta:
Para que el router y el resto de la app usen la misma instancia de
AppTitleStrategy. Si se usara useClass, el router crearía la suya y el navbar
leería la señal de otra distinta, que nunca se actualizaría.
5. ¿Qué ventaja tiene TitleStrategy sobre escuchar NavigationEnd?¶
Respuesta:
El router ya calculó el título, así que no hay que filtrar eventos ni recorrer las rutas a mano, y funciona con rutas hijas y con títulos calculados. Además deja un solo lugar que decide el título de la pestaña y del navbar.