L'archive des sources (répertoire src) pour les démonstrations est téléchargeable [ ici ]


 Préambule

  • Pinia n'existe pas pour vuejs V2. Il faut pour cela utiliser Vuex, qui est un peu plus complexe que Pinia, mais qui repose sur à peu près les mêmes principes.
  • Si vous utilisez des composants écrits pour la V2 et qui utilisent Vuex, il est cependant possible, sous certaines conditions, de les réutiliser dans une application V3, pour qu'ils fonctionnent avec pinia, avec très peu de modifications dans la partie script et aucune dans le template.
  • La condition principale à cette migration facile est d'avoir écrit le composant V2 SANS JAMAIS utiliser l'objet $store pour accéder au store, et donc d'utiliser les mapper de vuex.
  • En effet, pinia fournit les mêmes fonctions de mapping que Vuex. Bien entendu, cela suppose de conserver totalement avec la syntaxe V2 du composant, car la syntaxe V3 n'est pas compatible avec les mapper.

 

 1°/ Objectifs de Pinia

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

 

  • Pinia permet de créer un tel dépôt, appelé le "store", en seulement quelques lignes de code, et d'y accéder dans les composants via 'ajout de 2 instructions.
  • Il est ainsi 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.

 

ATTENTION ! Ce n'est pas parce que l'on utilise Pinia que l'on va mettre toutes les données dans le store. Les composants peuvent toujours définir des variables locales, si elles ne sont pas nécessaires à d'autres composants.

 

2°/ Principes basiques de Pinia

  • Quand on crée un projet vierge grâce à vite, dans lequel on intègre pinia (via le menu de personnalisation de projet), un répertoire stores est créé avec un fichier counter.js dedans.
  • Ce fichier est un juste exemple de store stockant un compteur et il peut être librement effacé et remplacé par un ou plusieurs fichiers qui vont définir le store de l'application.
  • En effet, pinia permet de créer autant de store que l'on veut, chacun dans un fichier séparé, plutôt qu'un seul "gros" dépôt.
  • Les composants peuvent ainsi importer uniquement les stores qui lui sont nécessaires.
  • Un store pinia comporte principalement :
    • un ensemble de variables définissant le "state" du store, donc les informations que l'on veut rendre accessibles à tous les composants.
    • des fonctions "action" permettant de mettre à jour ces variables (NB : elles peuvent être asynchrones et donc faire appel à une API distante)
  • Un store peut également définir des "getters", sous la forme de variables calculée, qui seront également accessibles aux composant qui importent le store.

 

  • A noter qu'il existe différentes syntaxes pour définir le state, les getters et les actions. Cependant, le plus simple est d'utiliser celle qui reprend la syntaxe que l'on utilise dans les parties <script setup> des composants.

  

3°/ Créer un store

3.1°/ La fonction de création

  • Créer un store commence par appeler la fonction defineStore() de pinia. Celle-ci prend 2 paramètres :
    • le nom du store,
    • une fonction (sans paramètre) dont le code définit le store, et qui doit renvoyer les éléments que l'on veut rendre accessibles aux composants.
  • Par exemple :
import { defineStore } from 'pinia'
export const useMyStore = defineStore('mystore', () => {
  // définition du store
})
  • On constate que cette fonction renvoie elle-même une fonction qu'il faut stocker dans une variable exportée.
  • C'est cette variable que les composants devront importer.
  • NB : le nom de cette variable est libre, mais il est d'usage de la nommer useXXXStore.

 

3.2°/ La définition du store

  • Le code de la fonction fléchée que l'on donne en paramètre à defineStore() peut être écrit de différente façon mais le plus simple est d'utiliser une syntaxe équivalente à la partie script des composants.
  • La seule différence est que ce code doit se terminer par un return, qui doit retourner un objet contenant la liste des éléments (state, getters et actions) que l'on veut rendre accessible.
  • C'est un peu comme le principe de l'exportation
  • NB : une erreur courante est d'ajouter des éléments à un store et d'oublier de les ajouter au return. Cela provoque forcément des erreurs lorsqu'un composant veut accéder à un élément du store qui n'a pas été retourné.

 

  • Pour définir des élément du state, on définit des variables avec la fonction ref() (ou éventuellement reactive() mais ce n'est pas forcément conseillé)
  • Pour définir des getters, on utilise la fonction computed()
  • Pour définir des actions, on utilise simplement le mot-clé function.

Remarques :

  • Pour utiliser une variable du state dans un getter ou une action, on utilise nom_var.value (donc comme dans script)
  • On peut utiliser un getter dans un autre getter, avec nom_getter.value
  • Une action peut prendre des paramètres.
  • Une action peut être asynchrone, ce qui permet notamment de faire appel à une API.
  • On peut créer des fonctions annexes utilisées par les getters/actions, sans pour autant les retourner à la fin. Cela correspond à des sortes de fonctions privées au store.

 

Exemple (dans stores/count.js) :

import { defineStore } from 'pinia'
import {ref, computed} from 'vue'
export const useCountStore = defineStore('countstore', () => {
  // STATE
  const count = ref(0)
  // GETTERS
  const doubleCount = computed( () => 2*count.value)
  const fourthCount = computed( () => 2*doubleCount.value)
  // ACTIONS
  function incCount(amount) {
    count.value += amount
  }
  return { count, doubleCount, fourthCount, incCount }
})
 

4°/ accès au store dans les composants

4.1°/ mettre en place l'accès

  • Un composant qui veut utiliser un store doit seulement dans <script setup> :
    • importer la fonction useXXXStore() du store voulu,
    • appeler cette fonction pour récupérer un objet permettant d'accéder au store.
  • Le nommage de cet objet est libre, mais l'usage veut qu'on l'appelle xxxStore

Exemple :

<script setup>
import {useCountStore} from '@/stores/count.js'
const countStore = useCountStore()
...
</script>

 

4.2°/ Manipuler le store

  • Accéder aux membres du store dans un composant se fait de la même façon dans le template ET dans script.
  • Pour manipuler une variable du state : nom_store.nom_variable,
  • Pour obtenir la valeur d'un getter : nom_store.nom_getter,
  • Pour appeler une action : nom_store.nom_action(...).
  • On remarque donc que l'on utilise JAMAIS .value, malgré le fait que l'on accède à des variables observées.

Exemple :

<template>
  <div>
      {{ countStore.count }} {{ countStore.doubleCount }}
      <button @click=countStore.incCount(5)>Inc by 5</button>
      <button> @click="reset">Reset</button>
  </div>
</template>
<script setup>
import {useCountStore} from '@/stores/count.js'
const countStore = useCountStore()
function reset() {
  countStore.incCount( -countStore.count) // example of calling an action, would be simpler to do countStore.count = 0
  console.log(countStore.doubleCount)
}
</script>

 

4.3°/ Subtilités

 

4.3.1°/ Getters avec paramètre

  •  Les getters sont créés avec computed() et ne peuvent donc par défaut avoir de paramètres.
  • Cependant, on peut utiliser une astuce : la fonction calculée ne renvoie pas une valeur mais une fonction avec paramètre, que l'on peut ensuite appeler.

Exemple (dans le store count.js)  :

const multBy = computed( () => amount => count.value*amount)

 Exemple d'utilisation dans le template d'un composant :

{{ countStore.multBy(3) }}
  • Cela fonctionne grâce au fait que JS est un langage interprété :
    • il évalue d'abord countStore.multBy, ce qui conduit à le "remplacer" par sa valeur, qui est amount => count.value*amount (donc une fonction)
    • il évalue ensuite (amount => count.value*amount)(3), ce qui conduit à exécuter cette fonction avec comme paramètre 3.

4.3.2°/ Accès "externe" à un store

  • Il est parfois utile d'accéder au store depuis autre chose qu'on composant, par exemple une fonction de service, dans le router, ...
  • Dans ce cas, la seule contrainte est de s'assurer que Pinia a été initialisé avant d'accéder au store (NB : Cette initialisation est généralement faite dans le fichier main.js, plus ou moins directement).
  • S'assurer que cette contrainte soit respectée n'est pas très compliqué :
    • on repère tout fichier comportant une instruction du type useXXXStore(),
    • on vérifie où se trouve cette instruction et quand est-ce qu'elle sera exécutée.
    • Si elle est exécutée, directement ou indirectement, dès que l'on importe le fichier => problème potentiel si l'importation se fait avant l'initialisation de pinia.
    • Dans ce cas, on obtient un message dans le console :
  • "getActivePinia()" was called but there was no active Pinia. Are you trying to use a store before calling "app.use(pinia)"?
  • Si on se trouve dans le situation ci-dessus, il suffit de déplacer l'instruction de façon à ce qu'elle ne soit pas exécutée lors de l'importation mais seulement quand c'est nécessaire.
  • Un exemple d'une telle solution est donné dans la démonstration.

4.3.3°/ Masquer l'utilisation du store dans le template

  •  Le fait d'utiliser le nom d'un store dans un template rend ce template dépendant de l'utilisation de pinia.
  • Son code ne peut donc pas être réutilisé directement pour créer un composant similaire visuellement mais avec une partie script différente.
  • Il est cependant possible de masquer cette utilisation en créant des variables computed et des fonctions qui vont être utilisées dans le template, mais qui en fait, permettent d'accéder au store.

Exemple :

<template>
      {{ count }}
      <button @click=inc(5)>Inc by 5</button>
</template>
<script setup>
import {useCountStore} from '@/stores/count.js'
import {computed} from 'vue'
const countStore = useCountStore()

const count = computed(() => countStore.count)

function inc(amount) {
  countStore.incCount(amount)
}
</script>
  • On remarque qu'il n'y a plus aucune référence au store dans ce template.

 

5°/ Démonstration

  • Examiner les 2 fichiers de stores : leur structure et contenu correspond à ce qui a été abordé dans les sections précédentes.
  • Montrer le début du script de TD5Demo1 : il y a bien les 2 instructions permettant d'accéder au store.
  • Dans la partie template, on réutilise le nom du store pour accéder directement au variables du state/getters.
  • Cliquer sur le premier bouton : la valeur est bien incrémentée immédiatement de 2 et les getters recalculés.
  • Cliquer sur le deuxième bouton : la valeur est incrémentée de 6, mais seulement au bout de 2 secondes, pour simuler un appel asynchrone. C'est le cas car l'action dans le store est asynchrone. Malgré cela, la page est bien rafraîchie automatiquement avec la nouvelle valeur du compteur et des getters.
  • Cliquer sur le 3ème bouton : l'incrémentation est immédiate de 7. En examinant TD5Demo1 on constate que l'on peut effectivement accéder au store depuis la partie script sans avoir besoin d'utiliser .value.
  • On constate aussi que l'on peut utiliser une fonction de service qui elle-même manipule de store.
  • Aller dans logger.service.js et décommenter la 1ère ligne avec useCounterStore() et commenter la 2ème. Recharger la page => erreur dans la console. Normal car on tente d'accéder au store avec que Pinia ne soit initialisé.
  • Remettre à l'état précédent.
  • Dans le champ de saisie en haut à gauche, taper 2
  • Le composant TD5Demo1 est détruit et TD5Demo2 apparaît à sa place. Pourtant, on remarque que les valeurs du compteur/getter n'ont pas changé.
  • C'est la preuve que l'on a bien centralisé ces valeurs et que différents composants peuvent y accéder.
  • Examiner TD5Demo2 : certains membres du store ont été "masqués" dans le template grâce à la technique montrée en 4.3.3.
  • cliquer plusieurs fois sur le bouton, tout en revenant sur le demo 1 pour changer le compteur, afin de stocker un certain nombre de valeur dans le second store.
  • En tapant en indice dans le champ de saisie du bas, on constate que l'on a bien réussi à faire appel à un getter "pseudo-paramétré", qui va chercher dans le tableau du store une valeur à l'indice donné dans le champ.