Ottimizzare il bundle di Flutter Web: deferred loading, Wasm e caching

Foto di Ferenc Almasi su Unsplash

Best practiceAvanzato55 min Flutter 3.x (3.22+ consigliato)

Ottimizzare il bundle di Flutter Web: deferred loading, Wasm e caching

Su mobile il peso del bundle incide poco sull'esperienza: l'app è già installata. Sul web, invece, ogni kilobyte di main.dart.js è tempo che l'utente passa davanti a uno schermo bianco. Un'app Flutter Web non banale può facilmente superare i 3-4 MB di JavaScript, a cui si aggiungono il runtime CanvasKit, i font e le icone.

In questo tutorial avanzato vedremo una pipeline completa di ottimizzazione:

  • misurare la baseline in modo ripetibile (gzip/Brotli, source map, Lighthouse);
  • spezzare il bundle con il deferred loading di Dart (deferred as + loadLibrary()), integrandolo con il router;
  • scegliere il renderer giusto e provare la compilazione WebAssembly (skwasm) con gli header COOP/COEP;
  • snellire gli asset: tree shaking delle icone, subset dei font, immagini moderne;
  • governare la cache con il bootstrap di Flutter e il service worker;
  • bloccare le regressioni con un performance budget nella CI.

Prerequisiti: Flutter 3.22 o successivo (nuovo flutter_bootstrap.js), Dart 3, familiarità con go_router e con la build release per il web.

  1. 1

    Misurare la baseline: dimensioni reali e source map

    Prima di ottimizzare serve un numero di riferimento riproducibile. Il peso che conta non è quello su disco ma quello trasferito in rete, cioè compresso con gzip o Brotli (quasi tutti i CDN servono Brotli).

    Compila in release abilitando le source map, poi misura:

    1. flutter build web --release --source-maps
    2. calcola la dimensione compressa dei file principali;
    3. usa source-map-explorer per capire quali package occupano spazio dentro main.dart.js.

    Annota tre metriche: peso di main.dart.js (Brotli), peso totale di build/web, e il First Contentful Paint misurato con Lighthouse in modalità Mobile / Slow 4G. Senza questi tre numeri ogni ottimizzazione successiva è un'opinione.

    Attenzione: non pubblicare le source map in produzione. Generale solo per l'analisi, oppure caricale su un servizio di error reporting e rimuovile dall'artefatto pubblico.

    #!/usr/bin/env bash
    set -euo pipefail
    
    flutter clean
    flutter build web --release --source-maps
    
    echo "== Dimensioni (raw / gzip / brotli) =="
    for f in build/web/main.dart.js build/web/flutter_bootstrap.js; do
      raw=$(wc -c < "$f")
      gz=$(gzip -9 -c "$f" | wc -c)
      br=$(brotli -q 11 -c "$f" | wc -c)
      printf "%-40s %8s KB  %8s KB  %8s KB\n" \
        "$(basename "$f")" $((raw/1024)) $((gz/1024)) $((br/1024))
    done
    
    echo "== Totale cartella build/web =="
    du -sh build/web
    
    # Analisi per package (richiede Node.js)
    npx source-map-explorer build/web/main.dart.js --no-border-checks
    
    # Rimuovi le source map prima del deploy pubblico
    # find build/web -name '*.map' -delete

    Risultato atteso

    Hai un report con il peso Brotli di main.dart.js e una treemap che mostra i package più pesanti (spesso intl, firebase, grafici o modelli generati).

  2. 2

    Spezzare il bundle con il deferred loading di Dart

    Dart supporta la deferred loading: una libreria importata con deferred as non finisce nel bundle principale ma in un chunk separato (main.dart.js_1.part.js), scaricato solo quando chiami loadLibrary().

    Regole operative:

    • il candidato ideale è una feature intera (report con grafici, editor, scanner, area admin), non una singola classe;
    • tutti gli accessi alla libreria differita devono avvenire tramite il prefisso (reports.ReportsPage()), e solo dopo che loadLibrary() è completato;
    • loadLibrary() è idempotente: chiamarlo più volte restituisce lo stesso Future già risolto, quindi puoi usarlo anche come "preload";
    • attenzione ai tipi condivisi: se il bundle principale referenzia un tipo della libreria differita (per esempio in una firma di funzione), il compilatore lo riporta nel chunk principale annullando il guadagno. Tieni i modelli condivisi in un package comune.

    Creiamo un widget riutilizzabile DeferredBuilder che gestisce stato di caricamento ed errore, memorizzando il Future per evitare di ricaricarlo a ogni rebuild.

    import 'package:flutter/material.dart';
    
    /// Carica una libreria differita e costruisce la UI solo quando è pronta.
    class DeferredBuilder extends StatefulWidget {
      const DeferredBuilder({
        super.key,
        required this.loader,
        required this.builder,
        this.placeholder = const Center(child: CircularProgressIndicator()),
      });
    
      /// Tipicamente `() => reports.loadLibrary()`.
      final Future<void> Function() loader;
      final WidgetBuilder builder;
      final Widget placeholder;
    
      @override
      State<DeferredBuilder> createState() => _DeferredBuilderState();
    }
    
    class _DeferredBuilderState extends State<DeferredBuilder> {
      late Future<void> _future = widget.loader();
    
      @override
      Widget build(BuildContext context) {
        return FutureBuilder<void>(
          future: _future,
          builder: (context, snapshot) {
            if (snapshot.connectionState != ConnectionState.done) {
              return widget.placeholder;
            }
            if (snapshot.hasError) {
              return _RetryView(
                message: 'Impossibile caricare il modulo.',
                onRetry: () => setState(() => _future = widget.loader()),
              );
            }
            return widget.builder(context);
          },
        );
      }
    }
    
    class _RetryView extends StatelessWidget {
      const _RetryView({required this.message, required this.onRetry});
      final String message;
      final VoidCallback onRetry;
    
      @override
      Widget build(BuildContext context) {
        return Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text(message),
              const SizedBox(height: 12),
              FilledButton(onPressed: onRetry, child: const Text('Riprova')),
            ],
          ),
        );
      }
    }

    Risultato atteso

    Hai un widget generico pronto a ospitare qualunque schermata differita, con gestione di loading, errore e retry.

  3. 3

    Integrare i chunk differiti con go_router e precaricarli

    Ora colleghiamo il DeferredBuilder al router. L'import differito va dichiarato nel file delle rotte; la builder della rotta restituisce il DeferredBuilder.

    Due accorgimenti che fanno la differenza percepita:

    1. Preload intelligente: quando l'utente passa il mouse su una voce di menu (o quando l'app è idle dopo il primo frame) chiama loadLibrary() in anticipo. Al click il chunk è già in cache e la navigazione è istantanea.
    2. Gestione degli errori di rete: dopo un nuovo deploy i vecchi chunk potrebbero non esistere più. Se loadLibrary() fallisce con un errore di caricamento, proponi un reload della pagina (web.window.location.reload()), perché l'index.html in cache punta a un build obsoleto.

    Verifica il risultato con flutter build web --release e controlla che in build/web compaiano file main.dart.js_*.part.js: ogni file è un chunk che non viene scaricato all'avvio.

    import 'package:flutter/material.dart';
    import 'package:go_router/go_router.dart';
    
    import 'features/home/home_page.dart';
    import 'features/reports/reports_page.dart' deferred as reports;
    import 'features/admin/admin_page.dart' deferred as admin;
    import 'widgets/deferred_builder.dart';
    
    final router = GoRouter(
      routes: [
        GoRoute(
          path: '/',
          builder: (_, __) => const HomePage(),
          routes: [
            GoRoute(
              path: 'reports',
              builder: (_, __) => DeferredBuilder(
                loader: reports.loadLibrary,
                builder: (_) => reports.ReportsPage(),
              ),
            ),
            GoRoute(
              path: 'admin',
              builder: (_, __) => DeferredBuilder(
                loader: admin.loadLibrary,
                builder: (_) => admin.AdminPage(),
              ),
            ),
          ],
        ),
      ],
    );
    
    /// Preload dei moduli "probabili" quando la UI è stabile.
    class ModulePrefetcher {
      static bool _started = false;
    
      static void scheduleAfterFirstFrame() {
        if (_started) return;
        _started = true;
        WidgetsBinding.instance.addPostFrameCallback((_) {
          Future<void>.delayed(const Duration(seconds: 2), () async {
            // Gli errori qui sono innocui: la rotta riproverà al bisogno.
            await reports.loadLibrary().catchError((_) {});
          });
        });
      }
    }
    
    // Esempio di preload su hover di una voce di menu:
    // MouseRegion(
    //   onEnter: (_) => admin.loadLibrary().catchError((_) {}),
    //   child: menuItem,
    // )

    Risultato atteso

    In build/web compaiono i file *.part.js; aprendo la rete del browser vedi che il chunk dei report viene scaricato solo alla navigazione (o in prefetch) e non all'avvio.

  4. 4

    Renderer e build WebAssembly: CanvasKit, skwasm e header COOP/COEP

    Dopo il JavaScript dell'app, il secondo blocco pesante è il renderer. Con Flutter 3.22+ hai tre strade:

    • CanvasKit (JS): resa fedele e stabile, ma scarica ~1,5 MB di WASM/JS di Skia (in parte lazy e cacheable dal CDN).
    • Compilazione Wasm (--wasm): l'app Dart stessa viene compilata in WebAssembly con renderer skwasm; prestazioni nettamente migliori su animazioni e scroll, startup più rapido sui browser moderni. Flutter genera comunque un fallback JS per i browser non compatibili.
    • Il vecchio renderer html è deprecato/rimosso nelle versioni recenti: non progettare su quello.

    skwasm multi-thread richiede un contesto cross-origin isolated, quindi il server deve inviare gli header Cross-Origin-Opener-Policy: same-origin e Cross-Origin-Embedder-Policy: credentialless (o require-corp). Senza quegli header l'app funziona ma rinuncia ai thread, con un calo di fluidità.

    Un'altra leva è --base-href e l'hosting dei file CanvasKit in locale (--pwa-strategy e FLUTTER_WEB_CANVASKIT_URL) se non vuoi dipendere dal CDN di Google, ad esempio per policy CSP o GDPR.

    # Build classica JS + CanvasKit
    flutter build web --release
    
    # Build WebAssembly (skwasm) con fallback JS automatico
    flutter build web --wasm --release --base-href /app/
    
    # Servire CanvasKit da dominio proprio invece che dal CDN
    flutter build web --release \
      --dart-define=FLUTTER_WEB_CANVASKIT_URL=/canvaskit/
    
    # --- nginx: header necessari per skwasm multi-thread ---
    # server {
    #   location /app/ {
    #     add_header Cross-Origin-Opener-Policy   "same-origin"    always;
    #     add_header Cross-Origin-Embedder-Policy "credentialless" always;
    #
    #     # compressione: pre-comprimi in build e servi i .br
    #     brotli_static on;
    #     gzip_static   on;
    #
    #     # asset con hash: cache lunga
    #     location ~* \.(js|wasm|woff2|png|json)$ {
    #       add_header Cache-Control "public, max-age=31536000, immutable";
    #     }
    #     # index.html: mai in cache
    #     location = /app/index.html {
    #       add_header Cache-Control "no-cache";
    #     }
    #   }
    # }

    Risultato atteso

    L'app compilata con --wasm si avvia correttamente e, con gli header COOP/COEP attivi, `self.crossOriginIsolated` nella console del browser restituisce true.

  5. 5

    Snellire gli asset: icone, font e immagini

    Il codice è solo una parte del payload. Le voci che più spesso gonfiano una build Flutter Web sono font e immagini.

    • Icone: il tree shaking è attivo di default in release e riduce MaterialIcons a poche decine di KB. Si disattiva da solo se costruisci IconData dinamicamente (IconData(codePoint)): in quel caso Flutter stampa un warning e include l'intero font. Mappa i codepoint su una const map di IconData per preservarlo.
    • Font custom: includi solo i pesi realmente usati e crea un subset dei glifi (latin + latin-ext) con fonttools. Si passa facilmente da 300 KB a 40 KB per peso. Dichiara i font in pubspec.yaml solo nei formati .woff2/.ttf necessari.
    • Immagini: preferisci WebP/AVIF, fornisci le risoluzioni con le varianti 2x/3x e, per le immagini above-the-fold, aggiungi <link rel="preload"> nell'index.html.
    • Package inutilizzati: dart pub deps e l'analisi del passo 1 rivelano dipendenze trascinate per una sola funzione (classico: intl completo, o un SDK cloud usato solo in una feature → candidato perfetto al deferred loading).

    Dopo ogni intervento, ripeti lo script di misura del passo 1: solo il confronto dei numeri ti dice se l'ottimizzazione è servita.

    # 1) Subset di un font variabile su latin + latin-ext
    pip install fonttools brotli
    pyftsubset assets/fonts/Inter.ttf \
      --output-file=assets/fonts/Inter-subset.woff2 \
      --flavor=woff2 \
      --layout-features='kern,liga' \
      --unicodes="U+0000-00FF,U+0100-017F,U+2000-206F,U+20AC"
    
    # 2) Verifica che il tree shaking delle icone sia attivo
    flutter build web --release 2>&1 | grep -i "Font asset"
    # Atteso: "MaterialIcons-Regular.otf: ... 98% reduction"
    
    # 3) Conversione immagini in WebP
    for f in assets/images/*.png; do cwebp -q 82 "$f" -o "${f%.png}.webp"; done

    Risultato atteso

    Il log della build mostra la riduzione percentuale dei font icona e la cartella build/web cala sensibilmente di peso rispetto alla baseline.

  6. 6

    Bootstrap personalizzato, splash e strategia di cache del service worker

    Dalla versione 3.22 l'entrypoint web è flutter_bootstrap.js, inseribile in web/index.html con il token {{flutter_bootstrap.js}}. Puoi personalizzarlo per:

    • mostrare uno splash HTML/CSS (zero JavaScript Flutter) rimosso quando il primo frame è pronto, eliminando la schermata bianca;
    • controllare la strategia PWA del service worker (--pwa-strategy=offline-first oppure none se gestisci la cache dal CDN);
    • scegliere il momento in cui avviare il motore (ad esempio dopo il caricamento di una configurazione runtime).

    Sulla cache la regola d'oro è: index.html e flutter_service_worker.js mai cachati (no-cache), tutto il resto con hash e immutable. Il service worker generato da Flutter usa un manifest di hash: se l'HTML è in cache lunga, gli utenti restano bloccati su un build vecchio e i chunk differiti del passo 3 falliranno con 404.

    Aggiungi infine un controllo di aggiornamento: quando il service worker installa una nuova versione, mostra una SnackBar "Nuova versione disponibile — Ricarica".

    <!DOCTYPE html>
    <html>
    <head>
      <base href="$FLUTTER_BASE_HREF">
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1">
      <title>La mia app</title>
      <link rel="manifest" href="manifest.json">
      <link rel="preload" href="assets/fonts/Inter-subset.woff2" as="font" type="font/woff2" crossorigin>
      <style>
        #splash {
          position: fixed; inset: 0; display: grid; place-items: center;
          background: #101418; color: #fff; font-family: system-ui, sans-serif;
          transition: opacity .25s ease-out;
        }
        #splash.hidden { opacity: 0; pointer-events: none; }
      </style>
    </head>
    <body>
      <div id="splash"><span>Caricamento…</span></div>
    
      <script>
        window.addEventListener('load', function () {
          _flutter.loader.load({
            serviceWorkerSettings: {
              serviceWorkerVersion: {{flutter_service_worker_version}},
            },
            onEntrypointLoaded: async function (engineInitializer) {
              const engine = await engineInitializer.initializeEngine({
                // renderer: 'canvaskit', // forzatura opzionale
              });
              await engine.runApp();
              const splash = document.getElementById('splash');
              splash.classList.add('hidden');
              setTimeout(() => splash.remove(), 300);
            },
          });
        });
      </script>
      <script src="flutter_bootstrap.js" async></script>
    </body>
    </html>

    Risultato atteso

    All'apertura l'utente vede subito lo splash HTML, che scompare esattamente quando compare il primo frame Flutter; ricaricando dopo un nuovo deploy l'app si aggiorna senza chunk 404.

  7. 7

    Bloccare le regressioni: performance budget in CI

    Tutte le ottimizzazioni svaniscono al primo merge distratto che aggiunge un package pesante al bundle principale. Serve un budget automatico: la CI compila la web app, misura la dimensione Brotli di main.dart.js e fallisce se supera la soglia.

    Accorgimenti pratici:

    • fissa la soglia appena sopra il valore attuale (es. +5%), così ogni crescita significativa va giustificata;
    • misura anche il numero di chunk differiti: se scende a zero, qualcuno ha rotto il deferred loading con un import diretto;
    • aggiungi un job Lighthouse CI (treosh/lighthouse-ci-action) con assert su performance e First Contentful Paint;
    • commenta il risultato sulla PR per rendere il costo visibile a chi rivede il codice.

    Con questo passo l'ottimizzazione smette di essere un intervento una tantum e diventa una proprietà verificata del progetto.

    name: web-size-budget
    
    on:
      pull_request:
        branches: [main]
    
    jobs:
      budget:
        runs-on: ubuntu-latest
        env:
          MAX_MAIN_KB: 1100   # budget Brotli per main.dart.js
          MIN_CHUNKS: 2       # chunk differiti attesi
        steps:
          - uses: actions/checkout@v4
          - uses: subosito/flutter-action@v2
            with:
              channel: stable
              cache: true
    
          - run: flutter pub get
          - run: flutter build web --release
    
          - name: Verifica budget
            run: |
              set -euo pipefail
              size=$(brotli -q 11 -c build/web/main.dart.js | wc -c)
              kb=$((size/1024))
              chunks=$(ls build/web/main.dart.js_*.part.js 2>/dev/null | wc -l)
    
              echo "main.dart.js (brotli): ${kb} KB (budget ${MAX_MAIN_KB} KB)"
              echo "chunk differiti: ${chunks} (minimo ${MIN_CHUNKS})"
    
              if [ "$kb" -gt "$MAX_MAIN_KB" ]; then
                echo "::error::Budget superato: ${kb} KB > ${MAX_MAIN_KB} KB"
                exit 1
              fi
              if [ "$chunks" -lt "$MIN_CHUNKS" ]; then
                echo "::error::Deferred loading rotto: solo ${chunks} chunk generati"
                exit 1
              fi
    
          - uses: actions/upload-artifact@v4
            with:
              name: web-build
              path: build/web

    Risultato atteso

    Una PR che aggiunge una dipendenza pesante al bundle principale o che rompe il deferred loading fa fallire la CI con un messaggio esplicito sul budget superato.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!