L'archive des sources (répertoire src) pour les démonstrations est téléchargeable [ ici ]
1°/ Objectifs de Vuex
- Dans les cours précédents, on a vu que chaque composant possède son propre modèle de données (via la propriété data).
- Il est cependant possible d'utiliser des données "externes" :
- via l'instruction import,
- via les props et les slots.
- Or, il est fréquent de créer/détruire dynamiquement des composants, notamment avec vue-routeur (qui sera abordé en TD 7), ou bien d'autres mécanismes vuejs.
- Quand un composant est détruit, ses données propres sont perdues mais pas celles dont la source est externe, à moins bien entendu que cette source soit un composant qui soit lui-même détruit.
- Pour faire persister des données malgré la destruction des composants, il faut donc que ces données soient stockées dans un emplacement qui ne sera jamais détruit.
- Pour que ce principe soit pratique, il suffit que créer un "dépôt" central pour le modèle de données, auquel tous les composants de l'application ont accès, dès leur création.
- L'autre avantage d'un dépôt central est de faciliter le passage d'information entre composants.
- En effet, faire passer des données entre 2 composants A et B frères nécessite :
- que A déclenche des événements avec des valeurs représentant les données à communiquer,
- que le père de A capture ces événements et qu'il affecte leur valeur à certaines de ses variables.
- que le père de A relie ces variables à des props de B.
- Si la communication doit se faire entre cousins, c'est encore plus compliqué puisqu'il faut faire remonter les événements et descendre des props sur plusieurs niveaux.
- Un dépôt central évite totalement ce type de mécanisme puisqu'il suffit d'avoir une variable dans le dépôt contenant les données à communiquer.
- Vuex permet de créer un tel dépôt, appelé le "store"
- Sur le Web, il est souvent noté que vuex n'est pas adapté à de petites applications car son utilisation est "verbeuse", c'est-à-dire qu'il faut écrire beaucoup de lignes pour définir le dépôt central et les données qu'il contient.
- Ce n'est pas totalement vrai : il est verbeux car il permet de créer un dépôt en suivant de bonnes règles de programmation.
- Il est donc possible d'écrire en quelques lignes un objet contenant les données centralisées et d'importer cet objet dans tous les composants via import.
- Cependant, cela complique grandement le déboguage, notamment car on ne garde jamais trace des modifications qui sont faîtes directement sur les données.
- Sans parler du fait que dans un environnement asynchrone tel qu'un navigateur, que se passe-t-il quand une fonction lit des données alors qu'une autre les modifie ?
- Enfin, même s'il n'y a que quelques données à centraliser, le supplément de codage lié à Vuex n'est pas si grand.
- Le seul désavantage est parfois la performance.
ATTENTION ! Ce n'est pas parce que l'on utilise Vuex que l'on va mettre toutes les données dans le store. Les composants peuvent toujours définir des variables locales (dans data ou computed).
2°/ Principes basiques de Vuex
- Quand on crée un projet grâce à vue-cli, dans lequel on intègre vuex (via le menu de personnalisation de projet), un répertoire store est créé avec un fichier index.js dedans.
- Ce fichier permet de définir le dépôt grâce à la création d'un objet Vuex.Store.
- Ce fichier est ensuite importé dans main.js, afin que l'application ait accès au dépôt, que l'on appellera un store dans la suite.
- Pour une utilisation basique de Vuex, seules 2 propriétés du store doivent être définies : state et mutations.
- Le principe essentiel de Vuex est de :
- définir des variables dans state, qui représentent les informations que l'on veut rendre accessibles à tous les composants.
- définir des fonctions dans mutations qui vont modifier les valeurs de ces variables.
- Autrement dit, un composant peut accéder directement en lecture à une variable de state, mais ne doit JAMAIS la modifier directement. Cela doit toujours se faire grâce à une des fonctions de mutations.
- Il existe une imitation aux mutations : elles ne peuvent effectuer un traitement asynchrone, comme par exemple, aller chercher des données sur une API.
- C'est pourquoi Vuex propose les actions, qui elles, peuvent être asynchrones et qui utilisent les mutations pour mettre à jour le state.
- Enfin, Vuex propose la notion de getters, qui sont simplement des fonctions renvoyant une valeur calculée à partir des variables du state. C'est similaire aux fonctions computed.
3°/ Créer un store
3.1°/ state et mutations
- La propriété state est un objet json similaire à celui que l'on pourrait définir pour data dans un composant. Par exemple :
state: () => ({
count : 0
})
- Il existe une deuxième notation, qui n'est pas utilisable lorsque l'on modularise le store (cf. section 3.4), en définissant state directement comme un objet :
state: {
count: 0
}
- La propriété mutations est un objet contenant des méthodes qui prennent forcément en paramètre l'objet state. C'est pourquoi on nomme généralement ce paramètre state.
- Ces méthodes peuvent également prendre un second paramètre (optionnel) qui est utilisable pour paramétrer le traitement de la méthode.
- Par exemple, si on veut une méthode permettant d'incrémenter la variable count de state d'un certain nombre, on écrit :
mutations: {
increment(state, amount) {
state.count += amount
}
}
ATTENTION ! les méthodes de mutations doivent être synchrones. Cela signifie que leur code ne doit pas contenir des appels à des méthodes dont le moteur JS va attendre le résultat dans le futur, sans savoir quand. Par exemple, il est interdit de faire un appel à axios pour récupérer des données auprès d'une API REST.
3.2°/ Les getters
- Prenons un composant qui définit une variable computed calculée à partir de plusieurs variable de state.
- Il existe peut être d'autres composants qui doivent utiliser exactement la même variable computed.
- Au lieu de faire du copier/coller de code dans chacun de ces composants, on peut définir dans le store un getter.
- Un getter est tout simplement l'équivalent d'une variable computed mais pour le store.
- Bien entendu, une fois défini, un getter est accessible par tous les composants.
- Pour définir des getters, il suffit de définir la propriété getters dans le store.
- Cette propriété contient une liste de méthodes qui retournent une valeur calculée à partir des variables de state, voire d'autres getters.
- Ces méthodes prennent en paramètre l'objet state et optionnellement l'objet getters (pour pouvoir appeler d'autres getters).
Exemple :
- Dans store/index.js
{
state: () => ({ count:0 }),
getters: {
doubleCount(state) { return state.count*2 },
fifthCount : (state, getters) => (state.count+getters.doubleCount*2)
}
}
- Le fait que les mutations fassent des traitement synchrones peut poser problème, notamment lorsque l'on doit récupérer des données de façon asynchrone (par ex, via axios).
- Dans ce cas, il faut définir une méthode dans la propriété actions du store.
- Cette méthode prend en paramètre un objet qui est une sorte de copie du store, que l'on appelle un contexte dans le jargon Vuex. C'est pourquoi on nomme généralement ce paramètre context.
- Cet objet permet d'accéder aux mutations, et à state (et si besoin getters), et ainsi faire le traitement asynchrone nécessaire pour mettre à jour state.
- Comme pour les mutations, une action peut prendre un deuxième paramètre.
- Quand une action veut utiliser une mutation, on utilise la fonction commit() de context, avec en paramètre le nom de la mutation et éventuellement un paramètre.
ATTENTION : une action ne modifie JAMAIS state directement (même si c'est techniquement possible). Pour cela elle utilise les mutations existantes.
Exemple :
- Dans store/index.js :
state: () => ({
count:0
}),
mutations: {
increment(state, amount) {
state.count+= amount
}
},
actions: {
incrementAsync(context, amount) {
setTimeout(() => {
context.commit('increment',amount)
}, 1000)
}
}
4°/ accès et manipulation du store
4.1°/ $store
- Quand on crée le projet avec vue-cli, le store est déjà intégré à l'instance de Vue.
- Il n'y a donc rien à faire de particulier pour qu'un composant accède au store.
- Grâce à cette intégration, chaque composant a accès dans sa partie <template> à une variable $store (et this.$store dans la partie <script>), qui permet notamment d'accéder :
- au state, via $store.state.nom_variable,
- aux getters, via $store.getters.nom_getter,
- aux mutation, via $store.commit('nom_mutation', paramètre),
- aux actions, via $store.dispatch('nom_action', paramètre)
- MAIS, en pratique, on n'utilise jamais directement $store dans un <template>.
- La bonne règle consiste à créer dans la partie <script> :
- une variable calculée qui accède à une variable du state ou un getter
- une méthode qui appelle une mutation ou une action.
- Dans <template>, on manipule les variables calculées et/ou méthodes ainsi créées.
Exemple :
<template>
<div>
{{ count }}
</div>
</template>
<script>
export default {
computed: {
count() { return this.$store.state.count }
},
methods: {
updateCount(val) { this.$store.commit('updateCount', val) }
}
}
</script>
Remarque : dans cet exemple, on utilise le même nom pour la variable calculée et celle du store. Ce n'est pas une obligation. De même pour la méthode et la mutation.
4.2°/ Les mappers.
- Selon le principe énoncé si dessus, on doit écrire autant de variables calculées et/ou méthodes qu'un composant doit récupérer de choses dans le store. Cela peut rapidement être fastidieux.
- Pour éviter cela, Vuex fournit des "Helpers", qui vont créer de manière simple les variables/méthodes dont on a besoin.
- Ces helpers sont ni plus ni moins que des fonctions qui vont créer les fonctions voulues à partir de noms de variables state, getters, mutations et actions, et retourner un tableau contenant les fonctions. C'est pourquoi ces helpers sont appelés des mappers (= associeurs)
- Il existe 4 helpers : un pour les accès à state mapState(), un pour les getters mapGetters(), un pour les mutations mapMutations(), et un pour les actions mapActions().
- Il existe plusieurs syntaxes pour passer des paramètres à ces helpers. La plus simple consiste à leur donner un tableau contenant les noms des state, getters, action pour lesquels on veut créer des fonctions.
- NB : ces helpers ne sont pas par défaut accessibles. Il faut donc les importer.
- Les helpers mapState et mapGetters servent à ajouter des fonctions dans la propriété computed, alors que mapMutations et mapActions ajoutent des fonctions dans la propriété methods.
- De plus, comme ces helpers renvoient un tableau de fonctions, on ne peut pas directement inclure ce tableau dans computed ou methods.
- En revanche, on peut utiliser l'opérateur ... qui va "découper" le contenu du tableau en objets distincts qui vont être ajoutés à computed ou methods.
Exemple :
- Dans store/index.js :
state: () => ({
count:0,
tab: []
}),
mutations: {
increment(state, amount) {
state.count+= amount
},
store(state, value) {
state.tab.push(value)
}
},
getters: {
doubleCount(state) { return state.count*2},
fifthCount: (state,getters) => (state.count + getters.doubleCount*2),
getItem: (state) => (id) => (state.tab[id]) //renvoie une fonction qui elle-même renvoie tab[id]
},
actions: {
incrementAsync(context, amount) {
setTimeout(() => {
context.commit('increment',amount)
}, 2000)
}
}
- Dans un composant :
<template>
<div class="about">
<h1>This is ComponentA</h1>
{{ count }} {{ doubleCount }} {{ fifthCount }}
<button @click="increment(2)">Inc by 2</button>
<button @click="incrementAsync(3)">Inc async by 3</button>
<hr />
<button @click="store(count)">Store current value</button>
tab = {{ tab }}, tab value in <input v-model="index"> : {{ getItem(index) }}
<hr />
index: {{ index }}, modified: {{ plusFive }}
</div>
</template>
<script>
import {mapState, mapGetters, mapMutations, mapActions} from 'vuex'
export default {
name: 'ComponentA',
data: () => {
return {
index: 0
}
},
computed: {
plusFive() { return this.index+5 },
...mapState([ 'count', 'tab']),
...mapGetters(['doubleCount','fifthCount','getItem'])
},
methods: {
...mapMutations(['increment','store']),
...mapActions(['incrementAsync'])
}
}
</script>
Remarques :
- comme dit en introduction, cet exemple illustre le fait que l'on peut toujours définir des variables locales (dans data ou computed) au composant, cf. index et plusFive.
- les helpers permettent de réduire notablement le volume de code à écrire.
- les variables computed et fonctions obtenues par mappage ont bien le même nom que les éléments définis dans le store. Par exemple, le mappage de la mutation increment crée une fonction increment() dont le paramètre est la valeur que l'on fournissait avant comme deuxième paramètre de commit().
- Idem pour les autres mutations/actions.
Démonstration 1 :
- Dans le répertoire store, copier index.1.js dans index.js
- Dans le répertoire components, copier TD5Demo1.vue dans TD5Demo.vue.
- Les codes ainsi copiés correspondent à l'exemple donné ci-dessus.
- lancer npm run serve, puis ouvrir localhost:8080 dans le navigateur.
- Cliquer sur les différents boutons pour constater que tout fonctionne comme attendu, malgré l'utilisation des helpers.
5°/ Les modules
- Quand une application devient suffisamment grosse, le fichier du store peut lui-même devenir trop long et difficilement lisible.
- Vuex permet de créer des modules, qui sont en fait des sous-stores, que l'on peut agréger pour former le store dit racine.
- Pour cela, il suffit de créer des fichiers js qui exportent un objet contenant les propriétés state, mutations et si besoin getters, actions, voire même modules.
- Dans le fichier store/index.js, on importe ces objets et on les utilise dans la propriété modules.
- Excepté state, tout est fusionné dans le store racine. Cela implique que si on a deux mutations qui portent le même nom dans deux modules différents, il y a problème.
- Pour éviter cela, on peut éviter cette fusion en indiquant que chaque module conserve son propre espace de nommage.
- Pour cela, il suffit d'ajouter au module la propriété namespaced : true.
- En contrepartie, lorsque l'on utilise les helpers, il faut indiquer dans quel module on veut chercher des éléments à mapper.
- Sous certaines conditions, un module A peut accéder à un autre module B.
- Les getters de A peuvent recevoir 2 paramètres supplémentaires (par rapport aux getters sans modules), que l'on nomme généralement rootState et rootGetters. Comme leur nom l'indique, ils donnent accès à toute l'arborescence de state et des getters définie par les modules. A a donc accès au state et aux getters de B.
- Ces 2 paramètres sont également accessibles dans la variable context qui est passée aux actions de A.
- Il est également possible d'utiliser directement une action ou un mutateur de B dans les actions de A (et uniquement dans les actions)
- Pour cela, il suffit de :
- mettre un nom d'action/mutation avec le "chemin" complet, par exemple "B/myMutation"
- mettre un 3ème paramètres à l'action/mutation : {root : true}
Par exemple :
- Dans module1.js
export default {
namespaced : true,
state: () => ({
count:0
}),
mutations: {
increment(state, amount) {
state.count+= amount
}
}
}
- Dans module2.js :
export default {
namespaced : true,
state: () => ({
tab: []
}),
mutations: {
store(state, value) {
state.tab.push(value)
}
},
getters: {
getItem: (state) => (id) => (state.tab[id])
},
actions: {
storeAndIncrement({commit}, value) {
commit('store',value)
commit('module1/increment',value,{root: true})
}
}
}
- Dans index.js :
...
import module1 from './module1.js'
import module2 from './module2.js'
export default new Vuex.Store({
modules: {
module1,
module2
}
})
- Dans un composant :
<script>
import {mapState, mapGetters, mapMutations, mapActions} from 'vuex'
export default {
...
computed: {
...mapState('module1',['count']),
...mapState('module2',['tab']),
...mapGetters('module2',['getItem'])
},
methods: {
...mapMutations('module1',['increment']),
...mapMutations('module2',['store']),
...mapActions('modules2',['storeAndIncrement'])
}
}
</script>
Démonstration 2:
- Dans le répertoire store, copier index.2.js dans index.js
- Dans le répertoire components, copier TD5Demo2.vue dans TD5Demo.vue.
- Les codes ainsi copiés correspondent en gros à l'exemple donné ci-dessus.
- lancer npm run serve, puis ouvrir localhost:8080 dans le navigateur.
- C'est le même résultat que la démonstration 1, mais en ayant modularisé le store.