Apprendre Vue 3 : Composants

Une application Vue.js est découpée en une multitude de blocs réutilisables et autonomes nommés composants.

Icône de calendrier
Intermédiaire
9 chapitres

Qu’est-ce qu’un composant ?

Diviser son UI

Un composant représente l’unité fondamentale de construction d’interface utilisateur (UI) du framework réactif Vue.js.

Les composants Vue.js sont des blocs de code réutilisables qui encapsulent la structure, la logique et l’apparence d’une partie de l’interface utilisateur d’une application Vue.js.

Les composants Vue.js sont ainsi généralement composés :

  • D’un template HTML pour la structure
  • D’un script JavaScript pour la logique
  • D’un style CSS pour l’apparence

Un composant peut être considéré comme une « boîte noire » qui :

  • Prend des données en entrée (appelées « props » ou propriétés)
  • Produit un template réactif en sortie

Ils peuvent être imbriqués et réutilisés dans d’autres composants, ce qui permet de créer des interfaces utilisateur complexes et modulaires. Une application réactive de grande ampleur est généralement constituée de multiples composants imbriqués les uns dans les autres, formant un arbre, à la manière du DOM.

Leur comportement est autonome, ce qui signifie qu’ils ne dépendent pas de l’état global de l’application.

Phase de build

Jusqu’à présent, nous avons pris en main Vue.js en chargeant le framework via un lien CDN. Si cela s’est avéré très pratique car aucune configuration n’a été requise, cela nous limite grandement en termes de fonctionnalités et de capacités.

Il conviendra alors de développer notre application de manière plus professionnelle via l’exploitation d’un serveur Node.js et de l’outil Vite.

Cet environnement nous permettra de construire notre application de manière plus efficace et de l’optimiser pour la production en bénéficiant d’une phase de build côté serveur.

Single File Components (SFC)

Lorsque nous utilisons Vue.js avec une phase de build (avec Vite et non via un lien CDN), on déclare nos composants dans des templates portant l’extension .vue, on parle de composants monofichiers ou Single File Components (SFC).

Les SFC contiennent à la fois :

  • Du HTML au sein de balises <template>
  • Du JavaScript au sein de balises <script>
  • Du CSS au sein de balises <style>
MyComponent.vue
copié !
<template></template>
<script></script>
<style></style>

Afin de les différencier de templates et composants HTML classiques, on déclare systématiquement nos fichiers de composants en PascalCase.

Par exemple : MainNav.vue, DropZone.vue

Ces fichiers ne peuvent pas être utilisés directement dans une application web, car les navigateurs ne comprennent pas leur syntaxe. Ils sont compilés sur le serveur par un outil tel que Vite qui les transformera en fichiers JavaScript simples afin d’être exécutés dans le navigateur.

Options API VS Composition API

Depuis Vue 3, deux approches/syntaxes s’offrent à nous afin d’écrire nos SFC :

  • Options API : consiste à définir les options des composants dans un objet.
  • Composition API : consiste à organiser la logique des composants en utilisant des fonctions plutôt qu’un objet.

Vous retrouverez plus d’informations sur la documentation officielle de Vue.js ainsi que sur une vidéo de VueSchool.

Le choix entre ces deux approches dépend bien entendu de vos préférences personnelles et de l’architecture de votre application.

Voici un tableau récapitulatif de ce qu’il faut retenir entre Options API VS Composition API.

APICaractéristiques
Options API- Historique
- Pédagogique
- Convient aux petits / moyens projets
Composition API- Moderne
- Plus structuré
- Convient aux gros projets

Créer un composant

Créons un composant nommé MyCounter.vue dont le rôle est d’incrémenter un compteur lorsqu’on clique sur un bouton.

MyCounter.vue
copié !
<template>
	<button @click="count++">You clicked me {{ count }} times.</button>
</template>

<script>
	export default {
		data() {
			return {
				count: 0
			}
		}
	}
</script>

<style scoped>
	button {
		display: inline-block;
		background-color: #FF0000;
		color: #fff;
		padding: .75rem 1rem;
		border-radius: 6px;
	}
</style>

Template

La structure d’un composant se déclare avec la balise <template>.

Le template regroupe les balises HTML du composant, couplées à d’éventuelles directives Vue.js.

Notez que le balise <template> ne doit avoir qu’un seul enfant.

<template>
	<button @click="count++">You clicked me {{ count }} times.</button>
</template>

Script

La logique JS d’un composant se déclare avec la balise <script>.

Les scripts regroupent l’état et la logique applicative du composant.

export default permet d’exporter l’objet contenant les options de notre composant.

<script>
	export default {
		data() {
			return {
				count: 0
			}
		}
	}
</script>

Style

Le style CSS d’un composant se déclare avec la balise <style>.

Le style regroupe la mise en forme du composant.

L’attribut scoped permet de limiter la portée du style aux balises HTML internes à ce composant.

<style scoped>
	button {
		display: inline-block;
		background-color: #FF0000;
		color: #fff;
		padding: .75rem 1rem;
		border-radius: 6px;
	}
</style>
Composant racine App.vue

📄 App.vue, situé à la racine du dossier 📂 src est le composant racine de notre application.

Il s’agit du composant de premier niveau, qui va agir comme conteneur pour tous les autres composants de l’application. Il est ensuite monté sur la div #app de l’application par l’intermédiaire du fichier 📄 main.js.

main.js
copié !
import { createApp } from 'vue'
import App from './App.vue'

// ...

createApp(App).mount('#app')

Enregistrer un composant

Pour utiliser un composant, il faut d’abord l’enregistrer / l’importer. Cet import peut être effectué de manière globale ou locale.

Enregistrement global

Enregistrer un module globalement permet d’en bénéficier depuis n’importe quel composant de notre application.

Cela s’avère très utile pour charger des composants réutilisables dans de nombreux endroits de l’application comme par exemple des composants UI : alert, button, accordion

Pour cela, on importe le composant global directement au sein du fichier 📄 main.js. Pour l’import de composant, on utilise la PascalCase.

main.js
copié !
import MyCounter from './components/MyCounter.vue'

createApp(App)
	.component('MyCounter', MyCounter)
	.mount('#app')

Après la création de l’application et avant de la monter sur #app, il est possible de chaîner des méthodes component() afin d’y charger des composants globalement.

La méthode component() possède 2 paramètres :

  • Nom : il s’agit de nommer le composant afin de pouvoir y faire référence depuis les templates.
  • Implémentation : il s’agit d’implémenter la logique du composant au sein d’un objet JavaScript. Cette logique étant externalisée dans un SFC, on y spécifie le composant importé.

Enregistrement local

En revanche, créer des composants globaux n’est pas toujours idéal pour la simple et bonne raison qu’ils seront chargés par notre application même lorsqu’ils ne sont pas ou peu utilisés.

C’est là qu’interviennent les composants locaux, qui ne seront importés qu’au sein de composants parents qui les exploitent.

Pour enregistrer localement un composant :

  1. On l’importe au sein de la balise <script> qui s’apprête à l’utiliser, en utilisant la syntaxe des modules ES6, basée sur les mots-clés import et from.
  2. On expose les composants disponibles au sein d’une propriété components.
ParentComponent.vue
copié !
import MyCounter from './MyCounter.vue'

export default {
	components: {
		MyCounter
	}
}

Si vous structurez vos composants au seins de sous-dossiers dans le dossier 📂 components (📂 ui, 📂 admin…), alors je vous recommande d’utiliser le caractère @ faisant directement référence au dossier 📂 src. Cela vous évitera des chemins relatifs complexes depuis le composant parent faisant l’import.

copié !
import SwitchUi from '@/components/ui/SwitchUi.vue'

Ici, même si le composant important localement le composant SwitchUi.vue est situé dans un dossier 📂 components/admin/dashboard/UserCard.vue, j’évite un import qui ressemblerait à :

copié !
import SwitchUi from '../../ui/SwitchUi.vue' // 🙃 Un peu laborieux...

Enregistrement global VS local

Mais alors ? Quand importer un composant globalement ? Quand importer un composant localement ?

Voici un tableau récapitulatif des points clés à retenir pour faire le bon choix entre importation globale et locale.

GlobalLocal
PortéeLe composant est accessible dans toute l’application sans avoir besoin de l’importer dans chaque composant parent.Le composant n’est accessible que dans le composant parent dans lequel il a été importé.
Utilisation recommandéeUtilisation recommandée pour les grands composants réutilisables dans de nombreux endroits de l’application.Utilisation recommandée pour les petits composants qui ne seront pas réutilisés dans d’autres parties de l’application.
DéfinitionLes composants globaux doivent être définis avec la méthode component(), avant la création de l’instance Vue.js pour être accessibles dans tous les composants.Les composants locaux peuvent être définis de manière explicite dans la section “components” du composant parent ou de manière implicite en utilisant des fichiers .vue qui contiennent un seul composant.
AvantagePermet une meilleure réutilisabilité et maintenabilité de codePermet une meilleure encapsulation et modularité
PerformanceAugmente le poids du bundle final retourné au client (généré lors de la phase de build)Optimise le poids du bundle final retourné au client (généré lors de la phase de build)

Utiliser un composant

Appel

Le composant étant enregistré/importé, il est temps de l’exploiter depuis un autre composant. Pour cela, on pourra l’exploiter au sein de balises HTML spécifiques qui portent le nom du composant en PascalCase.

Cette balise peut s’écrire selon les formes suivantes :

copié !
<MyComponent/>
copié !
<MyComponent>Contenu...</MyComponent>

La seconde syntaxe, constituée d’une balise ouvrante et d’une balise fermante est à privilégier lorsque le composant est voué à accueillir un contenu (nous aborderons ceci lors de l’introduction aux slots).

Notez que Vue.js prend en charge la résolution des balises kebab-case en composants enregistrés à l’aide de PascalCase. Cela signifie qu’un composant enregistré en tant que MyComponent peut être référencé dans le modèle via <MyComponent> et <my-component>. Cela peut être intéressant dans des cas spécifiques.

copié !
<my-component/>
<my-component>Contenu...</my-component>

Manipuler un composant va faire entrer en jeu de nouveaux concepts comme les props ou encore les slots.

Indépendance des composants

Chaque composant possède son propre état. Lorsque vous utilisez un composant plusieurs fois dans votre application, chaque instance de ce composant possédera sa propre copie de l’état défini dans sa propriété data.

copié !
<MyCounter/>
<MyCounter/>
<MyCounter/>

Cela signifie que si vous modifiez l’état d’une instance de composant, cela n’affectera pas les autres instances de ce même composant.

Cela permet à chaque instance de se comporter de manière indépendante et d’afficher des données spécifiques à son contexte.

Props

Principe

S’il n’était pas possible de passer des informations sur-mesure à un composant, alors leur utilisation serait limitée…

Imaginons avoir par exemple un composant SuperAlert dont le rôle est d’afficher un message d’alerte sur une page.

SuperAlert.vue
copié !
<template>
	<div class="super-alert">
		Mot de passe erroné
	</div>
</template>

<style scoped>
	.super-alert {
		padding: .75rem 1rem;
		background-color: red;
		border-radius: 5px;
	}
</style>
copié !
<script>
	import SuperAlert from './SuperAlert.vue'

	export default {
		components: {
			SuperAlert
		},
	}
</script>

<template>
	<SuperAlert>
</template>

Ici, le message de l’alerte est écrit en dur, il restera ainsi toujours Mot de passe erroné… De même pour la couleur de fond qui sera toujours red. L’exploitation et la réutilisation de ce composant est très discutable !

Pour pallier à cela, nous allons pouvoir transmettre des données au composant lors de son appel via des props.

Les props sont des attributs spécifiques aux composants qui permettent de transférer des données au composant.

Envoi de props (parent)

Pour envoyer des données statiques (chaîne de caractères), il suffit de déclarer votre prop comme un attribut classique et de lui associer la valeur à transmettre au composant.

ParentComponent.vue
copié !
<template>
	<SuperAlert
		message="Vous avez un nouveau message."
		bg-color="green"
		text-color="#fff"
	/>
	<SuperAlert
		message="Mauvais mot de passe."
		bg-color="red"
		text-color="#fff"
	/>
</template>

Pour envoyer des données dynamiques (expression JavaScript), il faudra alors déclarer votre attribut en le « bindant » avec v-bind ou :.

ParentComponent.vue
copié !
<template>
	<SuperAlert
		:message="alert.message"
		:bg-color="alert.bgColor"
		:text-color="alert.textColor"
	/>
</template>

<script>
	import SuperAlert from './SuperAlert.vue'

	export default {
		data() {
			return {
				alert: {
					message: "Test",
					bgColor: "green",
					textColor: "#fff"
				}
			}
		},
		components: {
			SuperAlert
		},
	}
</script>

Réception des props (enfant)

Pour réceptionner les données passées en props, il suffit de déclarer dans les options du composant la nouvelle propriété props. Cette propriété peut simplement contenir dans un tableau de chaînes de caractères les props passées.

SuperAlert.vue
copié !
<script>
	export default {
		props: ['message', 'bgColor', 'textColor'],
	}
</script>

<template>
	<div class="super-alert" :style="{ backgroundColor: bgColor, color: textColor }">
		{{ message }}
	</div>
</template>

<style scoped>
	.super-alert {
		padding: .75rem 1rem;
		border-radius: 5px;
	}
</style>

En revanche, il est préférable d’être plus rigoureux en détaillant pour chaque prop le type de valeur attendu. On parle de validation.

SuperAlert.vue
copié !
<script>
	export default {
		props: {
			message: String,
			bgColor: String,
			textColor: String
		},
	}
</script>

<template>
	<div class="super-alert" :style="{ backgroundColor: bgColor, color: textColor }">
		{{ message }}
	</div>
</template>

<style scoped>
	.super-alert {
		padding: .75rem 1rem;
		border-radius: 5px;
	}
</style>
Aller plus loin avec les validations

Check de type :

copié !
	export default {
	props: {
		//  (`null` and `undefined` values will allow any type)
		propA: String,
		// Multiple possible types
		propB: [String, Number],
	}
}

Prop requise :

copié !
export default {
	props: {
		// Required string
		propC: {
			type: String,
			required: true
		},
	}
}

Valeur par défaut :

copié !
export default {
	props: {
		// Number with a default value
		propD: {
			type: Number,
			default: 100
		},
		// Object with a default value
		propE: {
			type: Object,
			// Object or array defaults must be returned from
			// a factory function. The function receives the raw
			// props received by the component as the argument.
			default(rawProps) {
				return { message: 'hello' }
			}
		}
	}
}

Validateur personnalisé :

copié !
export default {
	props: {
		// Custom validator function
		propF: {
			validator(type) {
				// The type must match one of these strings
				return ['success', 'warning', 'danger'].includes(type)
			}
		}
	}
}

Quelques remarques :

  • Toutes les props sont facultatives par défaut, sauf si required: true est spécifié.
  • Une prop facultative aura une valeur undefined (hormis Boolean qui vaudra false).

Evènements (emits)

Principe

Parfois, il peut être nécessaire pour un composant enfant de communiquer avec son composant parent : cette mécanique est rendue possible par ce que l’on appelle les « emits ».

Cette communication se traduit la plupart du temps par le besoin de transmettre des données à un composant parent.

Concrètement :

  1. Un composant enfant émet un évènement
  2. Le composant parent est notifié lors de l’émission d’un évènement

Emettre un évènement (enfant)

Pour émettre un évènement depuis le composant enfant on utilise la méthode $emit(), dans laquelle on renseigne le nom d’un évènement sur mesure que l’on souhaite écouter.

Cela peut se faire directement depuis le template :

ChildComponent.vue
copié !
<template>
	<button @click="$emit('myCustomEvent')">Cliquez moi</button>
</template>

Cela peut aussi se faire depuis un script avec this :

ChildComponent.vue
copié !
<template>
	<button @click="demo()">Cliquez moi</button>
</template>

<script>
export default {
  methods: {
    demo() {
      this.$emit('myCustomEvent')
    }
  }
}
</script>

À chaque click sur le bouton, l’évènement myCustomEvent sera émis au composant parent. Le composant parent sera notifié que l’évènement a été déclenché dans le composant enfant et pourra réagir en fonction.

Dans le cas où vous souhaiteriez également transmettre au composant parent des données, il sera possible de les passer via des arguments additionnels à la méthode $emit().

copié !
$emit('myCustomEvent', data1, data2, ...)

Ecouter l’évènement émis (parent)

Du côté du composant parent, on écoute l’évènement émis par le composant enfant et on exécute la logique voulue lorsqu’il est déclenché.

ParentComponent.vue
copié !
<template>
	<ChildComponent @my-custom-event="..."/>
</template>

<script>
import ChildComponent from '@/components/ChildComponent.vue'

export default {
	components: {
		ChildComponent
	},
	data() {
		return {
			clicks: 0
		}
	}
}
</script>

Si vous souhaitez récupérer des données transmises par l’enfant avec par exemple $emit('myCustomEvent', 1), vous avez le choix entre :

1. Une fonction fléchée au sein du template
ParentComponent.vue
copié !
<ChildComponent @my-custom-event="(value) => clicks += value" />
2. Une méthode au sein des scripts
ParentComponent.vue
copié !
<ChildComponent @my-custom-event="maMethode" />

Ensuite, la valeur sera automatiquement passée en tant que paramètre de cette méthode :

ParentComponent.vue
copié !
methods: {
  maMethode(value) {
    this.clicks += value
  }
}

Slots

Principe

Si nous devions faire passer l’intégralité des informations à notre composant, comme par exemple son contenu, via des props, cela s’avèrerait parfois fastidieux et/ou limitant. Reprenons l’exemple de notre composant d’alerte :

ParentComponent.vue
copié !
<SuperAlert
	message="Vous avez un nouveau message."
	bg-color="green"
	text-color="#fff"
/>

Les slots consistent à délimiter des zones avec les balises <slot></slot> dans un composant.

ParentComponent.vue
copié !
<template>
	<div class="super-alert" :style="{ backgroundColor: bgColor, color: textColor }">
		<slot></slot>
	</div>
</template>

Désormais, lors de l’usage de ce composant, il est possible de passer du contenu (textuel ou HTML) au slot, en l’insérant au sein des balises paires.

ParentComponent.vue
copié !
<SuperAlert
	bg-color="green"
	text-color="#fff"
>
	<i class="fas fa-exclamation-circle"></i>
	Vous avez un nouveau message.
</SuperAlert>

Cela nous permet ainsi de nous passer de la prop message et d’insérer du contenu dynamiquement au sein du composant. C’est finalement la manière naturelle de transmettre du contenu à une balise HTML, et ça, c’est plus propre !

Contenu par défaut

Il est également possible de spécifier dans le composant un contenu par défaut qui s’affichera si aucun contenu n’est spécifié à l’utilisation d’un composant :

ParentComponent.vue
copié !
<SuperAlert
	bg-color="green"
	text-color="#fff"
>
</SuperAlert>
SuperAlert.vue
copié !
<template>
	<div class="super-alert" :style="{ backgroundColor: bgColor, color: textColor }">
		<slot>Contenu par défaut</slot>
	</div>
</template>

Slots nommés

Il est tout à fait possible de déclarer plusieurs slot au sein d’un même composant. Prenons l’exemple d’un composant de type accordion utilisé dans le cadre d’une FAQ. On donne à l’utilisateur du composant la possibilité de spécifier un titre ainsi qu’une description de FAQ en faisant usage de 2 slots distincts.

Chaque slot devra alors être identifié. Cette identification se fera via l’usage d’un attribut name.

SuperAccordion.vue
copié !
<template>
	<div class="super-accordion">
		<header class="super-accordion-header" @click="opened=!opened">
			<h3><slot name="title"></slot></h3>
		</header>
		<div class="super-accordion-body" v-if="opened">
			<p><slot name="description"></slot></p>
		</div>
	</div>
</template>

<script>
	export default {
		data() {
			return {
				opened: false
			}
		}	
	}
</script>

Ainsi, côté composant parent, nous avons la possibilité de préciser quel contenu doit s’insérer dans quelle zone en combinant la balise <template> avec la directive v-slot.

ParentComponent.vue
copié !
<template>
	<SuperAccordion>
		<template v-slot:title>Peut-on retourner un article ?</template>
		<template v-slot:description>Vous avez 30 jours à compter de la réception de votre commande pour... blablabla.</template>
	</SuperAccordion>
</template>

La directive v-slot: est généralement raccourcie par le caractère #, donc <template v-slot:title> peut être simplifié par juste <template #title>.

Lorsqu’un composant accepte un slot par défaut et des slots nommés, tous les nœuds HTML n’étant pas situés au sein d’une balise <template> seront considérés comme destinés au slot par défaut.

SuperAccordion.vue
copié !
<template>
	<div class="super-accordion">
		<header class="super-accordion-header" @click="opened=!opened">
			<h3><slot name="title"></slot></h3>
		</header>
		<div class="super-accordion-body" v-if="opened">
			<p><slot></slot></p>
		</div>
	</div>
</template>

<script>
export default {
	data() {
		return {
			opened: false
		}
	}	
}
</script>

<style scoped>
	.super-accordion {
		border: solid #000 1px;
	}
	.super-accordion-header {
		background-color: #eee;
	}
	.super-accordion-header, .super-accordion-body {
		padding: .5rem 1rem;
	}
</style>
ParentComponent.vue
copié !
<template>
	<SuperAccordion>
		<template #title>Peut-on retourner un article ?</template>
		Vous avez 30 jours à compter de la réception... <!-- Va dans le second slot, non nommé -->
	</SuperAccordion>
</template>

Préprocesseurs, supersets et moteurs de templates

Qu’il s’agisse de HTML, CSS ou encore JS, les langages front web se voient souvent “dopés” par l’utilisation de technologies qui simplifient, dynamisent ou encore sécurisent leur écriture.

  • Pour HTML, on parle de moteurs de templates tel que Pug.
  • Pour CSS, on parle de préprocesseurs tels que Sass, Less ou encore Stylus.
  • Pour JavaScript, on parle de superset tel que TypeScript.

Pour activer le support de la technologie en question, il faut :

  1. Installer et configurer les préprocesseurs en question via le gestionnaire de paquet et l’outil de build de votre choix (Vite, Webpack…).
  2. Ajouter l’attribut lang au sein des balises <template>, <script> ou <style> avec pour valeur, le préprocesseur à utiliser.

Quelques exemples :

Support de Pug
copié !
<template lang="pug">
	...
</template>
Support de Sass
copié !
<style lang="scss">
	...
</style>
Support de TypeScript
copié !
<script lang="ts">
	...
</script>