3. Les composants
Jusqu'ici, chaque exemple tenait dans un seul composant, [App.vue]. Une vraie application en compte des dizaines : une barre de navigation, des formulaires, des fenêtres... Chaque composant est un morceau d'interface autonome (son HTML, son code, son style) et réutilisable. Ce chapitre montre comment les composants s'emboîtent et communiquent.
3.1. Exemple 07 : les composants et leurs props
L'exemple affiche une carte par médecin. La carte est un composant, [CarteMedecin.vue], utilisé trois fois par son parent, [App.vue].
Le type des médecins, partagé par les deux composants, est dans un fichier [TypeScript] ordinaire :
| // le type des données partagé par le parent (App.vue) et l'enfant (CarteMedecin.vue)
export interface Medecin {
id: number;
titre: string;
prenom: string;
nom: string;
specialite: string;
disponible: boolean;
}
|
Le composant enfant :
| <!--
=========================================================================
07-composants/src/components/CarteMedecin.vue - un composant ENFANT
-------------------------------------------------------------------------
Il reçoit ses données de son parent par des PROPS (propriétés), déclarées
avec defineProps. Le type TypeScript des props est vérifié à la
compilation : <CarteMedecin :medecin="42"> serait refusé.
Règle d'or : un composant ne MODIFIE JAMAIS ses props. Les données
descendent du parent vers l'enfant ("one-way data flow") ; pour
demander un changement, l'enfant émet un événement (cf. exemple 08).
=========================================================================
-->
<script setup lang="ts">
import { computed } from 'vue';
import type { Medecin } from '../medecin';
// withDefaults : une valeur par défaut pour les props facultatives (?)
const props = withDefaults(
defineProps<{
medecin: Medecin;
rang: number;
compact?: boolean;
}>(),
{ compact: false },
);
// un computed fondé sur une prop : recalculé si le parent change la prop
const initiales = computed(() => `${props.medecin.prenom[0]}${props.medecin.nom[0]}`);
</script>
<template>
<div class="card h-100" :class="{ 'border-success': medecin.disponible }">
<div class="card-body d-flex gap-3 align-items-center">
<div class="initiales">{{ initiales }}</div>
<div>
<!-- dans le template, les props s'utilisent directement par leur nom -->
<h2 class="h6 mb-1">{{ rang }}. {{ medecin.titre }} {{ medecin.prenom }} {{ medecin.nom }}</h2>
<p v-if="!compact" class="mb-0 text-muted small">{{ medecin.specialite }}</p>
</div>
</div>
</div>
</template>
<style scoped>
.initiales {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: #0d6efd;
color: white;
font-weight: bold;
}
</style>
|
- ligne 16 : [import type] importe un type seulement : rien de cet import ne subsiste dans le [JavaScript] produit ;
- lignes 19-26 : [defineProps<{...}>()] déclare les props du composant, c'est-à-dire les données qu'il reçoit de son parent, avec leur type. Une prop suivie de [?] est facultative ; [withDefaults] lui donne une valeur par défaut. [defineProps] n'a pas besoin d'être importée : c'est une « macro » que le compilateur des fichiers [.vue] reconnaît ;
- ligne 29 : dans le script, on lit les props par l'objet [props]. Le [computed] suit les props : si le parent change le médecin transmis, les initiales sont recalculées ;
- ligne 38 : dans le template, les props s'utilisent directement par leur nom ;
- ligne 39 : une prop peut modifier l'affichage.
Le parent :
| <!--
=========================================================================
07-composants/src/App.vue - le composant PARENT
-------------------------------------------------------------------------
Il importe le composant CarteMedecin et l'utilise comme une nouvelle
balise HTML, <CarteMedecin>, autant de fois qu'il le faut : un composant
est un morceau d'interface RÉUTILISABLE, avec ses propres données, son
template et son style.
=========================================================================
-->
<script setup lang="ts">
import { ref } from 'vue';
// dans <script setup>, un composant importé est aussitôt utilisable dans le template
import CarteMedecin from './components/CarteMedecin.vue';
import type { Medecin } from './medecin';
const medecins = ref<Medecin[]>([
{ id: 1, titre: 'Mme', prenom: 'Marie', nom: 'PELISSIER', specialite: 'généraliste', disponible: true },
{ id: 2, titre: 'Mr', prenom: 'Paul', nom: 'TATOU', specialite: 'pédiatre', disponible: false },
{ id: 3, titre: 'Melle', prenom: 'Sophie', nom: 'DUPONT', specialite: 'généraliste', disponible: true },
]);
const compact = ref(false);
// le parent modifie SES données : les enfants concernés se mettent à jour
function inverserDisponibilite(): void {
for (const m of medecins.value) {
m.disponible = !m.disponible;
}
}
</script>
<template>
<div class="container py-4">
<h1 class="h3 mb-3">Exemple 07 - les composants et leurs props</h1>
<div class="d-flex gap-3 mb-3">
<label class="form-check-label"><input v-model="compact" type="checkbox" class="form-check-input"> affichage compact</label>
<button class="btn btn-sm btn-outline-primary" @click="inverserDisponibilite">Inverser les disponibilités</button>
</div>
<div class="row g-3">
<div v-for="(m, i) in medecins" :key="m.id" class="col-md-4">
<!-- :medecin="m" transmet l'OBJET m (une expression) ;
compact, sans deux-points, transmettrait la CHAÎNE "compact" -->
<CarteMedecin :medecin="m" :rang="i + 1" :compact="compact" />
</div>
</div>
</div>
</template>
|
- ligne 14 : dans [<script setup>], un composant importé est aussitôt utilisable dans le template, comme une nouvelle balise ;
- ligne 43 : chaque carte reçoit SON médecin. [:medecin="m"] (avec deux-points) transmet la valeur de l'expression [m] ; sans les deux-points, on transmettrait la chaîne de caractères ["m"], ce que [TypeScript] refuserait ici. Le nom des balises de composants s'écrit en [PascalCase] pour les distinguer des balises HTML ;
- lignes 25-29 : le parent modifie SES données ; les cartes concernées se mettent à jour.
La règle d'or : un composant ne modifie jamais ses props. Les données descendent du parent vers l'enfant ([one-way data flow]) : le parent en est le propriétaire. Pour demander un changement, l'enfant émet un événement, que le parent traite : c'est l'objet de l'exemple suivant.

3.2. Exemple 08 : les événements d'un composant et v-model
3.2.1. Émettre un événement
Le composant [Compteur] prévient son parent chaque fois qu'on clique sur son bouton :
| <!--
=========================================================================
08-evenements-composants/src/components/Compteur.vue
-------------------------------------------------------------------------
Un enfant qui PRÉVIENT son parent : defineEmits déclare les événements
qu'il peut émettre, avec le type de leurs données. Le parent les écoute
comme des événements HTML : <Compteur @change="..." @limite="...">.
=========================================================================
-->
<script setup lang="ts">
import { ref } from 'vue';
const props = defineProps<{ libelle: string; max: number }>();
// deux événements : 'change' (avec la nouvelle valeur) et 'limite' (sans donnée)
const emit = defineEmits<{
change: [valeur: number];
limite: [];
}>();
const valeur = ref(0);
function plus(): void {
if (valeur.value >= props.max) {
emit('limite');
return;
}
valeur.value++;
emit('change', valeur.value);
}
</script>
<template>
<div class="d-flex align-items-center gap-2 mb-2">
<span class="w-25">{{ libelle }}</span>
<button class="btn btn-sm btn-outline-primary" @click="plus">+1</button>
<span class="badge text-bg-secondary">{{ valeur }} / {{ max }}</span>
</div>
</template>
|
- lignes 16-19 : [defineEmits] déclare les événements que le composant peut émettre, avec le type de leurs données : [change] transporte un nombre, [limite] rien du tout ;
- ligne 25 et ligne 29 : l'émission d'un événement. [TypeScript] vérifie le nom de l'événement et le type de sa donnée ;
- ligne 21 : l'enfant a le droit d'avoir son propre état : [valeur] est à lui, pas au parent.
3.2.2. v-model sur un composant
Le composant [ChampTexte] est un champ de formulaire « maison » : un libellé, le champ, une aide, un compteur de caractères. On veut l'utiliser avec [v-model], comme un [<input>] ordinaire :
| <!--
=========================================================================
08-evenements-composants/src/components/ChampTexte.vue
-------------------------------------------------------------------------
Un champ de formulaire "maison" (libellé + champ + aide + compteur de
caractères) qui accepte v-model, comme un <input> ordinaire :
<ChampTexte v-model="nom" libelle="Nom" :max="20" />
defineModel() crée une ref reliée au v-model du parent : la LIRE donne la
valeur du parent, l'ÉCRIRE émet l'événement 'update:modelValue', que le
parent traite en mettant à jour sa donnée. Les données restent donc bien
la propriété du parent.
=========================================================================
-->
<script setup lang="ts">
import { useId } from 'vue';
const modele = defineModel<string>({ required: true });
defineProps<{ libelle: string; max: number; aide?: string }>();
// un identifiant unique par instance, pour relier <label for> et <input id>
const id = useId();
</script>
<template>
<div class="mb-3">
<label class="form-label" :for="id">{{ libelle }}</label>
<!-- v-model="modele" : le champ natif est relié à la ref de defineModel -->
<input :id="id" v-model="modele" class="form-control" :maxlength="max" :class="{ 'is-invalid': modele.length === max }">
<div class="form-text">{{ aide }} ({{ modele.length }} / {{ max }})</div>
</div>
</template>
|
- ligne 18 : [defineModel] crée une [ref] reliée au [v-model] du parent. La lire donne la valeur du parent ; l'écrire émet l'événement [update:modelValue], que le parent traite en modifiant SA donnée. [v-model="nom"] sur un composant n'est qu'un raccourci pour la prop [modelValue] et cet événement : la donnée reste la propriété du parent ;
- ligne 22 : [useId()] fournit un identifiant unique par instance du composant : les deux [ChampTexte] de la page n'auront pas le même [id], et chaque [<label for>] désigne bien SON champ ;
- ligne 29 : le champ natif est relié à la [ref] de [defineModel].
3.2.3. Le parent
| <!--
=========================================================================
08-evenements-composants/src/App.vue - le parent écoute ses enfants
-------------------------------------------------------------------------
Deux façons pour un enfant de faire remonter une information :
1. émettre un événement, que le parent écoute avec @ (Compteur) ;
2. v-model sur un composant (ChampTexte) : un raccourci pour la prop
modelValue + l'événement update:modelValue.
=========================================================================
-->
<script setup lang="ts">
import { computed, ref } from 'vue';
import Compteur from './components/Compteur.vue';
import ChampTexte from './components/ChampTexte.vue';
const total = ref(0);
const message = ref('');
const prenom = ref('Marie');
const nom = ref('PELISSIER');
const nomComplet = computed(() => `${prenom.value} ${nom.value.toUpperCase()}`);
// le gestionnaire de l'événement 'change' : il reçoit la donnée émise
function surChange(libelle: string, valeur: number): void {
total.value++;
message.value = `${libelle} vaut maintenant ${valeur}`;
}
</script>
<template>
<div class="container py-4" style="max-width: 640px">
<h1 class="h3 mb-3">Exemple 08 - événements et v-model sur un composant</h1>
<div class="card p-3 mb-4">
<h2 class="h6">1. Des événements émis par les enfants</h2>
<Compteur libelle="Cafés" :max="3" @change="(v) => surChange('Cafés', v)" @limite="message = 'Pas plus de 3 cafés !'" />
<Compteur libelle="Verres d'eau" :max="8" @change="(v) => surChange(`Verres d'eau`, v)" @limite="message = 'Assez bu.'" />
<p class="mb-0">Total des clics : {{ total }} - <em>{{ message }}</em></p>
</div>
<div class="card p-3">
<h2 class="h6">2. v-model sur un composant</h2>
<ChampTexte v-model="prenom" libelle="Prénom" :max="20" aide="Obligatoire" />
<ChampTexte v-model="nom" libelle="Nom" :max="20" aide="Mis en majuscules à l'affichage" />
<p class="mb-0">Données du parent : <strong>{{ nomComplet }}</strong></p>
<button class="btn btn-sm btn-outline-secondary mt-2 align-self-start" @click="prenom = ''; nom = ''">Vider (depuis le parent)</button>
</div>
</div>
</template>
|
- ligne 35 : le parent écoute les événements de ses enfants avec [@], comme des événements HTML. La fonction fléchée [(v) => surChange('Cafés', v)] reçoit la donnée de l'événement ; [@limite] exécute une simple instruction ;
- ligne 42 : [v-model] sur un composant ;
- ligne 45 : quand le PARENT vide ses données, les champs suivent : la liaison fonctionne dans les deux sens.

3.3. Exemple 09 : gabarits et slots
Les props transmettent des données ; les slots transmettent du contenu : du HTML, d'autres composants. Le composant [Carte] fixe le cadre (bordure, en-tête, pied), et c'est son parent qui dit ce qu'il y a dedans :
| <!--
=========================================================================
09-slots/src/components/Carte.vue - un GABARIT réutilisable
-------------------------------------------------------------------------
Les props transmettent des DONNÉES ; les slots transmettent du CONTENU
(du HTML, d'autres composants). Carte fixe le cadre (bordure, en-tête,
pied), le parent fournit ce qu'il y a dedans :
- <slot /> le slot par défaut : le contenu "principal" ;
- <slot name="entete"> un slot nommé, rempli par <template #entete> ;
- le texte placé ENTRE <slot> et </slot> est affiché si le parent ne
fournit rien (contenu par défaut).
C'est l'équivalent, côté client, du gabarit layout.hbs et de son {{{body}}}
dans le cours NestJS.
=========================================================================
-->
<script setup lang="ts">
defineProps<{ couleur?: 'primary' | 'success' | 'danger' }>();
</script>
<template>
<div class="card mb-3" :class="couleur ? `border-${couleur}` : ''">
<div class="card-header" :class="couleur ? `text-bg-${couleur}` : ''">
<slot name="entete">Sans titre</slot>
</div>
<div class="card-body">
<slot />
</div>
<!-- $slots.pied : le parent a-t-il fourni un pied ? Sinon, pas de <div> vide -->
<div v-if="$slots.pied" class="card-footer text-muted small">
<slot name="pied" />
</div>
</div>
</template>
|
- ligne 23 : un slot nommé ; le texte placé entre [<slot>] et [</slot>] est affiché si le parent ne fournit rien ;
- ligne 26 : le slot par défaut : le contenu « principal » ;
- ligne 29 : [$slots] dit quels slots le parent a remplis : sans pied, pas de [<div>] vide.
C'est l'équivalent, côté client, du gabarit [layout.hbs] et de son [{{{body}}}] dans le cours [NestJS].
Un second composant, [Tableau], affiche des lignes de n'importe quel type :
| <!--
=========================================================================
09-slots/src/components/Tableau.vue - un tableau GÉNÉRIQUE, à slot "scopé"
-------------------------------------------------------------------------
generic="T" : un composant générique, comme une classe générique
TypeScript - il affiche des lignes de n'importe quel type T.
Le tableau sait parcourir les lignes, mais pas comment afficher une
ligne : il PASSE chaque ligne à son slot (<slot name="ligne" :ligne="l">),
et c'est le parent qui dit quoi en faire (<template #ligne="{ ligne }">).
On parle de slot "scopé" : le contenu fourni par le parent utilise des
données qui appartiennent à l'enfant.
=========================================================================
-->
<script setup lang="ts" generic="T extends { id: number }">
defineProps<{ lignes: T[]; colonnes: string[] }>();
</script>
<template>
<table class="table table-sm align-middle mb-0">
<thead>
<tr><th v-for="c in colonnes" :key="c">{{ c }}</th></tr>
</thead>
<tbody>
<tr v-for="(l, i) in lignes" :key="l.id">
<slot name="ligne" :ligne="l" :index="i" />
</tr>
<tr v-if="lignes.length === 0">
<td :colspan="colonnes.length" class="text-muted"><slot name="vide">Aucune donnée.</slot></td>
</tr>
</tbody>
</table>
</template>
|
- ligne 15 : un composant générique, comme une classe générique [TypeScript] : [T] est le type des lignes, dont on exige seulement une propriété [id] (pour la clé du [v-for]) ;
- ligne 26 : le tableau sait parcourir les lignes, mais pas comment afficher une ligne. Il passe chaque ligne à son slot, et c'est le parent qui l'affiche. On parle de slot « scopé » : le contenu fourni par le parent utilise des données qui appartiennent à l'enfant.
Le parent :
| <!--
=========================================================================
09-slots/src/App.vue - utiliser les gabarits Carte et Tableau
=========================================================================
-->
<script setup lang="ts">
import { ref } from 'vue';
import Carte from './components/Carte.vue';
import Tableau from './components/Tableau.vue';
interface Creneau {
id: number;
horaire: string;
patient: string | null;
}
const creneaux = ref<Creneau[]>([
{ id: 1, horaire: '8:00 - 8:20', patient: null },
{ id: 2, horaire: '8:20 - 8:40', patient: 'Mme Alice DURAND' },
{ id: 3, horaire: '8:40 - 9:00', patient: null },
]);
</script>
<template>
<div class="container py-4" style="max-width: 720px">
<h1 class="h3 mb-3">Exemple 09 - gabarits et slots</h1>
<!-- #entete est l'abréviation de v-slot:entete -->
<Carte couleur="primary">
<template #entete>Agenda de Mme PELISSIER</template>
<!-- le slot par défaut : tout ce qui n'est pas dans un <template #...> -->
<Tableau :lignes="creneaux" :colonnes="['#', 'Créneau', 'Statut']">
<!-- on récupère les données passées par le Tableau : { ligne, index } -->
<template #ligne="{ ligne, index }">
<td>{{ index + 1 }}</td>
<td>{{ ligne.horaire }}</td>
<td>
<span v-if="ligne.patient" class="badge text-bg-secondary">{{ ligne.patient }}</span>
<span v-else class="badge text-bg-success">Libre</span>
</td>
</template>
</Tableau>
<template #pied>{{ creneaux.filter((c) => c.patient === null).length }} créneau(x) libre(s)</template>
</Carte>
<Carte couleur="success">
<template #entete>Une carte sans pied</template>
<p class="mb-0">Le pied n'est pas fourni : la carte ne l'affiche pas.</p>
</Carte>
<Carte>
<!-- pas d'en-tête fourni : le contenu par défaut du slot, « Sans titre » -->
<Tableau :lignes="[]" :colonnes="['Nom']">
<template #vide>Personne pour l'instant.</template>
</Tableau>
</Carte>
</div>
</template>
|
- ligne 30 : [<template #entete>] (abréviation de [v-slot:entete]) remplit le slot nommé [entete] ;
- ligne 32 : tout ce qui n'est pas dans un [<template #...>] va dans le slot par défaut ;
- ligne 34 : on récupère les données passées par le tableau. Grâce au composant générique, [TypeScript] sait que [ligne] est un [Creneau] ;
- ligne 51 : une carte sans en-tête : le contenu par défaut, « Sans titre », s'affiche.

3.4. Exemple 10 : le cycle de vie d'un composant
Un composant naît, s'affiche, se met à jour, puis disparaît. [Vue.js] permet d'exécuter du code à chacune de ces étapes. Le composant [Horloge] :
| <!--
=========================================================================
10-cycle-de-vie/src/components/Horloge.vue
-------------------------------------------------------------------------
Le cycle de vie d'un composant :
1. setup : le code de <script setup> s'exécute (création) ;
2. onMounted : le composant est AFFICHÉ, ses éléments HTML existent ;
3. onUpdated : son affichage vient d'être mis à jour ;
4. onUnmounted: le composant a été RETIRÉ de la page.
Une horloge lance une minuterie (setInterval) à l'affichage... et doit
impérativement l'arrêter quand elle disparaît : sinon la minuterie
continuerait de tourner pour rien (fuite de mémoire).
=========================================================================
-->
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
const emit = defineEmits<{ journal: [message: string] }>();
const heure = ref(new Date().toLocaleTimeString());
let minuterie: number | undefined;
emit('journal', 'Horloge : setup (création)');
onMounted(() => {
emit('journal', 'Horloge : onMounted - démarrage de la minuterie');
minuterie = window.setInterval(() => {
heure.value = new Date().toLocaleTimeString();
}, 1000);
});
onUnmounted(() => {
window.clearInterval(minuterie);
emit('journal', 'Horloge : onUnmounted - minuterie arrêtée');
});
</script>
<template>
<div class="display-6 font-monospace">{{ heure }}</div>
</template>
|
- ligne 23 : le code de [<script setup>] s'exécute à la création du composant, une fois ;
- lignes 25-30 : [onMounted] : le composant est affiché, ses éléments HTML existent. On démarre la minuterie ;
- lignes 32-35 : [onUnmounted] : le composant a été retiré de la page. On arrête impérativement la minuterie : sinon elle continuerait de tourner pour un composant disparu (une « fuite de mémoire »).
Le parent :
| <!--
=========================================================================
10-cycle-de-vie/src/App.vue
-------------------------------------------------------------------------
- v-if sur <Horloge> : cocher / décocher la case crée / détruit le
composant (onMounted / onUnmounted, cf. Horloge.vue) ;
- une "template ref" : ref="champ" donne accès à l'élément HTML <input>
lui-même, une fois le composant affiché (pour lui donner le focus) ;
- watch avec { immediate: true } : exécuté tout de suite, puis à chaque
changement ;
- nextTick : attendre que Vue ait mis la page à jour.
=========================================================================
-->
<script setup lang="ts">
import { nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
import Horloge from './components/Horloge.vue';
const avecHorloge = ref(true);
const journal = ref<string[]>([]);
const recherche = ref('');
const afficherRecherche = ref(false);
// l'élément <input ref="champ"> du template (null tant qu'il n'est pas affiché)
const champ = useTemplateRef<HTMLInputElement>('champ');
function tracer(message: string): void {
journal.value.push(`${new Date().toLocaleTimeString()} ${message}`);
}
onMounted(() => tracer('App : onMounted'));
watch(
recherche,
(valeur) => {
document.title = valeur ? `Recherche : ${valeur}` : 'Exemple 10';
},
{ immediate: true },
);
async function ouvrirRecherche(): Promise<void> {
afficherRecherche.value = true;
// à cet instant, le <input> n'existe pas encore : Vue mettra la page à
// jour un peu plus tard. nextTick() attend cette mise à jour.
await nextTick();
champ.value?.focus();
}
</script>
<template>
<div class="container py-4" style="max-width: 720px">
<h1 class="h3 mb-3">Exemple 10 - le cycle de vie d'un composant</h1>
<div class="card p-3 mb-3">
<label class="form-check-label mb-2">
<input v-model="avecHorloge" type="checkbox" class="form-check-input"> afficher l'horloge
</label>
<Horloge v-if="avecHorloge" @journal="tracer" />
</div>
<div class="card p-3 mb-3">
<button v-if="!afficherRecherche" class="btn btn-outline-primary align-self-start" @click="ouvrirRecherche">
Rechercher...
</button>
<input v-else ref="champ" v-model="recherche" class="form-control" placeholder="le titre de l'onglet suit la saisie">
</div>
<h2 class="h6">Journal</h2>
<ol class="small">
<li v-for="(ligne, i) in journal" :key="i">{{ ligne }}</li>
</ol>
</div>
</template>
|
- ligne 57 : cocher et décocher la case crée et détruit l'horloge : le journal montre [setup], [onMounted], [onUnmounted] ;
- ligne 24 : une référence de template : l'attribut [ref="champ"] de l'élément [<input>] donne accès à l'élément HTML lui-même. Elle vaut [null] tant que l'élément n'est pas affiché ;
- lignes 32-37 : [watch] avec [{ immediate: true }] s'exécute tout de suite, puis à chaque changement : le titre de l'onglet suit la saisie ;
- lignes 40-45 : après [afficherRecherche.value = true], le champ n'existe pas encore : [Vue.js] regroupe les mises à jour de la page et les fait un peu plus tard. [await nextTick()] attend cette mise à jour ; alors seulement, on peut donner le focus au champ.

3.5. Exemple 11 : les composables, provide et inject
Comment partager de la logique entre composants ? Côté serveur, [NestJS] avait ses services, injectés dans les contrôleurs. [Vue.js] a ses composables : de simples fonctions, dont le nom commence par [use], qui regroupent un état réactif et le code qui va avec.
| // =========================================================================
// 11-composables/src/composables/useHorloge.ts - un COMPOSABLE
// -------------------------------------------------------------------------
// Un composable est une simple fonction, dont le nom commence par "use"
// par convention, qui regroupe un état réactif ET la logique qui va avec
// (ici : la minuterie, son démarrage et son arrêt). Tout composant qui
// l'appelle obtient SA propre horloge, sans recopier le code.
//
// C'est la manière Vue.js de partager de la logique entre composants - le
// rôle que tenaient, côté serveur, les services NestJS.
// =========================================================================
import { onMounted, onUnmounted, ref, type Ref } from 'vue';
export function useHorloge(periodeMs = 1000): { maintenant: Ref<Date> } {
const maintenant = ref(new Date());
let minuterie: number | undefined;
// les hooks de cycle de vie s'attachent au composant qui APPELLE le composable
onMounted(() => {
minuterie = window.setInterval(() => (maintenant.value = new Date()), periodeMs);
});
onUnmounted(() => window.clearInterval(minuterie));
return { maintenant };
}
|
- ligne 15 : la fonction renvoie un objet contenant la [ref] [maintenant] ;
- lignes 20-22 : les fonctions de cycle de vie appelées dans un composable s'attachent au composant qui appelle le composable : chaque composant a sa minuterie, arrêtée quand il disparaît.
Un second composable, sans cycle de vie :
| // =========================================================================
// 11-composables/src/composables/usePanier.ts
// -------------------------------------------------------------------------
// Un second composable, SANS cycle de vie : un panier (liste d'articles,
// total, ajout, retrait). Chaque appel de usePanier() crée un NOUVEAU
// panier : deux composants qui l'appellent ont chacun le leur.
// (Pour un panier PARTAGÉ par toute l'application, cf. provide / inject
// ci-dessous, et Pinia dans l'exemple 15.)
// =========================================================================
import { computed, ref } from 'vue';
export interface Article {
libelle: string;
prix: number;
}
export function usePanier() {
const articles = ref<Article[]>([]);
const total = computed(() => articles.value.reduce((somme, a) => somme + a.prix, 0));
function ajouter(article: Article): void {
articles.value.push(article);
}
function retirer(index: number): void {
articles.value.splice(index, 1);
}
return { articles, total, ajouter, retirer };
}
// le type de ce que renvoie usePanier : utile pour provide / inject
export type Panier = ReturnType<typeof usePanier>;
|
- ligne 19 : chaque appel de [usePanier()] crée un NOUVEAU panier ;
- ligne 33 : le type de ce que renvoie [usePanier], déduit par [TypeScript].
Pour que plusieurs composants partagent LE MÊME panier, on utilise [provide] et [inject], une forme d'injection de dépendances :
| // =========================================================================
// 11-composables/src/cles.ts - les clés de provide / inject
// -------------------------------------------------------------------------
// provide(cle, valeur) : un composant MET À DISPOSITION une valeur pour
// tous ses descendants, quelle que soit leur profondeur ;
// inject(cle) : un descendant la récupère - sans que les composants
// intermédiaires aient à la transmettre de props en props.
// C'est une forme d'injection de dépendances, comme dans NestJS.
//
// Une InjectionKey<T> (un Symbol typé) garantit que inject() renvoie une
// valeur du bon type.
// =========================================================================
import type { InjectionKey } from 'vue';
import type { Panier } from './composables/usePanier';
export const CLE_PANIER: InjectionKey<Panier> = Symbol('panier');
|
- ligne 17 : la clé sous laquelle le panier est fourni : un [Symbol] (valeur unique) typé par [InjectionKey<Panier>], pour que [inject] renvoie un objet du bon type.
Le composant racine crée le panier et le fournit :
| <!--
=========================================================================
11-composables/src/App.vue
-------------------------------------------------------------------------
- useHorloge() : App a son horloge (1 s), ResumePanier la sienne (5 s) ;
- usePanier() + provide : App crée UN panier et le fournit à tous ses
descendants ; Catalogue et ResumePanier l'injectent et partagent
donc le même.
=========================================================================
-->
<script setup lang="ts">
import { provide } from 'vue';
import { useHorloge } from './composables/useHorloge';
import { usePanier } from './composables/usePanier';
import { CLE_PANIER } from './cles';
import Catalogue from './components/Catalogue.vue';
import ResumePanier from './components/ResumePanier.vue';
const { maintenant } = useHorloge();
provide(CLE_PANIER, usePanier());
</script>
<template>
<div class="container py-4" style="max-width: 720px">
<h1 class="h3 mb-3">Exemple 11 - composables et provide / inject</h1>
<p class="text-muted">Horloge de App : {{ maintenant.toLocaleTimeString() }}</p>
<div class="row g-3">
<div class="col-md-6"><Catalogue /></div>
<div class="col-md-6"><ResumePanier /></div>
</div>
</div>
</template>
|
- ligne 19 : App a sa propre horloge, rafraîchie chaque seconde ;
- ligne 20 : App met un panier à disposition de TOUS ses descendants, quelle que soit leur profondeur, sans avoir à le transmettre de props en props.
Deux descendants l'injectent :
| <!--
11-composables/src/components/Catalogue.vue - un descendant qui INJECTE le panier
-->
<script setup lang="ts">
import { inject } from 'vue';
import { CLE_PANIER } from '../cles';
import type { Article } from '../composables/usePanier';
// le panier fourni par App.vue (provide) ; erreur explicite s'il manque
const panier = inject(CLE_PANIER);
if (!panier) {
throw new Error('Catalogue doit être placé sous un composant qui fournit le panier');
}
const catalogue: Article[] = [
{ libelle: 'Consultation', prix: 30 },
{ libelle: 'Vaccin', prix: 12.5 },
{ libelle: 'Certificat', prix: 5 },
];
</script>
<template>
<ul class="list-group">
<li v-for="a in catalogue" :key="a.libelle" class="list-group-item d-flex justify-content-between align-items-center">
{{ a.libelle }} ({{ a.prix }} €)
<button class="btn btn-sm btn-outline-primary" @click="panier.ajouter(a)">Ajouter</button>
</li>
</ul>
</template>
|
- ligne 10 : on récupère le panier fourni par un ancêtre ; [inject] renvoie [undefined] si aucun ancêtre ne l'a fourni : on le signale par une erreur explicite.
| <!--
11-composables/src/components/ResumePanier.vue - un autre descendant, le MÊME panier
-->
<script setup lang="ts">
import { inject } from 'vue';
import { CLE_PANIER } from '../cles';
import { useHorloge } from '../composables/useHorloge';
const panier = inject(CLE_PANIER)!;
// ce composant a SA propre horloge, rafraîchie toutes les 5 secondes
const { maintenant } = useHorloge(5000);
</script>
<!-- panier.articles.value : dans un template, Vue ne "déballe" (.value
implicite) que les ref de PREMIER niveau du script, comme maintenant.
Les ref rangées DANS un objet (panier.articles) s'écrivent avec .value -->
<template>
<div class="card p-3">
<h2 class="h6">Panier ({{ panier.articles.value.length }} article(s)) - {{ maintenant.toLocaleTimeString() }}</h2>
<ul class="mb-2">
<li v-for="(a, i) in panier.articles.value" :key="i">
{{ a.libelle }} <a href="#" @click.prevent="panier.retirer(i)">retirer</a>
</li>
</ul>
<strong>Total : {{ panier.total.value }} €</strong>
</div>
</template>
|
- ligne 9 : même injection ; le [!] affirme à [TypeScript] que la valeur existe ;
- ligne 11 : ce composant a SA propre horloge, rafraîchie toutes les 5 secondes ;
- ligne 19 : dans un template, [Vue.js] ne « déballe » automatiquement que les [ref] de premier niveau du script. Une [ref] rangée DANS un objet ([panier.articles]) s'écrit avec [.value].
Les deux composants affichent le même panier ; les deux horloges vivent chacune à leur rythme :

3.6. Exemple 12 : une fenêtre de confirmation réutilisable
Avant une suppression, on demande « Êtes-vous sûr ? ». La fonction [window.confirm()] du navigateur existe, mais on ne peut ni mettre en forme sa boîte, ni traduire ses boutons. On la remplace par une fenêtre [Bootstrap], pilotée par un composable. C'est la version [Vue.js] de l'exemple 21 du cours [NestJS].
| // =========================================================================
// 12-confirmation/src/composables/useConfirmation.ts
// -------------------------------------------------------------------------
// « Êtes-vous sûr ? » : window.confirm() existe, mais on ne peut ni le
// mettre en forme, ni traduire ses boutons. On le remplace par une fenêtre
// Bootstrap (FenetreConfirmation.vue), pilotée par ce composable.
//
// L'astuce : confirmer(message) renvoie une PROMESSE, résolue à true (Oui)
// ou false (Non) quand l'utilisateur répond. Le code appelant s'écrit
// alors aussi simplement qu'avec window.confirm :
//
// if (await confirmer('Supprimer ce médecin ?')) { ... }
//
// L'état (message affiché, fonction à appeler à la réponse) est déclaré
// HORS de la fonction : il est donc UNIQUE pour toute l'application. Une
// seule fenêtre, placée une fois dans App.vue, sert tous les composants.
// =========================================================================
import { readonly, ref } from 'vue';
const message = ref<string | null>(null); // null : fenêtre fermée
let repondre: ((reponse: boolean) => void) | null = null;
export function useConfirmation() {
function confirmer(texte: string): Promise<boolean> {
message.value = texte;
return new Promise<boolean>((resolve) => {
repondre = resolve;
});
}
function reponse(oui: boolean): void {
message.value = null;
repondre?.(oui);
repondre = null;
}
// readonly : les composants lisent le message, seul le composable le modifie
return { message: readonly(message), confirmer, reponse };
}
|
- ligne 21 et ligne 22 : l'état est déclaré HORS de la fonction [useConfirmation], au niveau du module. Il est donc unique pour toute l'application : une seule fenêtre sert tous les composants ;
- lignes 25-30 : [confirmer] ouvre la fenêtre (en donnant une valeur au message) et renvoie une promesse. Elle garde la fonction [resolve] de cette promesse dans [repondre] : la promesse ne sera résolue que lorsque l'utilisateur aura répondu ;
- lignes 32-35 : la réponse de l'utilisateur ferme la fenêtre et résout la promesse à [true] ou [false] ;
- ligne 39 : les composants peuvent lire le message, pas le modifier.
La fenêtre :
| <!--
=========================================================================
12-confirmation/src/components/FenetreConfirmation.vue
-------------------------------------------------------------------------
La fenêtre modale, placée UNE fois dans App.vue.
- <Teleport to="body"> : son HTML est inséré à la fin de <body>, hors
de l'arborescence des composants - une fenêtre modale doit passer
au-dessus de tout, quel que soit l'endroit où elle est déclarée ;
- mêmes classes Bootstrap qu'une vraie modale (.modal, .modal-backdrop),
affichées "en dur" (show d-block) : pas besoin du JavaScript de Bootstrap ;
- clavier : Échap = Non ; le focus est placé sur le bouton "Non" (le
choix le moins dangereux) à l'ouverture.
=========================================================================
-->
<script setup lang="ts">
import { nextTick, onUnmounted, useTemplateRef, watch } from 'vue';
import { useConfirmation } from '../composables/useConfirmation';
const { message, reponse } = useConfirmation();
const boutonNon = useTemplateRef<HTMLButtonElement>('boutonNon');
function clavier(e: KeyboardEvent): void {
if (e.key === 'Escape') {
reponse(false);
}
}
// à l'ouverture : focus sur "Non", écoute de la touche Échap ; à la fermeture : on arrête d'écouter
watch(message, async (valeur) => {
if (valeur !== null) {
document.addEventListener('keydown', clavier);
await nextTick();
boutonNon.value?.focus();
} else {
document.removeEventListener('keydown', clavier);
}
});
onUnmounted(() => document.removeEventListener('keydown', clavier));
</script>
<template>
<Teleport to="body">
<template v-if="message !== null">
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1" role="alertdialog" aria-modal="true" @click.self="reponse(false)">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirmation</h5>
<button type="button" class="btn-close" aria-label="Fermer" @click="reponse(false)"></button>
</div>
<div class="modal-body">
<p class="mb-0">{{ message }}</p>
</div>
<div class="modal-footer">
<button ref="boutonNon" type="button" class="btn btn-secondary" @click="reponse(false)">Non, revenir</button>
<button type="button" class="btn btn-danger" @click="reponse(true)">Oui, confirmer</button>
</div>
</div>
</div>
</div>
</template>
</Teleport>
</template>
|
- ligne 42 : le HTML de la fenêtre est inséré à la fin de [<body>], hors de l'arborescence des composants : une fenêtre modale doit passer au-dessus de tout, quel que soit l'endroit où on la déclare ;
- ligne 45 : les classes [Bootstrap] d'une fenêtre modale, affichées « en dur » ([show d-block]) : pas besoin du [JavaScript] de [Bootstrap] ;
- ligne 45 : le modificateur [.self] : seul un clic sur l'élément lui-même (le fond grisé), et non sur la fenêtre qu'il contient, déclenche la réponse « non » ;
- lignes 29-37 : à l'ouverture, on écoute la touche Échap et l'on donne le focus au bouton « Non » (le choix le moins dangereux : un appui distrait sur Entrée ne supprime rien) ; à la fermeture, on cesse d'écouter le clavier.
Le composant qui l'utilise :
| <!--
=========================================================================
12-confirmation/src/App.vue - utiliser la fenêtre de confirmation
-------------------------------------------------------------------------
Même page que l'exemple 21 du cours NestJS (liens, boutons, confirmation),
mais sans aucun aller-retour avec un serveur.
=========================================================================
-->
<script setup lang="ts">
import { ref } from 'vue';
import FenetreConfirmation from './components/FenetreConfirmation.vue';
import { useConfirmation } from './composables/useConfirmation';
interface Contact {
id: number;
nom: string;
}
const contacts = ref<Contact[]>([
{ id: 1, nom: 'Jean MARTIN' },
{ id: 2, nom: 'Alice DURAND' },
{ id: 3, nom: 'Luc GERMAN' },
]);
const message = ref('');
const { confirmer } = useConfirmation();
// une fonction async : elle "attend" la réponse de l'utilisateur
async function supprimer(c: Contact): Promise<void> {
if (!(await confirmer(`Supprimer le contact ${c.nom} ?`))) {
message.value = 'Suppression abandonnée.';
return;
}
contacts.value = contacts.value.filter((x) => x.id !== c.id);
message.value = `${c.nom} a été supprimé.`;
}
async function toutSupprimer(): Promise<void> {
if (await confirmer(`Supprimer les ${contacts.value.length} contacts ?`)) {
contacts.value = [];
message.value = 'Tous les contacts ont été supprimés.';
}
}
</script>
<template>
<div class="container py-4" style="max-width: 640px">
<h1 class="h3 mb-3">Exemple 12 - une fenêtre de confirmation</h1>
<ul class="list-group mb-3">
<li v-for="c in contacts" :key="c.id" class="list-group-item d-flex justify-content-between align-items-center">
{{ c.nom }}
<button class="btn btn-sm btn-outline-danger" @click="supprimer(c)">Supprimer</button>
</li>
<li v-if="contacts.length === 0" class="list-group-item text-muted">Aucun contact.</li>
</ul>
<button class="btn btn-danger" :disabled="contacts.length === 0" @click="toutSupprimer">Tout supprimer</button>
<p v-if="message" class="alert alert-info mt-3">{{ message }}</p>
</div>
<!-- la fenêtre, UNE fois pour toute l'application -->
<FenetreConfirmation />
</template>
|
- ligne 29 : grâce à la promesse, le code s'écrit aussi simplement qu'avec [window.confirm] : la fonction [async] « attend » la réponse de l'utilisateur ;
- ligne 59 : la fenêtre, placée UNE fois pour toute l'application.

Après « Oui, confirmer » :
