frontend / vuejs
Suspense & composants asynchrones
Explication
Ce que vous allez apprendre
- Créer un composant "async setup" qui utilise
awaitdirectement au premier niveau du script - Afficher un état de chargement pendant que ce composant résout ses promesses avec
<Suspense> - Charger un composant entier à la demande avec
defineAsyncComponent, avec gestion d'erreur et retry - Capturer une erreur survenue dans un arbre
<Suspense>aveconErrorCaptured - Paralléliser plusieurs sources asynchrones plutôt que de les enchaîner séquentiellement
Dans quel contexte ?
Une page de détail produit doit attendre la réponse d'une API avant de pouvoir s'afficher : sans mécanisme dédié, chaque composant devrait gérer manuellement son propre état de chargement avec un ref(true) initial et un v-if conditionnel. <Suspense> centralise cette attente au niveau du composant PARENT, simplifiant considérablement le composant enfant lui-même.
D'abord, un composant qui devient automatiquement asynchrone
Utiliser await directement au premier niveau d'un <script setup> (pas dans une fonction imbriquée) transforme automatiquement le composant en composant "async setup". Vue sait alors que ce composant a besoin de temps avant de pouvoir se rendre, et attend la résolution de toutes les promesses avant d'afficher son template.
Prérequis
Il faut avoir compris les hooks de cycle de vie et onErrorCaptured (leçon 7) : <Suspense> s'appuie sur ce même mécanisme de capture d'erreur pour son propre fonctionnement.
Ensuite, afficher quelque chose pendant l'attente
<Suspense> encadre ce composant async avec deux slots : #default contient le contenu réel, affiché une fois toutes les promesses résolues, et #fallback affiche un état de chargement pendant l'attente. C'est une bascule automatique gérée entièrement par Vue, sans ref de chargement à gérer manuellement dans le composant enfant.
Slot de <Suspense> | Affiché quand |
|---|---|
#default | Toutes les promesses de l'arbre sont résolues |
#fallback | Au moins une promesse est encore en attente |
Il reste un besoin différent : charger un composant ENTIER à la demande
defineAsyncComponent répond à un problème différent de <Suspense> : il découpe le bundle JavaScript pour ne charger le CODE du composant que lorsqu'il est réellement affiché, indépendamment de savoir si ce composant lui-même fait des appels réseau. Il gère sa propre configuration de chargement, d'erreur et de délai, sans dépendre de <Suspense>.
Astuce
delay: 200 dans defineAsyncComponent évite un "flash" désagréable de l'état de chargement sur une connexion rapide : si le composant se charge en moins de 200ms, l'utilisateur ne voit jamais l'indicateur de chargement clignoter inutilement.
Maintenant, gérer les erreurs de ce mécanisme asynchrone
Si la promesse d'un composant async échoue (une requête réseau en erreur, par exemple), onErrorCaptured dans un composant ancêtre peut intercepter cette erreur et afficher un message adapté plutôt que de laisser l'application dans un état incohérent.
Piège fréquent
Enchaîner plusieurs await séquentiels pour des requêtes indépendantes (await fetch(a); await fetch(b);) rallonge inutilement le temps de chargement total, puisque la seconde requête n'attend pas réellement la première pour des raisons techniques. Promise.all([fetch(a), fetch(b)]) lance les deux en parallèle, réduisant le temps total au maximum des deux durées plutôt qu'à leur somme.
Maintenant que la gestion des états de chargement asynchrones est claire, la prochaine leçon aborde un aspect purement visuel mais tout aussi important pour l'expérience utilisateur : les transitions et animations.
Commandes & code
Suspense & composants asynchrones
<Suspense> gère l'état de chargement d'un sous-arbre de composants qui dépendent de données asynchrones.
<!-- pages/ProduitDetail.vue — composant "async setup" : utilise top-level await -->
<script setup>
const props = defineProps({ id: String })
// await directement dans <script setup> : rend AUTOMATIQUEMENT ce composant asynchrone
const reponse = await fetch(`/api/produits/${props.id}`)
if (!reponse.ok) throw new Error('Produit introuvable')
const produit = await reponse.json()
</script>
<template>
<article>
<h1>{{ produit.nom }}</h1>
<p>{{ produit.prix }} €</p>
</article>
</template><!-- Composant parent : encadre le composant async avec Suspense -->
<script setup>
import { defineAsyncComponent } from 'vue'
import ProduitDetail from './pages/ProduitDetail.vue'
</script>
<template>
<Suspense>
<!-- slot par défaut : le contenu réel, une fois toutes les promesses résolues -->
<template #default>
<ProduitDetail id="42" />
</template>
<!-- slot fallback : affiché pendant le chargement -->
<template #fallback>
<p>Chargement du produit...</p>
</template>
</Suspense>
</template>// defineAsyncComponent : chargement paresseux d'un composant ENTIER (code splitting),
// indépendamment de <Suspense>, avec sa propre gestion loading/error.
import { defineAsyncComponent } from 'vue'
const GraphiqueAvance = defineAsyncComponent({
loader: () => import('./components/GraphiqueAvance.vue'),
loadingComponent: () => import('./components/SpinnerChargement.vue'),
errorComponent: () => import('./components/ErreurChargement.vue'),
delay: 200, // attend 200ms avant d'afficher loadingComponent (évite le flash sur réseau rapide)
timeout: 10000, // bascule sur errorComponent si le chargement dépasse 10s
onError(error, retry, fail, tentatives) {
if (tentatives <= 3) {
retry() // réessaie automatiquement jusqu'à 3 fois (utile sur réseau instable)
} else {
fail()
}
},
})<!-- Gestion des erreurs de Suspense avec onErrorCaptured dans le parent -->
<script setup>
import { ref, onErrorCaptured } from 'vue'
import ProduitDetail from './pages/ProduitDetail.vue'
const erreur = ref(null)
onErrorCaptured((e) => {
erreur.value = e
return false // stoppe la propagation
})
</script>
<template>
<p v-if="erreur">Une erreur est survenue : {{ erreur.message }}</p>
<Suspense v-else>
<template #default><ProduitDetail id="42" /></template>
<template #fallback><p>Chargement...</p></template>
</Suspense>
</template>// Suspense imbriqué + plusieurs sources async en parallèle dans un composant
// (bonne pratique : Promise.all plutôt que des await séquentiels qui rallongent le chargement)
const [reponseProduit, reponseAvis] = await Promise.all([
fetch(`/api/produits/${props.id}`),
fetch(`/api/produits/${props.id}/avis`),
])
const produit = await reponseProduit.json()
const avis = await reponseAvis.json()Résumé
<script setup>avecawaiten top-level transforme automatiquement le composant en composant async.<Suspense>affiche un#fallbacktant que l'arbre#defaultn'a pas fini de résoudre ses promesses.defineAsyncComponentgère le code splitting d'un composant entier, avec loading/error/retry configurables.Promise.allpour paralléliser les requêtes indépendantes plutôt que les enchaîner séquentiellement.
Exercices pratiques
Mission : une fiche produit deux fois plus lente que nécessaire
Objectif : Diagnostiquer des appels réseau séquentiels dans un composant async, puis les paralléliser sans casser Suspense ni la gestion d'erreur.
Contexte
ProduitDetail.vue, un composant async setup, exécute actuellement const reponseProduit = await fetch(...); const produit = await reponseProduit.json(); const reponseAvis = await fetch(...); const avis = await reponseAvis.json(). Chaque requête prend environ 400ms, donc la page met près de 800ms à s'afficher alors que les deux appels sont totalement indépendants l'un de l'autre. Le composant est déjà encadré par un <Suspense> avec un fallback correct.