Pinia w Vue 3: zarządzanie globalnym stanem aplikacji

Pinia w Vue 3: zarządzanie globalnym stanem aplikacji
Szybka odpowiedź

Pinia to rekomendowana biblioteka do zarządzania stanem w Vue 3, Vuex jest w trybie utrzymania. Store deklaruje się przez defineStore, używa przez wywołanie usePanierStore() w dowolnym komponencie i destrukturyzuje przez storeToRefs, żeby nie stracić reaktywności.

Przekazanie zdarzenia z komponentu do rodzica sprawdza się na jednym, może dwóch poziomach. Wyżej spędzasz czas na przekazywaniu zdarzeń dalej i przepychaniu propsów przez komponenty, którym są zupełnie niepotrzebne. Pinia rozwiązuje ten problem: stan deklarujesz raz, a czytasz go i zmieniasz z dowolnego komponentu.

Pinia czy Vuex?

To pytanie już nie istnieje. Vuex jest w trybie utrzymania, a oficjalna dokumentacja Vue odsyła do Pinii jako rekomendowanej biblioteki do zarządzania stanem. W nowym projekcie bierz Pinię bez wahania. W działającym projekcie na Vuexie migracja nie jest pilna: robi się ją store po storze, obie biblioteki spokojnie współistnieją.

Co Pinia zmienia w praktyce: żadnych mutacji, tylko akcje. żadnych zagnieżdżonych modułów, bo każdy store jest modułem, i typowanie, które działa bez akrobacji.

Instalacja

bash
npm install pinia

Następnie podepnij plugin do aplikacji:

src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)

app.use(createPinia())
app.mount('#app')

W nowym projekcie robi to za ciebie oficjalny generator: odpowiedz „tak” na pytanie o Pinię podczas npm create vue@latest.

Pisanie store’a

Store deklaruje się przez defineStore. Pierwszy argument to unikalny identyfikator, używany przez narzędzia deweloperskie. Drugi może przyjąć dwie formy, forma setup jest tą, którą generuje oficjalne narzędzie, i tą, która najbardziej przypomina komponent.

src/stores/panier.js
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'

export const usePanierStore = defineStore('panier', () => {
  // stan: ref-y
  const lignes = ref([])

  // gettery: computed
  const total = computed(() =>
    lignes.value.reduce((somme, l) => somme + l.prix * l.quantite, 0)
  )
  const nombreArticles = computed(() =>
    lignes.value.reduce((somme, l) => somme + l.quantite, 0)
  )

  // akcje: funkcje
  function ajouter(produit) {
    const ligne = lignes.value.find((l) => l.id === produit.id)
    if (ligne) {
      ligne.quantite += 1
      return
    }
    lignes.value.push({ ...produit, quantite: 1 })
  }

  function retirer(id) {
    lignes.value = lignes.value.filter((l) => l.id !== id)
  }

  function vider() {
    lignes.value = []
  }

  return { lignes, total, nombreArticles, ajouter, retirer, vider }
})

Wszystko, co funkcja zwróci, staje się publiczne. To, czego nie zwróci, zostaje prywatne dla store’a: tak trzyma się w nim wewnętrzny licznik albo funkcję pomocniczą.

Konwencja nazewnicza to useQuelqueChoseStore, dokładnie jak w composable.

Forma options

Drugi zapis powtarza strukturę Vuexa, tyle że bez mutacji:

src/stores/panier.js
export const usePanierStore = defineStore('panier', {
  state: () => ({ lignes: [] }),
  getters: {
    total: (state) => state.lignes.reduce((s, l) => s + l.prix * l.quantite, 0)
  },
  actions: {
    ajouter(produit) {
      this.lignes.push({ ...produit, quantite: 1 })
    }
  }
})

Obie formy są równoważne i wymienne. Forma options ułatwia przesiadkę z Vuexa, forma setup daje więcej swobody, choćby w użyciu composable wewnątrz store’a.

Używanie store’a

Wywołaj funkcję w komponencie. Instancja jest jedna: dwa komponenty wywołujące usePanierStore() dostają dokładnie ten sam obiekt.

src/components/Panier.vue
<script setup>
import { usePanierStore } from '@/stores/panier'

const panier = usePanierStore()
</script>

<template>
  <p>{{ panier.nombreArticles }} article(s) — {{ panier.total }} €</p>

  <ul>
    <li v-for="ligne in panier.lignes" :key="ligne.id">
      {{ ligne.nom }} × {{ ligne.quantite }}
      <button @click="panier.retirer(ligne.id)">Retirer</button>
    </li>
  </ul>

  <button @click="panier.vider()">Vider</button>
</template>

Stan, gettery i akcje są dostępne bezpośrednio na obiekcie, bez żadnego pośrednika.

storeToRefs, czyli utracona reaktywność

To najczęstszy błąd przy Pinii. Destrukturyzacja store’a daje zamrożone wartości:

javascript
const panier = usePanierStore()

const { total } = panier               // ✗ zamrożona liczba, już się nie zmieni
panier.ajouter({ id: 1, prix: 7 })
console.log(total)                     // 0

storeToRefs wyciąga stan i gettery, zachowując reaktywność:

javascript
import { storeToRefs } from 'pinia'

const panier = usePanierStore()
const { total, lignes } = storeToRefs(panier)   // ✓ ref-y
const { ajouter, retirer } = panier             // ✓ akcje destrukturyzuje się wprost

panier.ajouter({ id: 1, prix: 7 })
console.log(total.value)                        // 7

Zapamiętaj zasadę: stan i gettery przechodzą przez storeToRefs, akcje destrukturyzuje się wprost. Akcje to zwykłe funkcje związane ze storem, nic nie tracą.

Zmiana stanu

Trzy sposoby, w kolejności od najlepszego.

Akcja do każdej logiki biznesowej. To przypadek normalny: reguła jest zapisana raz, w storze.

javascript
panier.ajouter({ id: 2234, nom: 'T-Shirt', prix: 25 })

$patch do zmiany kilku wpisów w jednej operacji, co daje tylko jeden wpis w narzędziach deweloperskich:

javascript
panier.$patch({ lignes: [], codePromo: null })

// forma funkcyjna, dla tablic
panier.$patch((state) => {
  state.lignes.push(nouvelleLigne)
  state.derniereMaj = Date.now()
})

Bezpośrednie przypisanie jest dozwolone, inaczej niż w Vuexie, ale zostaw je do przypadków trywialnych:

javascript
panier.codePromo = 'RENTREE'
$reset nie istnieje w storze setup

W storze napisanym w formie options panier.$reset() przywraca stan początkowy. W storze w formie setup Pinia tego stanu początkowego nie zna: wywołanie rzuca błąd. Napisz własną akcję czyszczącą, taką jak funkcja vider() powyżej.

Obserwowanie zmian

$subscribe jest wywoływane przy każdej zmianie stanu. To haczyk, na którym wiesza się utrwalanie koszyka:

javascript
panier.$subscribe((mutation, state) => {
  localStorage.setItem('panier', JSON.stringify(state.lignes))
})

Przy szerszej potrzebie utrwalania robotę na wszystkich store’ach wykona plugin Pinii. Plugin to funkcja, która dostaje kontekst i wzbogaca każdy store:

src/stores/plugin-persistance.js
export function persistance({ store }) {
  const sauvegarde = localStorage.getItem(store.$id)
  if (sauvegarde) {
    store.$patch(JSON.parse(sauvegarde))
  }

  store.$subscribe((_, state) => {
    localStorage.setItem(store.$id, JSON.stringify(state))
  })
}
src/main.js
const pinia = createPinia()
pinia.use(persistance)

Testowanie store’a

Store testuje się bez montowania komponentu. Wystarczy przed każdym testem aktywować świeżą instancję Pinii, żeby startować z czystego stanu:

src/stores/__tests__/panier.spec.js
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { usePanierStore } from '../panier.js'

describe('store panier', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('cumule les quantités du même produit', () => {
    const panier = usePanierStore()
    panier.ajouter({ id: 2234, nom: 'T-Shirt', prix: 25 })
    panier.ajouter({ id: 2234, nom: 'T-Shirt', prix: 25 })

    expect(panier.lignes).toHaveLength(1)
    expect(panier.nombreArticles).toBe(2)
    expect(panier.total).toBe(50)
  })
})

Bez setActivePinia wywołanie store’a kończy się komunikatem „”getActivePinia()” was called but there was no active Pinia. Are you trying to use a store before calling „app.use(pinia)”?”.

Kiedy nie sięgać po Pinię

Store nie jest darmowy: dokłada plik, warstwę pośrednią i kolejny globalny stan. Ma sens wtedy, gdy dane są używane przez odległe od siebie komponenty albo gdy mają przetrwać odmontowanie komponentu.

Przy danych z usługi czasu rzeczywistego zerknij raczej na Firebase i VueFire, których wiązania są już reaktywne.

Do lokalnego stanu formularza wystarczy ref. Do stanu dzielonego między rodzicem a jego bezpośrednim dzieckiem robotę wykonają propsy i zdarzenia. Do cache’owania danych z serwera lepiej odpowie dedykowane narzędzie w rodzaju TanStack Query.

Jeśli dopiero poznajesz Vue, zacznij od kursu Vue 3 dla początkujących: Pinia nabiera sensu, kiedy masz już za sobą komponenty i zdarzenia.

Częste błędy

Destrukturyzacja store'a const { total } = panier daje zamrożoną wartość. Stan i gettery przepuść przez storeToRefs(panier), akcje destrukturyzuje się wprost.n$reset w storze setup | Pinia nie zna stanu początkowego store'a napisanego w formie setup: wywołanie rzuca błąd. Napisz własną akcję czyszczącą.
Wywołanie store'a poza komponentem bez aktywnej Pinii Pojawia się błąd „getActivePinia was called with no active Pinia”. W teście wywołaj setActivePinia(createPinia()) przed każdym przypadkiem.
Wrzucanie całego stanu aplikacji do store'a Do formularza wystarczy lokalny ref, a między rodzicem a jego bezpośrednim dzieckiem propsy albo zdarzenia.

JavaScriptPiniaVue.js

Damien Flandrin Web developer od 2010 roku, twórca Gekkode i Email Impact. Każdy artykuł jest sprawdzany na prawdziwym projekcie przed publikacją. Kontakt
Newsletter

Nowe testy, poradniki i projekty, e-mailem.

Powtarzalne testy, wersjonowany kod, datowane wyniki. Nigdy spamu.