Salta al contenuto principale

7 - Ottimizzare Core Web Vitals in Angular

Guida pratica per ottimizzare LCP, INP e CLS in Angular: NgOptimizedImage, bundle optimization, font loading, monitoring in produzione e benchmark.

Cosa ne pensi di questo articolo?

7 - Ottimizzare Core Web Vitals in Angular
Angular ModernoFoto:Bibek ghoshPexels

In Breve

Guida pratica per ottimizzare LCP, INP e CLS in Angular: NgOptimizedImage, bundle optimization, font loading, monitoring in produzione e benchmark.

Ottimizzare Core Web Vitals in Angular

I Core Web Vitals sono le metriche di performance che Google utilizza come fattore di ranking per determinare la qualità dell'esperienza utente di un sito web. Per le applicazioni Angular, ottimizzare queste metriche non è solo una questione tecnica: è un investimento diretto sulla visibilità nei motori di ricerca e sulla soddisfazione degli utenti.

In questo settimo articolo della serie Angular Moderno esploreremo in profondità cosa sono i Core Web Vitals, come misurarli, e soprattutto come ottimizzarli in un'applicazione Angular con tecniche specifiche per LCP, INP e CLS. Dalla gestione delle immagini con NgOptimizedImage alla riduzione del bundle con @defer, fino al monitoraggio in produzione con Real User Monitoring.

Cosa Imparerai in Questo Articolo

  • Cosa sono i Core Web Vitals e perchè sono un fattore di ranking Google
  • Come ottimizzare il Largest Contentful Paint (LCP) con NgOptimizedImage e preconnect
  • Strategie per migliorare Interaction to Next Paint (INP) con Signals e riduzione della change detection
  • Come prevenire il Cumulative Layout Shift (CLS) con dimensioni esplicite e skeleton screen
  • Ottimizzazioni Angular-specifiche: SSR, lazy loading con @defer, route-based code splitting
  • Analisi e riduzione del bundle con tree shaking, minificazione e source-map-explorer
  • Misurazione delle performance con web-vitals, Lighthouse e Chrome DevTools
  • Ottimizzazione avanzata di immagini e font per il web
  • Monitoraggio in produzione con Performance Observer API e GA4

Panoramica della Serie Angular Moderno

# Articolo Focus
1Angular SignalsReattività fine-grained
2Zoneless Change DetectionEliminare Zone.js
3Nuovi Template @if, @for, @deferControl flow moderno
4Standalone ComponentsArchitettura senza NgModule
5Signal FormsForm reattivi con Signals
6SSR e Incremental HydrationServer-Side Rendering
7Sei qui → Core Web Vitals in AngularPerformance e metriche
8Angular PWAProgressive Web App
9Dependency Injection AvanzataDI tree-shakable
10Migrare da Angular 17 a 21Guida alla migrazione

1. Cosa Sono i Core Web Vitals

I Core Web Vitals sono un insieme di metriche definite da Google che misurano tre aspetti fondamentali dell'esperienza utente: velocità di caricamento, reattività alle interazioni e stabilità visiva. Dal 2021 fanno parte ufficialmente dei segnali di ranking di Google, il che significa che un sito con Core Web Vitals scadenti viene penalizzato nei risultati di ricerca.

Le Tre Metriche Core Web Vitals

Metrica Cosa Misura Buono Migliorabile Scarso
LCP (Largest Contentful Paint) Tempo per renderizzare l'elemento più grande visibile ≤ 2.5s 2.5s - 4.0s > 4.0s
INP (Interaction to Next Paint) Latenza tra interazione utente e aggiornamento visivo ≤ 200ms 200ms - 500ms > 500ms
CLS (Cumulative Layout Shift) Spostamenti imprevisti degli elementi nella pagina ≤ 0.1 0.1 - 0.25 > 0.25

Da FID a INP: L'Evoluzione della Metrica di Reattività

A marzo 2024 Google ha sostituito il First Input Delay (FID) con Interaction to Next Paint (INP) come metrica ufficiale. La differenza fondamentale è che FID misurava solo il ritardo della prima interazione, mentre INP misura la latenza di tutte le interazioni durante l'intera sessione, restituendo il valore peggiore (al 98° percentile). Questo rende INP una metrica molto più rappresentativa dell'esperienza reale dell'utente.

Perchè Sono Importanti per Angular

Le applicazioni Angular, specialmente quelle Single Page Application (SPA) con rendering lato client, hanno storicamente problemi con i Core Web Vitals. I motivi principali sono:

  • Bundle JavaScript pesanti: Angular include il framework, i componenti e le dipendenze in un unico bundle iniziale
  • Change detection costosa: Zone.js intercetta ogni evento asincrono e trigghera cicli di change detection
  • Rendering lato client: Senza SSR, il browser deve scaricare, parsare e eseguire tutto il JS prima di mostrare contenuto
  • Lazy loading non ottimale: Senza strategie adeguate, tutto il codice viene caricato al primo accesso

2. Ottimizzare il Largest Contentful Paint (LCP)

Il LCP misura il tempo necessario per renderizzare l'elemento più grande visibile nel viewport iniziale. Tipicamente è un'immagine hero, un blocco di testo o un elemento video. Per Angular, il LCP è fortemente influenzato dal Server-Side Rendering, dal caricamento delle immagini e dalle risorse bloccanti.

NgOptimizedImage: La Direttiva Fondamentale

Angular fornisce la direttiva NgOptimizedImage nel pacchetto @angular/common che applica automaticamente le best practice per il caricamento delle immagini. Questa direttiva gestisce lazy loading, priorità, dimensioni e formati moderni.

Utilizzo di NgOptimizedImage per ottimizzare LCP

// hero.component.ts
import { Component } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';

@Component({
  selector: 'app-hero',
  standalone: true,
  imports: [NgOptimizedImage],
  template: `
    <section class="hero">
      <!-- Immagine LCP: priority carica immediatamente -->
      <img
        ngSrc="assets/images/hero-banner.webp"
        width="1200"
        height="630"
        priority
        alt="Hero banner del portfolio"
      />

      <!-- Immagini below-the-fold: lazy loading automatico -->
      <img
        ngSrc="assets/images/project-thumb.webp"
        width="400"
        height="300"
        alt="Anteprima progetto"
      />
    </section>
  `
})
export class HeroComponent {}
    

Errore Comune: Dimenticare l'Attributo priority

Senza priority, NgOptimizedImage applica automaticamente loading="lazy" a tutte le immagini. Per l'immagine LCP (tipicamente la hero image above-the-fold) questo e controproducente perchè ritarda il caricamento dell'elemento più importante. Aggiungi sempre priority alla tua immagine LCP.

Preconnect e Preload per Risorse Critiche

Il browser deve risolvere DNS, stabilire connessioni TCP e negoziare TLS prima di scaricare risorse da domini esterni. Con preconnect e preload possiamo anticipare queste operazioni.

index.html - Preconnect e preload per risorse critiche

<head>
  <!-- Preconnect ai domini delle risorse critiche -->
  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
  <link rel="preconnect" href="https://cdn.example.com" />

  <!-- Preload dell'immagine LCP -->
  <link
    rel="preload"
    as="image"
    href="/assets/images/hero-banner.webp"
    type="image/webp"
    fetchpriority="high"
  />

  <!-- Preload del font critico -->
  <link
    rel="preload"
    as="font"
    href="/assets/fonts/inter-v12-latin-regular.woff2"
    type="font/woff2"
    crossorigin
  />
</head>
    

Checklist LCP per Angular

  • Usa NgOptimizedImage con priority sull'immagine LCP
  • Abilita SSR per inviare HTML con contenuto pre-renderizzato
  • Aggiungi preconnect per CDN immagini e font
  • Usa preload per l'immagine hero e il font critico
  • Converti le immagini in WebP o AVIF per ridurre la dimensione
  • Implementa un image loader CDN per ridimensionamento automatico
  • Evita redirect sulle risorse critiche (ogni redirect aggiunge 300-500ms)
  • Minimizza il CSS critico e inline-alo nell'HTML

3. Ottimizzare Interaction to Next Paint (INP)

L'INP misura il tempo che intercorre tra un'interazione dell'utente (click, tap, pressione tasto) e il prossimo aggiornamento visivo sullo schermo. Per Angular questa metrica è direttamente influenzata dalla change detection, dagli event handler e dall'uso di Zone.js.

Signals per Ridurre la Change Detection

Una delle ottimizzazioni più impattanti per l'INP in Angular e il passaggio da Zone.js ai Signals. Con i Signals, Angular aggiorna solo i componenti i cui dati sono effettivamente cambiati, eliminando i cicli globali di change detection.

Ottimizzazione INP con Signals e OnPush

// product-list.component.ts - PRIMA (Zone.js, change detection globale)
@Component({
  selector: 'app-product-list',
  template: `
    <div *ngFor="let product of products">
      <span>{{ product.name }}</span>
      <button (click)="addToCart(product)">Aggiungi</button>
    </div>
  `
})
export class ProductListComponent {
  products: Product[] = [];

  addToCart(product: Product): void {
    // Ogni click trigghera change detection su TUTTA l'app
    this.cartService.add(product);
  }
}

// product-list.component.ts - DOPO (Signals, aggiornamento mirato)
@Component({
  selector: 'app-product-list',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (product of products(); track product.id) {
      <div>
        <span>{{ product.name }}</span>
        <button (click)="addToCart(product)">Aggiungi</button>
      </div>
    }
  `
})
export class ProductListComponent {
  products = input.required<Product[]>();
  private cartService = inject(CartService);

  addToCart(product: Product): void {
    // Solo questo componente viene aggiornato
    this.cartService.add(product);
  }
}
    

Evitare Long Task negli Event Handler

Un event handler che esegue calcoli pesanti blocca il main thread e peggiora l'INP. La strategia è suddividere il lavoro in micro-task usando requestAnimationFrame o setTimeout.

Suddivisione di long task con scheduler yield

// Approccio ottimizzato: suddividi il lavoro pesante
export class DataProcessorComponent {
  async onFilterChange(filter: string): Promise<void> {
    // 1. Aggiorna immediatamente l'UI (feedback visivo)
    this.isLoading.set(true);

    // 2. Cedi il controllo al browser per il paint
    await this.yieldToMain();

    // 3. Esegui il lavoro pesante in chunk
    const results = await this.processInChunks(this.rawData(), filter);

    // 4. Aggiorna i risultati
    this.filteredData.set(results);
    this.isLoading.set(false);
  }

  private yieldToMain(): Promise<void> {
    return new Promise(resolve => {
      // scheduler.yield() dove disponibile, altrimenti setTimeout
      if ('scheduler' in window && 'yield' in (window as any).scheduler) {
        (window as any).scheduler.yield().then(resolve);
      } else {
        setTimeout(resolve, 0);
      }
    });
  }

  private async processInChunks(
    data: Item[],
    filter: string,
    chunkSize = 500
  ): Promise<Item[]> {
    const results: Item[] = [];
    for (let i = 0; i < data.length; i += chunkSize) {
      const chunk = data.slice(i, i + chunkSize);
      results.push(...chunk.filter(item => item.name.includes(filter)));
      // Cedi il main thread tra un chunk e l'altro
      if (i + chunkSize < data.length) {
        await this.yieldToMain();
      }
    }
    return results;
  }
}
    

Impatto di Zone.js sull'INP

Scenario Con Zone.js Zoneless (Signals) Miglioramento
Click su bottone in lista 100 elementi 85ms 12ms -86%
Digitazione in campo ricerca 120ms 18ms -85%
Toggle filtro in tabella 1000 righe 250ms 45ms -82%
Navigazione tra tab 65ms 8ms -88%

4. Prevenire il Cumulative Layout Shift (CLS)

Il CLS misura la somma degli spostamenti imprevisti degli elementi nella pagina durante il suo ciclo di vita. Un CLS elevato crea un'esperienza frustrante: l'utente sta per cliccare un bottone e il layout si sposta, facendogli cliccare qualcos'altro.

Dimensioni Esplicite per Immagini e Media

La causa più comune di CLS sono immagini e iframe senza dimensioni esplicite. Quando il browser non conosce le dimensioni di un'immagine, alloca zero spazio fino al caricamento, poi il layout si ricalcola bruscamente.

Prevenzione CLS con dimensioni esplicite e aspect-ratio

<!-- MALE: nessuna dimensione, causa CLS -->
<img src="photo.jpg" alt="Foto" />

<!-- BENE: dimensioni esplicite, zero CLS -->
<img src="photo.jpg" alt="Foto" width="800" height="600" />

<!-- MEGLIO: NgOptimizedImage con fill per immagini responsive -->
<div class="image-container" style="position: relative; aspect-ratio: 16/9;">
  <img ngSrc="photo.webp" fill alt="Foto responsive" />
</div>

<!-- Video e iframe: stessa regola -->
<iframe
  src="https://www.youtube.com/embed/xxx"
  width="560"
  height="315"
  loading="lazy"
  title="Video tutorial"
></iframe>
    

Skeleton Screen per Contenuto Dinamico

Quando il contenuto viene caricato in modo asincrono (da API, database, ecc.), uno skeleton screen preserva lo spazio necessario e previene il layout shift.

Skeleton screen component per prevenire CLS

// article-skeleton.component.ts
@Component({
  selector: 'app-article-skeleton',
  standalone: true,
  template: `
    <div class="skeleton-wrapper">
      <div class="skeleton-image" style="aspect-ratio: 16/9;"></div>
      <div class="skeleton-title" style="height: 32px; width: 80%;"></div>
      <div class="skeleton-text" style="height: 16px; width: 100%;"></div>
      <div class="skeleton-text" style="height: 16px; width: 95%;"></div>
      <div class="skeleton-text" style="height: 16px; width: 60%;"></div>
    </div>
  `,
  styles: [`
    .skeleton-wrapper {
      padding: 16px;
      display: flex;
      flex-direction: column;
      gap: 12px;
    }
    .skeleton-image, .skeleton-title, .skeleton-text {
      background: linear-gradient(90deg, #21262d 25%, #30363d 50%, #21262d 75%);
      background-size: 200% 100%;
      animation: shimmer 1.5s infinite;
      border-radius: 4px;
    }
    @keyframes shimmer {
      0% { background-position: 200% 0; }
      100% { background-position: -200% 0; }
    }
  `]
})
export class ArticleSkeletonComponent {}

// Utilizzo nel componente padre
@Component({
  template: `
    @if (article(); as art) {
      <app-article [data]="art" />
    } @else {
      <app-article-skeleton />
    }
  `
})
export class ArticlePageComponent {
  article = signal<Article | null>(null);
}
    

Cause Comuni di CLS e Soluzioni

Causa Impatto CLS Soluzione
Immagini senza dimensioni 0.1 - 0.5 Attributi width/height o aspect-ratio CSS
Font web FOUT/FOIT 0.05 - 0.15 font-display: swap + size-adjust
Contenuto iniettato dinamicamente 0.1 - 0.3 Skeleton screen o min-height riservato
Banner cookie/notifiche 0.05 - 0.2 Overlay position fixed, non push content
Annunci e embed di terze parti 0.1 - 0.4 Contenitore con dimensioni riservate

5. Ottimizzazioni Angular-Specifiche

Angular offre diverse funzionalità integrate che migliorano significativamente i Core Web Vitals. La combinazione di SSR, lazy loading con @defer e route-based code splitting crea un'architettura che carica solo il codice necessario, quando necessario.

SSR per LCP Ottimale

Come abbiamo visto nell'articolo precedente, il Server-Side Rendering invia HTML pre-renderizzato al browser, eliminando il tempo di attesa per il download e l'esecuzione di JavaScript. Questo ha un impatto diretto e misurabile sul LCP.

Lazy Loading con @defer

Il blocco @defer permette di caricare componenti on-demand, riducendo il bundle JavaScript iniziale e migliorando sia LCP che INP.

Lazy loading con @defer per ridurre il bundle iniziale

<!-- Contenuto above-the-fold: caricato normalmente -->
<app-hero />
<app-featured-projects />

<!-- Below-the-fold: caricato quando visibile -->
@defer (on viewport) {
  <app-blog-preview />
} @placeholder {
  <app-blog-skeleton />
}

@defer (on viewport) {
  <app-testimonials />
} @placeholder {
  <div style="min-height: 400px;"></div>
}

<!-- Componenti pesanti: caricati su interazione -->
@defer (on interaction) {
  <app-contact-form />
} @placeholder {
  <button class="cta-button">Contattami</button>
}

<!-- Componenti non critici: caricati quando il browser e libero -->
@defer (on idle) {
  <app-newsletter-signup />
}
    

Route-Based Code Splitting

Code splitting per route con lazy loading

// app.routes.ts - Code splitting automatico per route
export const routes: Routes = [
  {
    path: '',
    loadComponent: () =>
      import('./pages/home/home-page').then(m => m.HomePageComponent),
  },
  {
    path: 'blog',
    loadComponent: () =>
      import('./pages/blog/blog-page').then(m => m.BlogPageComponent),
  },
  {
    path: 'blog/:slug',
    loadComponent: () =>
      import('./pages/article/article-page').then(m => m.ArticlePageComponent),
  },
  {
    path: 'dev-tools',
    loadChildren: () =>
      import('./pages/dev-tools/dev-tools.routes').then(m => m.DEV_TOOLS_ROUTES),
  },
];
    

Impatto del Code Splitting sulle Metriche

Strategia Bundle Iniziale LCP TTI
Nessun code splitting 450KB 3.2s 4.1s
Route-based splitting 180KB 1.8s 2.4s
Route + @defer splitting 85KB 0.9s 1.3s
Route + @defer + SSR 85KB 0.6s 1.1s

6. Ottimizzazione del Bundle

La dimensione del bundle JavaScript è direttamente proporzionale al tempo di caricamento e parsing. Ridurre il bundle significa migliorare tutte le metriche: LCP (meno JS da scaricare), INP (meno codice da eseguire) e indirettamente CLS (rendering più rapido).

Analisi del Bundle con source-map-explorer

Analizzare il contenuto del bundle

# Installa source-map-explorer
npm install --save-dev source-map-explorer

# Build di produzione con source maps
ng build --source-map

# Analizza il bundle principale
npx source-map-explorer dist/portfolio/browser/main-*.js

# Alternativa: usa webpack-bundle-analyzer
npm install --save-dev webpack-bundle-analyzer
npx webpack-bundle-analyzer dist/portfolio/browser/stats.json
    

Tree Shaking e Ottimizzazioni di Build

Angular CLI applica automaticamente tree shaking, minificazione e compressione nella build di produzione. Tuttavia, ci sono ulteriori ottimizzazioni che puoi configurare.

angular.json - Configurazione build ottimizzata

{
  "projects": {
    "portfolio": {
      "architect": {
        "build": {
          "configurations": {
            "production": {
              "budgets": [
                {
                  "type": "initial",
                  "maximumWarning": "250kB",
                  "maximumError": "500kB"
                },
                {
                  "type": "anyComponentStyle",
                  "maximumWarning": "4kB",
                  "maximumError": "8kB"
                }
              ],
              "optimization": true,
              "outputHashing": "all",
              "sourceMap": false,
              "namedChunks": false,
              "extractLicenses": true
            }
          }
        }
      }
    }
  }
}
    

Dipendenze Pesanti da Controllare

Alcune librerie comuni aggiungono peso significativo al bundle. Verifica sempre l'impatto con source-map-explorer e considera alternative più leggere:

  • moment.js (~300KB): Sostituisci con date-fns (~30KB tree-shaked) o API native Intl.DateTimeFormat
  • lodash (~70KB): Importa singole funzioni con lodash-es/debounce o usa metodi nativi
  • rxjs (incluso): Importa solo gli operatori necessari, mai import * from 'rxjs'
  • chart.js (~200KB): Registra solo i componenti necessari con Chart.register()
  • @angular/material: Importa solo i moduli/componenti effettivamente usati

7. Misurare le Performance

Non puoi ottimizzare ciò che non misuri. Esistono diversi strumenti per misurare i Core Web Vitals, ognuno con vantaggi specifici. La strategia ideale combina dati di laboratorio (Lighthouse, DevTools) con dati reali degli utenti (RUM, CrUX).

La Libreria web-vitals

La libreria web-vitals di Google è il modo più semplice e affidabile per misurare i Core Web Vitals nel browser. Si integra facilmente in un'applicazione Angular.

Integrazione web-vitals in Angular

// web-vitals.service.ts
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

@Injectable({ providedIn: 'root' })
export class WebVitalsService {
  private platformId = inject(PLATFORM_ID);

  async measureVitals(): Promise<void> {
    if (!isPlatformBrowser(this.platformId)) return;

    const { onLCP, onINP, onCLS, onFCP, onTTFB } = await import('web-vitals');

    onLCP((metric) => {
      this.reportMetric('LCP', metric.value, metric.rating);
    });

    onINP((metric) => {
      this.reportMetric('INP', metric.value, metric.rating);
    });

    onCLS((metric) => {
      this.reportMetric('CLS', metric.value, metric.rating);
    });

    onFCP((metric) => {
      this.reportMetric('FCP', metric.value, metric.rating);
    });

    onTTFB((metric) => {
      this.reportMetric('TTFB', metric.value, metric.rating);
    });
  }

  private reportMetric(
    name: string,
    value: number,
    rating: 'good' | 'needs-improvement' | 'poor'
  ): void {
    // Invia a Google Analytics 4
    if (typeof gtag !== 'undefined') {
      gtag('event', name, {
        value: Math.round(name === 'CLS' ? value * 1000 : value),
        event_category: 'Web Vitals',
        event_label: rating,
        non_interaction: true,
      });
    }

    // Log per debug in development
    console.log(`[${rating.toUpperCase()}] ${name}: ${value.toFixed(2)}`);
  }
}
    

Strumenti di Misurazione a Confronto

Strumento Tipo Metriche Utilizzo
Lighthouse Lab data LCP, CLS, TBT, FCP, SI Audit durante sviluppo, CI/CD
Chrome DevTools Performance Lab data Tutte + flame chart Debug dettagliato di long task
PageSpeed Insights Lab + Field CWV + suggerimenti Overview rapida con dati CrUX
web-vitals (libreria) Field data (RUM) LCP, INP, CLS, FCP, TTFB Monitoraggio real user in produzione
Chrome UX Report (CrUX) Field data CWV (dati aggregati reali) Dati reali di utenti Chrome, usato per ranking
Search Console Field data CWV per URL Impatto SEO e pagine problematiche

8. Ottimizzazione Avanzata delle Immagini

Le immagini rappresentano tipicamente il 50-70% del peso totale di una pagina web. Un'ottimizzazione aggressiva delle immagini ha l'impatto più significativo sulle performance, specialmente su LCP e sul tempo di caricamento complessivo.

Formati Moderni: WebP e AVIF

I formati moderni offrono compressione superiore rispetto a JPEG e PNG senza perdita percepibile di qualità. AVIF in particolare offre risparmi del 50% rispetto a JPEG.

Responsive Images con srcset

Immagini responsive con NgOptimizedImage e image loader

// app.config.ts - Configurare un image loader
import { provideImageKitLoader } from '@angular/common';
// Oppure usa un loader personalizzato
import { IMAGE_LOADER, ImageLoaderConfig } from '@angular/common';

export const appConfig: ApplicationConfig = {
  providers: [
    // Opzione 1: Loader integrato (Cloudinary, ImageKit, Imgix)
    provideImageKitLoader('https://ik.imagekit.io/myaccount'),

    // Opzione 2: Loader personalizzato
    {
      provide: IMAGE_LOADER,
      useValue: (config: ImageLoaderConfig) => {
        const width = config.width ? `?w=${config.width}` : '';
        const quality = '&q=80';
        const format = '&fm=webp';
        return `https://cdn.example.com/${config.src}${width}${quality}${format}`;
      }
    }
  ]
};

// Utilizzo nel template
@Component({
  template: `
    <!-- Genera automaticamente srcset con dimensioni multiple -->
    <img
      ngSrc="hero-banner.jpg"
      width="1200"
      height="630"
      priority
      sizes="(max-width: 768px) 100vw, (max-width: 1024px) 80vw, 1200px"
      alt="Hero banner"
    />
  `
})
export class HeroComponent {}
    

Confronto Formati Immagine

Formato Compressione vs JPEG Supporto Browser Uso Consigliato
JPEG Baseline 100% Fallback per browser vecchi
WebP -25-35% 97%+ Default per la maggior parte dei casi
AVIF -50% 92%+ Fotografie, immagini complesse
PNG +200-400% 100% Solo per trasparenza necessaria
SVG N/A (vettoriale) 100% Icone, loghi, grafici semplici

9. Ottimizzazione dei Font

I font web sono spesso una risorsa bloccante per il rendering: il browser non mostra il testo fino a quando il font non è stato scaricato (FOIT - Flash of Invisible Text) oppure mostra un font fallback che poi viene sostituito causando un layout shift (FOUT - Flash of Unstyled Text). Entrambi i comportamenti impattano CLS e LCP.

Strategia di Caricamento Font Ottimale

Font optimization: preload, display swap, subset

/* styles.css - Dichiarazione font ottimizzata */

/* Font con subset latino (riduce la dimensione del 60-70%) */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400;
  font-display: swap;  /* Mostra testo subito con fallback */
  src: url('/assets/fonts/inter-v12-latin-regular.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC;
  /* size-adjust riduce CLS causato dal cambio font */
  size-adjust: 100%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('/assets/fonts/inter-v12-latin-700.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC;
}

@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/assets/fonts/jetbrains-mono-v13-latin-regular.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153;
}

/* Font stack con fallback metricamente compatibili */
body {
  font-family: 'Inter', -apple-system, BlinkMacSystemFont,
    'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}

code, pre {
  font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code',
    'SF Mono', Consolas, monospace;
}
    

Strategie font-display a Confronto

Valore Comportamento Impatto CLS Impatto LCP Raccomandato
swap Mostra fallback subito, scambia quando pronto Medio (possibile FOUT) Basso (testo visibile subito) Si, con size-adjust
block Nasconde testo per 3s, poi mostra fallback Basso Alto (FOIT, testo invisibile) No
fallback Nasconde 100ms, poi fallback per 3s Basso-medio Medio Si, per font non critici
optional Nasconde 100ms, usa solo se già in cache Nessuno Nessuno Si, massima performance

10. Monitoraggio in Produzione

I dati di laboratorio (Lighthouse) sono utili durante lo sviluppo, ma non rappresentano l'esperienza reale degli utenti. Il Real User Monitoring (RUM) raccoglie metriche dai browser reali degli utenti, fornendo dati rappresentativi delle condizioni effettive: device diversi, connessioni lente, geolocalizzazione variabile.

Performance Observer API

Monitoraggio completo con Performance Observer

// performance-monitor.service.ts
@Injectable({ providedIn: 'root' })
export class PerformanceMonitorService {
  private platformId = inject(PLATFORM_ID);

  constructor() {
    afterNextRender(() => {
      this.observeLCP();
      this.observeCLS();
      this.observeLongTasks();
    });
  }

  private observeLCP(): void {
    const observer = new PerformanceObserver((list) => {
      const entries = list.getEntries();
      const lastEntry = entries[entries.length - 1] as any;
      const lcpValue = lastEntry.startTime;
      const lcpElement = lastEntry.element?.tagName || 'unknown';

      this.sendToAnalytics('LCP', {
        value: Math.round(lcpValue),
        element: lcpElement,
        url: lastEntry.url || '',
        rating: lcpValue <= 2500 ? 'good' : lcpValue <= 4000 ? 'needs-improvement' : 'poor'
      });
    });
    observer.observe({ type: 'largest-contentful-paint', buffered: true });
  }

  private observeCLS(): void {
    let clsScore = 0;
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries() as any[]) {
        if (!entry.hadRecentInput) {
          clsScore += entry.value;
        }
      }
    });
    observer.observe({ type: 'layout-shift', buffered: true });

    // Invia CLS quando l'utente lascia la pagina
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        this.sendToAnalytics('CLS', {
          value: Math.round(clsScore * 1000),
          rating: clsScore <= 0.1 ? 'good' : clsScore <= 0.25 ? 'needs-improvement' : 'poor'
        });
      }
    });
  }

  private observeLongTasks(): void {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.duration > 50) {
          console.warn(
            `Long Task rilevato: ${entry.duration.toFixed(0)}ms`,
            entry
          );
        }
      }
    });
    observer.observe({ type: 'longtask', buffered: true });
  }

  private sendToAnalytics(name: string, data: Record<string, any>): void {
    // Invio a GA4
    if (typeof gtag !== 'undefined') {
      gtag('event', name, {
        ...data,
        event_category: 'Web Vitals',
        non_interaction: true,
      });
    }

    // Invio a endpoint custom (beacon per affidabilità)
    if (navigator.sendBeacon) {
      navigator.sendBeacon('/api/vitals', JSON.stringify({
        metric: name,
        ...data,
        timestamp: Date.now(),
        url: location.href,
        userAgent: navigator.userAgent,
      }));
    }
  }
}
    

Integrazione con Google Analytics 4

GA4 supporta nativamente i Core Web Vitals attraverso eventi personalizzati. Combinando la libreria web-vitals con GA4 puoi monitorare le performance reali degli utenti e creare dashboard per identificare pagine problematiche.

Dashboard GA4 per Core Web Vitals

// Inizializzazione nel componente root
@Component({
  selector: 'app-root',
  standalone: true,
  template: `<router-outlet />`
})
export class AppComponent {
  private webVitals = inject(WebVitalsService);
  private platformId = inject(PLATFORM_ID);

  constructor() {
    afterNextRender(() => {
      // Avvia la misurazione dei Core Web Vitals
      this.webVitals.measureVitals();

      // Monitora le navigazioni SPA
      this.trackNavigationTiming();
    });
  }

  private trackNavigationTiming(): void {
    const navigation = performance.getEntriesByType('navigation')[0] as any;
    if (navigation) {
      gtag('event', 'page_timing', {
        dns_time: Math.round(navigation.domainLookupEnd - navigation.domainLookupStart),
        tcp_time: Math.round(navigation.connectEnd - navigation.connectStart),
        ttfb: Math.round(navigation.responseStart - navigation.requestStart),
        dom_load: Math.round(navigation.domContentLoadedEventEnd - navigation.startTime),
        full_load: Math.round(navigation.loadEventEnd - navigation.startTime),
      });
    }
  }
}
    

Errori Comuni nel Monitoraggio delle Performance

  • Fidarsi solo di Lighthouse: I punteggi di laboratorio non riflettono le condizioni reali. Usa sempre RUM insieme a Lighthouse
  • Ignorare il 75° percentile: Google usa il 75° percentile (p75) dei dati di campo per il ranking. La mediana non basta
  • Non segmentare i dati: Analizza le metriche per device (mobile vs desktop), connessione e geolocalizzazione
  • Misurare solo la homepage: Ogni pagina ha metriche diverse. Le pagine con più contenuto (blog, prodotti) sono spesso le più problematiche
  • Non usare sendBeacon: Le richieste fetch possono essere cancellate quando l'utente naviga via. navigator.sendBeacon garantisce l'invio
  • Non monitorare i Long Task: Long task > 50ms bloccano il main thread e peggiorano INP. Monitorali per identificare colli di bottiglia

Riepilogo e Prossimi Passi

Ottimizzare i Core Web Vitals in Angular non è un'operazione una tantum ma un processo continuo. Le metriche cambiano con ogni nuova funzionalità, ogni nuova dipendenza e ogni aggiornamento del framework. La chiave del successo è combinare misurazioni continue (RUM in produzione) con ottimizzazioni proattive (SSR, lazy loading, ottimizzazione immagini e font).

Concetti Chiave di Questo Articolo

  • Core Web Vitals: LCP (≤ 2.5s), INP (≤ 200ms) e CLS (≤ 0.1) sono fattori di ranking Google
  • LCP: Usa NgOptimizedImage con priority, SSR, preconnect e formati moderni (WebP/AVIF)
  • INP: Passa a Signals per eliminare change detection globale, suddividi i long task con scheduler.yield()
  • CLS: Dimensioni esplicite su immagini/media, skeleton screen, font-display: swap con size-adjust
  • Bundle: Route-based code splitting + @defer riducono il JS iniziale dell'80%+
  • Immagini: WebP come default, AVIF per foto, srcset per responsive, lazy loading automatico
  • Font: Self-hosting, subset, font-display: optional per massima performance
  • Misurazione: Libreria web-vitals + GA4 per RUM, Lighthouse per lab data
  • Monitoraggio: Performance Observer API, Long Task detection, sendBeacon per invio affidabile

Obiettivi di Performance Consigliati per Angular

Metrica Obiettivo Come Raggiungerlo
LCP < 1.5s SSR + NgOptimizedImage + preload hero image
INP < 100ms Zoneless + Signals + event handler ottimizzati
CLS < 0.05 Dimensioni esplicite + skeleton + font-display optional
Lighthouse Performance 90+ Tutte le ottimizzazioni combinate
Bundle iniziale < 100KB gzipped Code splitting + tree shaking + @defer
TTFB < 200ms CDN + SSG per pagine statiche + edge caching

Nel prossimo articolo della serie esploreremo le Angular PWA (Progressive Web App), analizzando come trasformare un'applicazione Angular in un'app installabile con supporto offline, notifiche push e aggiornamenti automatici tramite Service Worker.

Domande frequenti

Cosa sono i Core Web Vitals?

I Core Web Vitals sono un insieme di metriche definite da Google che misurano tre aspetti fondamentali dell'esperienza utente: velocità di caricamento, reattività alle interazioni e stabilità visiva.

Perché i Core Web Vitals sono importanti per Angular?

Le applicazioni Angular, specialmente quelle Single Page Application (SPA) con rendering lato client, hanno storicamente problemi con i Core Web Vitals a causa di fattori come il Server-Side Rendering, il caricamento delle immagini e le risorse bloccanti.

Come ottimizzare il Largest Contentful Paint (LCP)?

Per ottimizzare il LCP in Angular è possibile utilizzare la direttiva NgOptimizedImage per gestire lazy loading, priorità, dimensioni e formati moderni delle immagini. Inoltre, è consigliabile aggiungere la proprietà "loading" alle immagini per anticipare le operazioni di caricamento.

Come monitorare le performance dei Core Web Vitals?

Per monitorare le performance dei Core Web Vitals esistono diversi strumenti, tra cui la libreria web-vitals di Google, che si integra facilmente in un'applicazione Angular e fornisce dati rappresentativi delle condizioni effettive.

Fonti e approfondimenti

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti