Open menu

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, ou bien d'autres mécanisme vuejs.
  • Quand un composant est détruit, ses données propres sont perdus 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épot" 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épot 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 niveau.
  • 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.
  • 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.

2.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.
  • On peut donc définir cet objet "en dur", comme ci-dessous
state: {
    count: 0
}
  •  ou bien en utilisant une fonction qui retourne l'objet :
state: () => ({
    count : 0
})
  •  La deuxième notation n'est réellement utile que lorsque l'on modularise le store (cf. section 3.4) mais c'est une pratique couramment utilisée, donc à connaître.

 

  • 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.

 

2.2°/ accès et manipulation du 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.
  • Cela se fait grâce à la variable $store (quand on est dans le template), ou bien this.$store quand on est dans le script.

2.2.1°/ accès à state en lecture

  • Dans un composant, il est possible d'accéder à state directement grâce à $store.state.
  • Cependant, le guide des bonnes pratiques conseille plutôt d'utiliser des variables de type computed, qui vont accéder à state pour renvoyer la valeur d'une de ses variables.
  • Exemple :
<template>
  <div>
      {{ $store.state.count }}
      {{ mycount }}
  </div>
</template>
<script>
  export default {
    computed: {
      mycount() { return this.$store.state.count }
    }
  }
</script>
  •  Les deux lignes du template vont afficher la même valeur, à savoir celle de la variable count de state.
  • La différence est que la première ligne consiste en un accès direct à state, alors que la ligne suivante affiche une variable computed, dont la valeur est tirée du state.count.
  • L'avantage de la deuxième solution est qu'elle permet de créer une variable computed dont la valeur est calculée à partir de plusieurs variables de state.
  • De plus, quand on a besoin d'accéder à plein de variables de state, Vuex fournit des facilités (helpers) pour créer les variables computed correspondantes.
  • Il y a donc tout avantage à utiliser des variables computed.

2.2.2°/ accès en modification

  • Comme dit plus haut, modifier les valeurs de state se fait en appelant les méthodes de mutations.
  • Pour cela, on utilise la méthode commit() avec comme paramètre le nom de la méthode à appeler. Si cette méthode a 2 paramètres, alors il faut le donner également à commit().

Exemple :

<template>
  <div>
      value: {{ mycount }}
      <button @click="$store.commit('increment', 2)"> Inc by 2 </button>
  </div>
</template>
<script>
  export default {
    computed: {
      mycount() { return this.$store.state.count }
    }
  }
</script>

 

Démonstration 1 :

  • Dans le répertoire store, copier index.js.1 dans index.js
  • Dans le répertoire views, copier ComponentA.vue.1 dans ComponentA.vue.
  • lancer npm run serve, puis ouvrir localhost:8080 dans le navigateur.
  • Si on clique sur le lien A, on suit une route (de vue-router) qui permet d'afficher ComponentA. La valeur affichée est 0.
  • Si on clique sur le bouton "Inc by 2", la valeur est bien incrémentée de 2.
  • Cliquer sur le lien B pour suivre une autre route, puis de nouveau sur le lien A. Miracle : la valeur précédente du compteur est conservée, malgré le fait qu'entre temps, ComponentA a été détruit.
  • Normal, car lors de la nouvelle création de ComponentA va chercher calculer la valeur de mycount à partir du store, qui lui est persistant.

 

3°/ Principes avancés.

3.1°/ 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).
  • Quand un composant veut utiliser la valeur retournée par un getter, il suffit d'utiliser $store.getters.nom_getter, ou de créer une variable computed qui récupère cette valeur.

Exemple :

  • Dans store/index.js
{
  state: () => ({	count:0 }),
  getters: {
    doubleCount(state) { return state.count*2 },
    fifthCount : (state, getters) => (state.count+getters.doubleCount*2)
  }
}
  •  Dans un composant :
<template>
  <div> {{ count }} {{ doubleCount }} {{ fifthCount }} </div>
</template>
<script>
export default {
  computed: {
     count() { return this.$store.state.count },
     doubleCount() { return this.$store.getters.doubleCount },
     fifthCount() { return this.$store.getters.fifthCount },
  }
}
</script>

 

Remarques :

  • le getter doubleCount est défini en utilisant la syntaxe basique pour créer une fonction,
  • le getter fifthCount définit la fonction en utilisant un des raccourcis possible de la notation fléchée. Dans le cas présent, la fonction fait juste un return d'une valeur. On met à gauche de la flèche les paramètres de la fonction et à droite, directement la valeur à retourner. On aurait pu aussi mettre { return state.count+getters.doubleCount*2 }.

Démonstration 2 :

  • Dans le répertoire store, copier index.js.2 dans index.js
  • Dans le répertoire views, copier ComponentA.vue.2 dans ComponentA.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.
  • Si on clique sur le lien A. Les valeurs affichées sont 0 0 0.
  •  Quand on clique sur le bouton, on voit que les valeurs changent conformément aux calculs fait dans les getters.

Remarques finales :

  • Il existe une autre façon de définir un getter : en retournant une fonction (au lieu de la définir).
  • Cela permet de faire un getter avec paramètre, cf. section 3.3 pour un exemple.

 

3.2°/ Les actions

  • 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 srote de copie du store, que l'on appelle un contexte dans le jargon Vuex. C'est pourqoi 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.

ATTENTION : une action ne modifie JAMAIS state directement (même si c'est techniquement possible). Pour cela elle utilise les mutations existantes.

  • Dans les composants, on utilise $store.dispatch('nom_action', param) (au lieu de $store.commit) pour lancer une action.

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)
     }
  }
  •  Dans un composant :
<template>
  <div >
    {{ count }}
    <button @click="$store.dispatch('incrementAsync',3)">Inc async by 3</button>
  </div>
</template>

 

Remarques :

  • l'action incrementAsync simule un comportement asynchrone grâce à l'appel de setTimeOut() qui est effectivement une fonction asynchrone.
  • Elle prend en paramètre une fonction qui sera appelée par le moteur JS quand le temps donné en second paramètre sera écoulé.
  • Pendant ce temps, comme setTimeOut() est asynchrone, le moteur JS ne reste pas bloqué en attente et exécute potentiellement d'autres morceaux de code.

 

Démonstration 3 :

  • Dans le répertoire store, copier index.js.3 dans index.js
  • Dans le répertoire views, copier ComponentA.vue.3 dans ComponentA.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.
  • Si on clique sur le lien A. Les valeurs affichées sont 0 0 0.
  •  Quand on clique sur le bouton "Inc async by 3", on voit que les valeurs changent conformément aux calculs fait dans les getters, mais seulement au bout de deux secondes.
  • Cliquer sur le bouton de nouveau puis rapidement sur le lien B. On voit ComponentB s'afficher, ce qui prouve bien que le moteur JS n'est pas resté bloqué sur setTimeOut().
  • Quand on revient sur ComponentA, les valeurs ont bien changé, preuve que le traitement n'a pas été interrompu par le changement de page.
  • Cliquer trois fois rapidement sur "Inc async by 3" : au bout de 3 secondes, les incrémentations apparaissent successivement.

 

Remarques finales :

  • Il existe d'autres possibilités avec les actions, notamment celle de les chaîner.
  • Par exemple, on peut lancer une action et attendre qu'elle se termine pour lancer une autre action, etc.
  • Pour cela, il faut utiliser le principe des Promise, cf. documentation de Vuex.

 

3.3°/ Les mappers.

  • Quand un composant doit récupérer directement la valeur (donc sans calcul dessus) de plusieurs variables de state, il doit créer autant de variables computed.
  • Idem quand il veut récupérer la valeur de plusieurs getter.
  • Et quand on veut lancer une mutation/action, on est obligé d'appeler commit() ou dispatch(...) dans le template, ce qui n'est pas dans l'esprit vuejs où un composant est plutôt censé appeler des méthodes qui lui sont propres.
  • Pour éviter l'écriture fastidieuse de nombreuses fonctions, Vuex fournit des "Helpers", qui vont créer de manière simple les fonctions dont on a besoin.
  • Ces helpers sont ni plus ni moins que des fonctions qui vont créer les fonction 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>

Petite explication JS :

  • Toutes les instructions suivantes sont équivalentes. Les deux premières utilisent les notations raccourcies des fonctions fléchées. La dernière utilise la notation classique.
getItem : (state) => (id) => (state.tab[id])

getItem : (state) => (id) => { return state.tab[id] }

getItem : (state) => { return (id) => { return state.tab[id] } }

getItem : function(state) {
            return function(id) {
              return state.tab[id]
	         }
	      }
  • Elles permettent à getItem() de retourner une fonction (anonyme) au lieu d'une valeur.
  • On peut donc ensuite appeler cette fonction avec un paramètre qui correspondra à id.
  • Comme cette fonction permet de retourner state.tab[id], on a ainsi obtenu un getter paramétré.

 

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 4 :

  • Dans le répertoire store, copier index.js.4 dans index.js
  • Dans le répertoire views, copier ComponentA.vue.4 dans ComponentA.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.

 

3.4°/ 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 5:

  • Dans le répertoire store, copier index.js.5 dans index.js
  • Dans le répertoire views, copier ComponentA.vue.5 dans ComponentA.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 4, mais en ayant modularisé le store.