Composants et API
Dans la page précédente, nous avons rendu un portfolio HTML interactif avec Vue, chargé depuis un CDN. Nous avons aussi découvert la structure d’un composant .vue.
Nous allons maintenant créer une application Vue avec Vite, répartir son interface en composants et leur faire échanger des informations. Nous utiliserons d’abord des données locales pour nous concentrer sur la conception, puis nous les remplacerons par des données chargées avec fetch.
D’une page à une application
Section intitulée « D’une page à une application »Notre application présentera un répertoire de cafés étudiants, avec des cartes, des favoris et des filtres rapides. Nous prendrons comme référence le domaine de Café sans-fil, un projet qui répertorie les cafés étudiants de l’Université de Montréal.
Plutôt que de réunir toute l’interface dans un seul fichier, nous répartirons les responsabilités à travers plusieurs composants (chacun dans un fichier) :
App.vue État commun : cafés, favoris et filtres│ Chargement des données et composition de la page│└── CarteCafe.vue Affichage d'un café et de son bouton favori Réutilisé pour chaque élément de la listeTrois principes guideront nos choix
Section intitulée « Trois principes guideront nos choix »- Un composant doit avoir une seule responsabilité.
CarteCafeaffiche un café ; il ne décide ni de la provenance des données, ni des filtres à appliquer. - L’état est placé là où il doit être partagé. Les favoris concernent plusieurs cartes et le filtre « Mes favoris » :
App.vueles conserve. - Les données descendent, les événements remontent. Le parent transmet des valeurs par des props ; l’enfant signale les actions par des événements.
Préparer le projet
Section intitulée « Préparer le projet »La page précédente a présenté Vite et les composants monofichiers.
Nous reprenons ici les commandes essentielles :
npm create vue@latestcd cafes-udemnpm installnpm run devLors de la création, nommer le projet cafes-udem, conserver JavaScript (sans TypeScript) et ne sélectionner aucune fonctionnalité facultative : nous n’utiliserons pas encore Vue Router ni d’outil de test.
Nous travaillerons principalement avec cette structure :
cafes-udem/├── index.html├── public/│ └── cafes.json à ajouter lors de l'étape sur les API└── src/ ├── main.js ├── style.css styles communs ├── cafes-exemples.js données locales pour débuter ├── App.vue └── components/ └── CarteCafe.vue à créerDans src/main.js, monter l’application et importer la feuille de style commune :
Section intitulée « Dans src/main.js, monter l’application et importer la feuille de style commune : »import { createApp } from 'vue'import App from './App.vue'import './style.css'
createApp(App).mount('#app')Créer src/style.css :
:root { --encre: #1f2430; --gris: #667085; --bord: #e2e5ec; --accent: #7a2e3b; --fond: #faf8f6;}* { box-sizing: border-box; }body { margin: 0; font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; color: var(--encre); background: var(--fond); line-height: 1.5;}Étape 1 : Définir les données de départ
Section intitulée « Étape 1 : Définir les données de départ »Pour débuter, nous utiliserons quelques cafés fictifs. Ils servent uniquement aux exercices : leurs noms, emplacements, moyens de paiement et statuts ne représentent pas des informations réelles ou à jour.
Créer src/cafes-exemples.js :
export const cafesExemples = [ { slug: 'demo-agora', name: 'Café Démo Agora', description: 'Boissons et collations.', banner_url: null, is_open: true, status_message: '', location: { pavillon: 'Pavillon A', local: 'A-101' }, payment_details: [{ method: 'CASH' }, { method: 'DEBIT' }], }, { slug: 'demo-jardin', name: 'Café Démo Jardin', description: 'Sandwichs et repas légers.', banner_url: null, is_open: false, status_message: 'Fermé pour le moment', location: { pavillon: 'Pavillon B', local: 'B-205' }, payment_details: [{ method: 'CASH' }], }, { slug: 'demo-atelier', name: 'Café Démo Atelier', description: 'Café et pâtisseries.', banner_url: null, is_open: true, status_message: '', location: { pavillon: 'Pavillon A', local: 'A-215' }, payment_details: [{ method: 'CREDIT' }], },]Chaque café est représenté par un objet, et les objets sont regroupés dans un tableau.
La propriété slug sert d’identifiant stable pour chaque café.
La structure de ces objets sera réutilisée lors du chargement des données.
Étape 2 : Créer un composant réutilisable
Section intitulée « Étape 2 : Créer un composant réutilisable »Nous voulons afficher chaque café dans une carte. Au lieu de répéter le HTML dans App.vue, nous allons créer CarteCafe.vue et lui transmettre le café à afficher.
Observer les props (defineProps)
Section intitulée « Observer les props (defineProps) »Dans cet exemple réduit, le composant enfant déclare les informations qu’il accepte :
Composant enfant BadgeStatut.vue
Section intitulée « Composant enfant BadgeStatut.vue »<script setup>defineProps({ nom: { type: String, required: true }, ouvert: { type: Boolean, default: false },})</script>
<template> <span>{{ nom }} : {{ ouvert ? 'ouvert' : 'fermé' }}</span></template>Le parent utilise ce composant et lui transmet des données :
Composant parent
Section intitulée « Composant parent »<script setup>import { ref } from 'vue'import BadgeStatut from './BadgeStatut.vue'
const nom = ref('Café Démo Agora')const ouvert = ref(true)</script>
<template> <input v-model="nom" aria-label="Nom du café" /> <label><input v-model="ouvert" type="checkbox" /> Ouvert</label> <BadgeStatut :nom="nom" :ouvert="ouvert" /> <BadgeStatut nom="Café Démo Jardin" /></template>Créer CarteCafe.vue
Section intitulée « Créer CarteCafe.vue »Créer le fichier src/components/CarteCafe.vue :
<script setup>import { computed } from 'vue'
const props = defineProps({ cafe: { type: Object, required: true },})
const NOMS_PAIEMENT = { CASH: 'Comptant', DEBIT: 'Débit', CREDIT: 'Crédit',}
const paiements = computed(() => (props.cafe.payment_details ?? []) .map((p) => NOMS_PAIEMENT[p.method] ?? p.method) .join(', ') || 'Non précisé',)</script>
<template> <article class="carte"> <img v-if="cafe.banner_url" :src="cafe.banner_url" :alt="`Photo du café ${cafe.name}`" loading="lazy" />
<div class="contenu"> <header><h2>{{ cafe.name }}</h2></header> <p class="statut" :class="cafe.is_open ? 'ouvert' : 'ferme'"> {{ cafe.is_open ? 'Ouvert' : 'Fermé' }} <span v-if="cafe.status_message"> · {{ cafe.status_message }}</span> </p> <p>{{ cafe.description || 'Aucune description disponible.' }}</p> <p class="infos"> {{ cafe.location?.pavillon || 'Pavillon non précisé' }} <span v-if="cafe.location?.local">, {{ cafe.location.local }}</span> <br />Paiement : {{ paiements }} </p> </div> </article></template>
<style scoped>.carte { background: #fff; border: 1px solid var(--bord); border-radius: 12px; overflow: hidden; }img { width: 100%; height: 140px; object-fit: cover; }.contenu { padding: 12px 14px; display: flex; flex-direction: column; gap: 6px; }header { display: flex; justify-content: space-between; align-items: start; gap: 8px; }h2, p { margin: 0; }h2 { font-size: 1.1rem; }.statut { font-size: .85rem; font-weight: 600; }.ouvert { color: #067647; }.ferme { color: #b42318; }.infos { font-size: .85rem; color: var(--gris); }</style>Remarques:
Section intitulée « Remarques: »- Dans le script, nous utilisons
props.cafe; - Dans le gabarit (
<template>),cafeest directement accessible. - La propriété calculée
paiementsconvertit les codes de paiement en libellés lisibles. - L’expression
?? []prévoit le cas oùpayment_detailsest absent ou vautnull.Lorsque les données proviennent d’une source externe, il faut éviter de supposer que tous les champs facultatifs seront toujours présents.
Afficher la liste dans App.vue
Section intitulée « Afficher la liste dans App.vue »Remplacer le contenu de src/App.vue par une première version complète et fonctionnelle :
<script setup>import { ref } from 'vue'import CarteCafe from './components/CarteCafe.vue'import { cafesExemples } from './cafes-exemples.js'
const cafes = ref(cafesExemples)</script>
<template> <main> <h1>Cafés étudiants — démonstration</h1> <div class="grille"> <CarteCafe v-for="cafe in cafes" :key="cafe.slug" :cafe="cafe" /> </div> </main></template>
<style scoped>main { max-width: 1100px; margin: 0 auto; padding: 24px 20px 48px; }h1 { margin: 0 0 16px; }.grille { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }</style>Vérification : trois cartes doivent apparaître.
Modifier le nom d’un café dans
src/cafes-exemples.js: sa carte doit afficher la nouvelle valeur après actualisation par Vite.
VérificationDans cette première version, quel composant doit connaître la liste complète des cafés ? Et pourquoi CarteCafe ne devrait-il pas chercher lui-même les cafés ?
App.vue connaît la collection et crée une carte par café. CarteCafe reçoit un seul objet et l’affiche. Il demeure réutilisable sans dépendre du fichier de données, d’une API particulière ou de l’existence d’autres cartes.
Étape 3 : Faire communiquer les composants
Section intitulée « Étape 3 : Faire communiquer les composants »Ajoutons un bouton permettant d’indiquer les cafés favoris. Le bouton se trouve dans CarteCafe, mais la liste des favoris appartient à App.vue, car elle concerne plusieurs cartes et servira aussi à filtrer la liste.
Observer les événements émis (defineEmits)
Section intitulée « Observer les événements émis (defineEmits) »- Café Beagel
- La Planck
- Café-In
Favoris : aucun
Un composant enfant peut déclarer et émettre un événement :
Composant enfant BoutonFavori.vue
Section intitulée « Composant enfant BoutonFavori.vue »<script setup>defineProps({ actif: { type: Boolean, default: false } })const emit = defineEmits(['basculer'])</script>
<template> <button type="button" aria-label="Favori" :aria-pressed="actif" @click="emit('basculer')"> {{ actif ? '★' : '☆' }} </button></template>Le parent reçoit cet événement et décide comment modifier sa liste :
Composant parent
Section intitulée « Composant parent »<script setup>import { ref } from 'vue'import BoutonFavori from './BoutonFavori.vue'
const noms = ['Café Démo Agora', 'Café Démo Jardin']const favoris = ref([])
function basculer(nom) { if (favoris.value.includes(nom)) { favoris.value = favoris.value.filter((f) => f !== nom) } else { favoris.value.push(nom) }}</script>
<template> <p v-for="nom in noms" :key="nom"> {{ nom }} <BoutonFavori :actif="favoris.includes(nom)" @basculer="basculer(nom)" /> </p></template>Ajouter les favoris au projet
Section intitulée « Ajouter les favoris au projet »Dans CarteCafe.vue, remplacer la déclaration des props par :
const props = defineProps({ cafe: { type: Object, required: true }, favori: { type: Boolean, default: false },})
const emit = defineEmits(['basculer-favori'])Dans son <template>, remplacer la ligne <header><h2>…</h2></header> par :
<header> <h2>{{ cafe.name }}</h2> <button type="button" class="favori" :aria-pressed="favori" :aria-label="`Favori : ${cafe.name}`" @click="emit('basculer-favori', cafe.slug)" > {{ favori ? '★' : '☆' }} </button></header>Ajouter dans son <style scoped> :
.favori { border: 0; background: none; font-size: 1.3rem; color: #b54708; cursor: pointer; }Le libellé accessible du bouton reste stable, tandis que aria-pressed indique si le café est favori. Chaque bouton identifie son café dans le libellé.
Dans App.vue, après const cafes = ..., ajouter :
const favoris = ref([]) // contient des slugs, pas des noms affichés
function basculerFavori(slug) { if (favoris.value.includes(slug)) { favoris.value = favoris.value.filter((s) => s !== slug) } else { favoris.value.push(slug) }}Remplacer l’utilisation de <CarteCafe /> dans la grille par :
<CarteCafe v-for="cafe in cafes" :key="cafe.slug" :cafe="cafe" :favori="favoris.includes(cafe.slug)" @basculer-favori="basculerFavori"/>Sous la grille, afficher le nombre de favoris :
<p>Favoris sélectionnés : {{ favoris.length }}</p>La valeur émise est le slug : avec @basculer-favori="basculerFavori", Vue la transmet comme argument de la fonction du parent.
Vérification : cliquer sur différentes étoiles. Une carte se met à jour sans changer les autres ; le nombre de favoris évolue dans App.vue.
Étape 4 : Charger les données depuis une API
Section intitulée « Étape 4 : Charger les données depuis une API »Nous avons maintenant une interface qui fonctionne sans connexion (réseau).
Pour l’alimenter avec des données distantes, nous n’avons pas besoin de modifier CarteCafe : c’est App.vue qui remplacera sa collection de cafés.
La source de données
Section intitulée « La source de données »L’API publique de Café sans-fil est présentée à l’adresse https://api.cafesansfil.ca/docs. Une requête envisagée pour la liste est :
GET https://api.cafesansfil.ca/v1/cafes?size=100Dans cette activité, nous travaillons avec le format de réponse suivant :
{ "items": [ { "slug": "demo-agora", "name": "Café Démo Agora", "is_open": true, "location": { "pavillon": "Pavillon A", "local": "A-101" }, "payment_details": [{ "method": "DEBIT" }] } ], "total": 1, "page": 1, "size": 1, "pages": 1}Observer les états d’un chargement
Section intitulée « Observer les états d’un chargement »Chargement…
La démonstration simule une réponse réussie ou un échec réseau :
<script setup>import { ref, onMounted } from 'vue'
const cafes = ref([])const chargement = ref(true)const erreur = ref('')const simulerErreur = ref(false)
function fausseRequete() { return new Promise((resoudre, rejeter) => { setTimeout(() => { if (simulerErreur.value) rejeter(new Error('réseau indisponible')) else resoudre(['Café Démo Agora', 'Café Démo Jardin']) }, 1000) })}
async function charger() { chargement.value = true erreur.value = '' try { cafes.value = await fausseRequete() } catch (e) { erreur.value = `Échec du chargement : ${e.message}` } finally { chargement.value = false }}
onMounted(charger)</script>
<template> <p v-if="chargement">Chargement…</p> <p v-else-if="erreur" role="alert">{{ erreur }}</p> <ul v-else> <li v-for="cafe in cafes" :key="cafe">{{ cafe }}</li> </ul> <label><input type="checkbox" v-model="simulerErreur" /> Simuler une erreur</label> <button :disabled="chargement" @click="charger">Recharger</button></template>Adapter App.vue
Section intitulée « Adapter App.vue »Dans <script setup> de App.vue :
- ajouter
onMountedà l’import venant devue; - supprimer l’import
cafesExemples; - remplacer
const cafes = ref(cafesExemples)par le code suivant.
const SOURCE = '/cafes.json' // test local stable ; l'API distante pourra être essayée ensuite
const cafes = ref([])const chargement = ref(true)const erreur = ref('')
async function chargerCafes() { chargement.value = true erreur.value = ''
try { const reponse = await fetch(SOURCE) if (!reponse.ok) throw new Error(`statut HTTP ${reponse.status}`)
const page = await reponse.json() if (!Array.isArray(page.items)) throw new Error('format de réponse inattendu') cafes.value = page.items } catch (e) { erreur.value = `Impossible de charger les cafés (${e.message}).` } finally { chargement.value = false }}
onMounted(chargerCafes)Dans le gabarit, remplacer la grille actuelle par ces éléments. La grille et ses cartes sont désormais affichées uniquement après un chargement réussi :
<p v-if="chargement" class="message" role="status">Chargement des cafés…</p><p v-else-if="erreur" class="message erreur" role="alert">{{ erreur }}</p><template v-else> <div class="grille"> <CarteCafe v-for="cafe in cafes" :key="cafe.slug" :cafe="cafe" :favori="favoris.includes(cafe.slug)" @basculer-favori="basculerFavori" /> </div></template>Ajouter les styles suivants au <style scoped> de App.vue :
.message { margin: 0 0 12px; color: var(--gris); }.erreur { color: #b42318; }Vérification : les cafés apparaissent avec /cafes.json. Dans l’onglet Réseau des outils de développement, observer la requête, son statut et sa réponse. Renommer temporairement l’URL en /absent.json pour tester le message d’erreur, puis rétablir la bonne adresse.
Le composant CarteCafe n’a pas changé : l’affichage dépend de la structure des données reçues, pas de leur provenance. Si l’API réelle utilise une autre structure, il faudra adapter les données à celle attendue par les cartes.