Gestion de l'état partagé dans Vue 3
Publié: 2022-03-10L'état peut être difficile. Lorsque nous démarrons un projet Vue simple, il peut être simple de conserver notre état de fonctionnement sur un composant particulier :
setup() { let books: Work[] = reactive([]); onMounted(async () => { // Call the API const response = await bookService.getScienceBooks(); if (response.status === 200) { books.splice(0, books.length, ...response.data.works); } }); return { books }; },
Lorsque votre projet consiste en une seule page d'affichage de données (peut-être pour les trier ou les filtrer), cela peut être convaincant. Mais dans ce cas, ce composant obtiendra des données sur chaque requête. Et si vous vouliez le garder ? C'est là que la gestion de l'État entre en jeu. Comme les connexions réseau sont souvent coûteuses et parfois peu fiables, il serait préférable de conserver cet état lorsque vous naviguez dans une application.
Un autre problème est la communication entre les composants. Bien que vous puissiez utiliser des événements et des accessoires pour communiquer avec les enfants-parents directs, la gestion de situations simples comme la gestion des erreurs et les indicateurs occupés peut être difficile lorsque chacune de vos vues/pages est indépendante. Par exemple, imaginez que vous disposiez d'un contrôle de niveau supérieur câblé pour afficher l'erreur et l'animation de chargement :
// App.vue <template> <div class="container mx-auto bg-gray-100 p-1"> <router-link to="/"><h1>Bookcase</h1></router-link> <div class="alert" v-if="error">{{ error }}</div> <div class="alert bg-gray-200 text-gray-900" v-if="isBusy"> Loading... </div> <router-view :key="$route.fullPath"></router-view> </div> </template>
Sans un moyen efficace de gérer cet état, cela pourrait suggérer un système de publication/abonnement, mais en fait, le partage de données est plus simple dans de nombreux cas. Si vous voulez avoir un état partagé, comment procédez-vous ? Regardons quelques façons courantes de le faire.
Note : Vous trouverez le code de cette section dans la branche « main » du projet exemple sur GitHub.
État partagé dans Vue 3
Depuis que je suis passé à Vue 3, j'ai complètement migré vers l'utilisation de l'API de composition. Pour l'article, j'utilise également TypeScript bien que ce ne soit pas nécessaire pour les exemples que je vous montre. Bien que vous puissiez partager l'état comme vous le souhaitez, je vais vous montrer plusieurs techniques que je trouve les modèles les plus couramment utilisés. Chacun a ses propres avantages et inconvénients, alors ne prenez pas tout ce dont je parle ici comme un dogme.
Les techniques comprennent :
- Des usines,
- Singletons partagés,
- Vuex 4,
- Vuex 5.
Remarque : Vuex 5, au moment de la rédaction de cet article, est au stade RFC (Request for Comments) donc je veux vous préparer pour où va Vuex, mais pour le moment il n'y a pas de version fonctionnelle de cette option.
Creusons…
Des usines
Remarque : Le code de cette section se trouve dans la branche "Factories" du projet d'exemple sur GitHub.
Le modèle d'usine consiste simplement à créer une instance de l'état qui vous intéresse. Dans ce modèle, vous renvoyez une fonction qui ressemble beaucoup à la fonction start de l'API de composition. Vous créeriez une portée et créeriez les composants de ce que vous recherchez. Par exemple:
export default function () { const books: Work[] = reactive([]); async function loadBooks(val: string) { const response = await bookService.getBooks(val, currentPage.value); if (response.status === 200) { books.splice(0, books.length, ...response.data.works); } } return { loadBooks, books }; }
Vous pouvez demander uniquement les parties des objets créés en usine dont vous avez besoin comme ceci :
// In Home.vue const { books, loadBooks } = BookFactory();
Si nous ajoutons un indicateur isBusy
pour indiquer quand la demande réseau se produit, le code ci-dessus ne change pas, mais vous pouvez décider où vous allez afficher le isBusy
:
export default function () { const books: Work[] = reactive([]); const isBusy = ref(false); async function loadBooks(val: string) { isBusy.value = true; const response = await bookService.getBooks(val, currentPage.value); if (response.status === 200) { books.splice(0, books.length, ...response.data.works); } } return { loadBooks, books, isBusy }; }
Dans une autre vue (vue ?), vous pouvez simplement demander le drapeau isBusy sans avoir à savoir comment fonctionne le reste de l'usine :
// App.vue export default defineComponent({ setup() { const { isBusy } = BookFactory(); return { isBusy } }, })
Mais vous avez peut-être remarqué un problème; chaque fois que nous appelons l'usine, nous obtenons une nouvelle instance de tous les objets. Il y a des moments où vous souhaitez qu'une usine renvoie de nouvelles instances, mais dans notre cas, nous parlons de partager l'état, nous devons donc déplacer la création en dehors de l'usine :
const books: Work[] = reactive([]); const isBusy = ref(false); async function loadBooks(val: string) { isBusy.value = true; const response = await bookService.getBooks(val, currentPage.value); if (response.status === 200) { books.splice(0, books.length, ...response.data.works); } } export default function () { return { loadBooks, books, isBusy }; }
Maintenant, l'usine nous donne une instance partagée, ou un singleton si vous préférez. Bien que ce modèle fonctionne, il peut être déroutant de renvoyer une fonction qui ne crée pas une nouvelle instance à chaque fois.
Étant donné que les objets sous-jacents sont marqués comme const
, vous ne devriez pas pouvoir les remplacer (et briser la nature singleton). Donc ce code devrait se plaindre :
// In Home.vue const { books, loadBooks } = BookFactory(); books = []; // Error, books is defined as const
Il peut donc être important de s'assurer que l'état mutable peut être mis à jour (par exemple en utilisant books.splice()
au lieu d'assigner les livres).
Une autre façon de gérer cela consiste à utiliser des instances partagées.
Instances partagées
Le code de cette section se trouve dans la branche "SharedState" de l'exemple de projet sur GitHub.
Si vous allez partager l'état, autant être clair sur le fait que l'état est un singleton. Dans ce cas, il peut simplement être importé en tant qu'objet statique. Par exemple, j'aime créer un objet qui peut être importé en tant qu'objet réactif :
export default reactive({ books: new Array<Work>(), isBusy: false, async loadBooks() { this.isBusy = true; const response = await bookService.getBooks(this.currentTopic, this.currentPage); if (response.status === 200) { this.books.splice(0, this.books.length, ...response.data.works); } this.isBusy = false; } });
Dans ce cas, il vous suffit d'importer l'objet (que j'appelle un magasin dans cet exemple) :
// Home.vue import state from "@/state"; export default defineComponent({ setup() { // ... onMounted(async () => { if (state.books.length === 0) state.loadBooks(); }); return { state, bookTopics, }; }, });
Il devient alors facile de se lier à l'état :
<!-- Home.vue --> <div class="grid grid-cols-4"> <div v-for="book in state.books" :key="book.key" class="border bg-white border-grey-500 m-1 p-1" > <router-link :to="{ name: 'book', params: { id: book.key } }"> <BookInfo :book="book" /> </router-link> </div>
Comme les autres modèles, vous obtenez l'avantage de pouvoir partager cette instance entre les vues :
// App.vue import state from "@/state"; export default defineComponent({ setup() { return { state }; }, })
Ensuite, cela peut se lier à ce qui est le même objet (qu'il s'agisse d'un parent de Home.vue
ou d'une autre page du routeur):
<!-- App.vue --> <div class="container mx-auto bg-gray-100 p-1"> <router-link to="/"><h1>Bookcase</h1></router-link> <div class="alert bg-gray-200 text-gray-900" v-if="state.isBusy">Loading...</div> <router-view :key="$route.fullPath"></router-view> </div>
Que vous utilisiez le modèle d'usine ou l'instance partagée, ils ont tous deux un problème commun : l'état mutable. Vous pouvez avoir des effets secondaires accidentels de liaisons ou de changement d'état de code lorsque vous ne le souhaitez pas. Dans un exemple trivial comme celui que j'utilise ici, ce n'est pas assez complexe pour s'en soucier. Mais au fur et à mesure que vous créez des applications de plus en plus grandes, vous voudrez réfléchir plus attentivement à la mutation d'état. C'est là que Vuex peut venir à la rescousse.
Vuex 4
Le code de cette section se trouve dans la branche "Vuex4" de l'exemple de projet sur GitHub.
Vuex est le gestionnaire d'état de Vue. Il a été construit par l'équipe principale bien qu'il soit géré comme un projet distinct. Le but de Vuex est de séparer l'état des actions que vous voulez faire à l'état. Tous les changements d'état doivent passer par Vuex, ce qui signifie que c'est plus complexe, mais vous bénéficiez d'une protection contre les changements d'état accidentels.
L'idée de Vuex est de fournir un flux prévisible de gestion d'état. Les vues passent aux actions qui, à leur tour, utilisent des mutations pour changer d'état, ce qui, à son tour, met à jour la vue. En limitant le flux de changement d'état, vous devriez avoir moins d'effets secondaires qui modifient l'état de vos applications ; il sera donc plus facile de créer des applications plus volumineuses. Vuex a une courbe d'apprentissage, mais avec cette complexité, vous obtenez une prévisibilité.
De plus, Vuex prend en charge les outils de développement (via les outils Vue) pour travailler avec la gestion de l'état, y compris une fonctionnalité appelée voyage dans le temps. Cela vous permet d'afficher un historique de l'état et d'avancer et de reculer pour voir comment cela affecte l'application.
Il y a aussi des moments où Vuex est également important.
Pour l'ajouter à votre projet Vue 3, vous pouvez soit ajouter le package au projet :
> npm i vuex
Ou, alternativement, vous pouvez l'ajouter en utilisant le Vue CLI :
> vue add vuex
En utilisant la CLI, cela créera un point de départ pour votre magasin Vuex, sinon vous devrez le connecter manuellement au projet. Voyons comment cela fonctionne.
Tout d'abord, vous aurez besoin d'un objet d'état créé avec la fonction createStore de Vuex :
import { createStore } from 'vuex' export default createStore({ state: {}, mutations: {}, actions: {}, getters: {} });
Comme vous pouvez le constater, la boutique nécessite la définition de plusieurs propriétés. L'état n'est qu'une liste des données auxquelles vous souhaitez donner accès à votre application :
import { createStore } from 'vuex' export default createStore({ state: { books: [], isBusy: false }, mutations: {}, actions: {} });
Notez que l'état ne doit pas utiliser de wrappers ref ou réactifs . Ces données sont le même type de données de partage que nous avons utilisées avec les instances partagées ou les usines. Ce magasin sera un singleton dans votre application, donc les données en état seront également partagées.
Ensuite, regardons les actions. Les actions sont des opérations que vous souhaitez activer et qui impliquent l'état. Par exemple:
actions: { async loadBooks(store) { const response = await bookService.getBooks(store.state.currentTopic, if (response.status === 200) { // ... } } },
Les actions sont transmises à une instance du magasin afin que vous puissiez accéder à l'état et à d'autres opérations. Normalement, nous ne déstructurerions que les parties dont nous avons besoin :
actions: { async loadBooks({ state }) { const response = await bookService.getBooks(state.currentTopic, if (response.status === 200) { // ... } } },
Le dernier morceau de ceci sont des mutations. Les mutations sont des fonctions qui peuvent muter un état. Seules les mutations peuvent affecter l'état. Donc, pour cet exemple, nous avons besoin de mutations qui changent l'état :
mutations: { setBusy: (state) => state.isBusy = true, clearBusy: (state) => state.isBusy = false, setBooks(state, books) { state.books.splice(0, state.books.length, ...books); } },
Les fonctions de mutation passent toujours dans l'objet d'état afin que vous puissiez muter cet état. Dans les deux premiers exemples, vous pouvez voir que nous définissons explicitement l'état. Mais dans le troisième exemple, nous passons à l'état de définir. Les mutations prennent toujours deux paramètres : l'état et l'argument lors de l'appel de la mutation.
Pour appeler une mutation, vous utiliseriez la fonction de validation sur le magasin. Dans notre cas, je vais juste l'ajouter à la déstructuration :
actions: { async loadBooks({ state, commit }) { commit("setBusy"); const response = await bookService.getBooks(state.currentTopic, if (response.status === 200) { commit("setBooks", response.data); } commit("clearBusy"); } },
Ce que vous verrez ici, c'est comment commit nécessite le nom de l'action. Il existe des astuces pour que cela n'utilise pas seulement des chaînes magiques, mais je vais ignorer cela pour l'instant. Cette utilisation de chaînes magiques est l'une des limites de l'utilisation de Vuex.
Bien que l'utilisation de commit puisse sembler inutile, rappelez-vous que Vuex ne vous laissera pas muter l'état sauf à l'intérieur de la mutation, donc seuls les appels via commit le feront.
Vous pouvez également voir que l'appel à setBooks prend un deuxième argument. C'est le deuxième argument qui appelle la mutation. Si vous aviez besoin de plus d'informations, vous auriez besoin de les regrouper dans un seul argument (une autre limitation de Vuex actuellement). En supposant que vous deviez insérer un livre dans la liste des livres, vous pourriez l'appeler ainsi :
commit("insertBook", { book, place: 4 }); // object, tuple, etc.
Ensuite, vous pouvez simplement déstructurer les éléments dont vous avez besoin :
mutations: { insertBook(state, { book, place }) => // ... }
Est-ce élégant ? Pas vraiment, mais ça marche.
Maintenant que notre action fonctionne avec les mutations, nous devons pouvoir utiliser le magasin Vuex dans notre code. Il y a vraiment deux façons d'accéder au magasin. Tout d'abord, en enregistrant la boutique avec l'application (par exemple, main.ts/js), vous aurez accès à une boutique centralisée à laquelle vous avez accès partout dans votre application :
// main.ts import store from './store' createApp(App) .use(store) .use(router) .mount('#app')
Notez que cela n'ajoute pas Vuex, mais votre boutique réelle que vous créez. Une fois ceci ajouté, vous pouvez simplement appeler useStore
pour obtenir l'objet store :
import { useStore } from "vuex"; export default defineComponent({ components: { BookInfo, }, setup() { const store = useStore(); const books = computed(() => store.state.books); // ...
Cela fonctionne bien, mais je préfère simplement importer directement le magasin :
import store from "@/store"; export default defineComponent({ components: { BookInfo, }, setup() { const books = computed(() => store.state.books); // ...
Maintenant que vous avez accès à l'objet store, comment l'utilisez-vous ? Pour l'état, vous devrez les envelopper avec des fonctions calculées afin que les modifications soient propagées à vos liaisons :
export default defineComponent({ setup() { const books = computed(() => store.state.books); return { books }; }, });
Pour appeler des actions, vous devrez appeler la méthode dispatch :
export default defineComponent({ setup() { const books = computed(() => store.state.books); onMounted(async () => await store.dispatch("loadBooks")); return { books }; }, });
Les actions peuvent avoir des paramètres que vous ajoutez après le nom de la méthode. Enfin, pour changer d'état, vous devrez appeler commit comme nous l'avons fait dans les actions. Par exemple, j'ai une propriété de pagination dans le magasin, puis je peux changer l'état avec commit :
const incrementPage = () => store.commit("setPage", store.state.currentPage + 1); const decrementPage = () => store.commit("setPage", store.state.currentPage - 1);
Notez que l'appeler ainsi générerait une erreur (car vous ne pouvez pas changer d'état manuellement):
const incrementPage = () => store.state.currentPage++; const decrementPage = () => store.state.currentPage--;
C'est le vrai pouvoir ici, nous voudrions contrôler où l'état est changé et ne pas avoir d'effets secondaires qui produisent des erreurs plus tard dans le développement.
Vous pouvez être submergé par le nombre de pièces mobiles dans Vuex, mais cela peut vraiment aider à gérer l'état dans des projets plus grands et plus complexes. Je ne dirais pas que vous en avez besoin dans tous les cas, mais il y aura de grands projets où cela vous aidera globalement.
Le gros problème avec Vuex 4 est que travailler avec lui dans un projet TypeScript laisse beaucoup à désirer. Vous pouvez certainement créer des types TypeScript pour aider au développement et aux builds, mais cela nécessite beaucoup de pièces mobiles.
C'est là que Vuex 5 est censé simplifier le fonctionnement de Vuex dans TypeScript (et dans les projets JavaScript en général). Voyons comment cela fonctionnera une fois qu'il sera publié ensuite.
Vuex 5
Remarque : Le code de cette section se trouve dans la branche "Vuex5" du projet d'exemple sur GitHub.
Au moment de cet article, Vuex 5 n'est pas réel. C'est une RFC (Request for Comments). C'est un plan. C'est un point de départ pour la discussion. Donc, beaucoup de ce que je peux expliquer ici changera probablement quelque peu. Mais pour vous préparer au changement de Vuex, je voulais vous donner un aperçu de là où ça se passe. Pour cette raison, le code associé à cet exemple ne se construit pas.
Les concepts de base du fonctionnement de Vuex sont restés quelque peu inchangés depuis sa création. Avec l'introduction de Vue 3, Vuex 4 a été créé pour permettre principalement à Vuex de travailler dans de nouveaux projets. Mais l'équipe essaie d'examiner les vrais problèmes avec Vuex et de les résoudre. À cette fin, ils prévoient des changements importants :
- Plus de mutations : les actions peuvent muter l'état (et éventuellement n'importe qui).
- Meilleure prise en charge de TypeScript.
- Meilleure fonctionnalité multi-magasins.
Alors, comment cela fonctionnerait-il ? Commençons par créer la boutique :
export default createStore({ key: 'bookStore', state: () => ({ isBusy: false, books: new Array<Work>() }), actions: { async loadBooks() { try { this.isBusy = true; const response = await bookService.getBooks(); if (response.status === 200) { this.books = response.data.works; } } finally { this.isBusy = false; } } }, getters: { findBook(key: string): Work | undefined { return this.books.find(b => b.key === key); } } });
Le premier changement à voir est que chaque magasin a maintenant besoin de sa propre clé. C'est pour vous permettre de récupérer plusieurs magasins. Ensuite, vous remarquerez que l'objet d'état est maintenant une usine (par exemple, les retours d'une fonction, non créés lors de l'analyse). Et il n'y a plus de section sur les mutations. Enfin, à l'intérieur des actions, vous pouvez voir que nous accédons à l'état en tant que propriétés sur le pointeur this
. Plus besoin de passer en état et de s'engager dans des actions. Cela aide non seulement à simplifier le développement, mais facilite également la déduction des types pour TypeScript.
Pour enregistrer Vuex dans votre application, vous enregistrerez Vuex au lieu de votre boutique globale :
import { createVuex } from 'vuex' createApp(App) .use(createVuex()) .use(router) .mount('#app')
Enfin, pour utiliser le magasin, vous allez importer le magasin puis en créer une instance :
import bookStore from "@/store"; export default defineComponent({ components: { BookInfo, }, setup() { const store = bookStore(); // Generate the wrapper // ...
Notez que ce qui est renvoyé par le magasin est un objet fabrique qui renvoie cette instance du magasin, quel que soit le nombre de fois que vous appelez la fabrique. L'objet renvoyé est juste un objet avec les actions, l'état et les getters en tant que citoyens de première classe (avec des informations de type):
onMounted(async () => await store.loadBooks()); const incrementPage = () => store.currentPage++; const decrementPage = () => store.currentPage--;
Ce que vous verrez ici, c'est que l'état (par exemple, currentPage
) ne sont que de simples propriétés. Et les actions (par exemple loadBooks
) ne sont que des fonctions. Le fait que vous utilisez un magasin ici est un effet secondaire. Vous pouvez traiter l'objet Vuex comme un simple objet et poursuivre votre travail. Il s'agit d'une amélioration significative de l'API.
Un autre changement qu'il est important de souligner est que vous pouvez également générer votre boutique en utilisant une syntaxe de type Composition API :
export default defineStore("another", () => { // State const isBusy = ref(false); const books = reactive(new Array≷Work>()); // Actions async function loadBooks() { try { this.isBusy = true; const response = await bookService.getBooks(this.currentTopic, this.currentPage); if (response.status === 200) { this.books = response.data.works; } } finally { this.isBusy = false; } } findBook(key: string): Work | undefined { return this.books.find(b => b.key === key); } // Getters const bookCount = computed(() => this.books.length); return { isBusy, books, loadBooks, findBook, bookCount } });
Cela vous permet de créer votre objet Vuex comme vous le feriez pour vos vues avec l'API de composition et c'est sans doute plus simple.
L'un des principaux inconvénients de cette nouvelle conception est que vous perdez la non-mutabilité de l'état. Il y a des discussions en cours autour de la possibilité d'activer cela (pour le développement uniquement, tout comme Vuex 4) mais il n'y a pas de consensus sur l'importance de cela. Personnellement, je pense que c'est un avantage clé pour Vuex, mais nous devrons voir comment cela se passe.
Où sommes-nous?
La gestion de l'état partagé dans les applications à page unique est une partie cruciale du développement de la plupart des applications. Avoir un plan de match sur la façon dont vous voulez vous y prendre dans Vue est une étape importante dans la conception de votre solution. Dans cet article, je vous ai montré plusieurs modèles de gestion de l'état partagé, y compris ce qui arrive pour Vuex 5. J'espère que vous aurez maintenant les connaissances nécessaires pour prendre la bonne décision pour vos propres projets.