back to top

Usare Vuex per la gestione dello stato di un’applicazione

Col nome Vuex ci si riferisce ad un pattern e all’omonima libreria usata per la gestione dello stato di un’applicazione. Vuex prende ispirazione da Redux che a sua volta è nato sulla base del pattern Flux, sviluppato dal team di React con l’intento di creare un modo prevedibile, riproducibile ed affidabile per la gestione dei dati di un’applicazione.

Al contrario di Redux che è indipendente da qualsiasi libreria o framework, Vuex è stato realizzato per essere usato appositamente al fianco di Vue.js.

Quando si parla di stato di un’applicazione si fa riferimento ai dati da cui dipendono i componenti e che, quando vengono modificati, causano l’aggiornamento di questi ultimi. Per esempio, nel caso di un sito per la consegna del cibo a domicilio lo stato dell’applicazione è costituito dall’elenco dei ristoranti, dal loro menù di piatti e dai prodotti aggiunti al carrello prima di procedere all’acquisto. Se clicchiamo su un pulsante per aggiungere un nuovo prodotto al carrello, ci aspettiamo che l’applicazione aggiorni automaticamente i dati relativi all’ordine e di conseguenza gli opportuni componenti in modo da mostrare sempre le corrette informazioni.

Perché usare Vuex

Cerchiamo allora di capire per quale motivo sono stati introdotti dei pattern come Vuex e che tipo di problemi intendono risolvere.

Vuex è particolarmente indicato in applicazioni di grandi dimensioni con numerosi componenti che formano una struttura ad albero piuttosto profondo.

Le applicazioni realizzate con Vue.js seguono il paradigma di flusso unidirezionale delle informazioni. In questo modo i dati vengono trasmessi in modo prevedibile scorrendo sempre dal componente radice verso i componenti figli. Questi ultimi inviano invece delle segnalazioni ai componenti di livello superiore tramite degli eventi.

Il meccanismo appena descritto permette di realizzare delle applicazioni in cui è sempre chiaro in che modo comunicano i componenti, ma crea delle difficoltà se le informazioni da scambiare devono percorrere l’intero albero dei componenti.

Immaginiamo infatti di rappresentare la struttura di una certa applicazione con l’immagine sottostante in cui ogni nodo costituisce un componente.

Comunicazione fra componenti tramite eventi e props

Se il componente A vuole comunicare con il componente B, deve lanciare un evento personalizzato che viene inoltrato verso l’alto fino al componente base il quale mantiene lo stato dell’applicazione. Dopo aver modificato opportunamente i dati, il componente base invia le informazioni aggiornate tramite Props al componente figlio e la procedura viene ripetuta fin quando i nuovi dati arrivano al componente B.

Per semplificare la comunicazione fra qualsiasi componente dell’applicazione ed evitare un flusso inutile e dispendioso di informazioni ed eventi, introduciamo allora una nuova entità in cui concentriamo i dati dell’intera applicazione. Quando uno dei componenti vuole modificare delle informazioni, invia una richiesta a quest’unica sorgente di dati. Quest’ultima effettua gli aggiornamenti e notifica i componenti che provvedono a mostrare i nuovi dati.

scambio di dati in Vuex

Capire il funzionamento di Vuex

Vuex viene quindi introdotto per semplificare e rendere più prevedibile lo scambio di informazioni fra componenti.

In Vuex esiste infatti un’entità centrale, detta Vuex Store o semplicemente store, che contiene i dati e fornisce gli strumenti per prelevarli o modificarli.

Avendo già visto come definire un componente con l’Option API, la struttura dello store dovrebbe risultare familiare.

Per inizializzare lo Store, creiamo un nuovo oggetto che configuriamo come mostrato nel frammento di codice sottostante.

export default new Vuex.Store({
  state: {},
  mutations: {},
  actions: {},
  getters: {}
});

Distinguiamo quindi 4 proprietà di cui parleremo nel resto della lezione.

  • state
  • mutations
  • actions
  • modules
schema di funzionamento di Vuex

Attraverso la proprietà state definiamo quali sono i dati che compongono lo stato dello store e quindi dell’applicazione.

Un componente può ottenere i dati dello state direttamente oppure attraverso una delle funzioni dell’oggetto getters. Possiamo pensare ai getters come le computed properties per uno store Vuex.

Per modificare i dati dello store, un componente può invece eseguire una mutation. Si tratta di semplici funzioni che hanno il compito di aggiornare in modo sincrono lo stato dello store.

Le mutations hanno però un limite: non possono eseguire del codice asincrono. Non è per esempio possibile effettuare una richiesta ad un server remoto all’interno di una mutation.

Per questo motivo esistono le actions che possono essere asincrone e possono invocare una o più mutations. Si tratta anche in questo caso di banali funzioni a cui Vuex passa degli argomenti che vedremo in dettaglio nel resto della lezione.

Di solito, se un componente vuole modificare i dati dello store, lancia una certa action che esegue una mutation. Quest’ultima ha poi il compito di modificare i dati dello state.

Non appena lo stato dello store subisce una modifica, vengono opportunamente aggiornati i componenti.

Esempio di applicazione in Vuex

Creiamo allora un semplice esempio per illustrare in pratica il funzionamento di Vuex. Si tratta di un’applicazione la cui interfaccia presenta tre pulsanti: uno consente di ottenere un nuovo numero casuale, gli altri due permettono di decrementarlo e incrementarlo rispettivamente.

esempio vuex per generatore dei numeri casuali

Il numero casuale non viene generato dall’applicazione nel browser, bensì da un server al quale inviamo una richiesta di tipo GET per ottenere un nuovo valore.

Spostiamoci allora in una nuova cartella e lanciamo il comando vue create vuex-example. Selezioniamo poi Manually select features attraverso i tasti freccia e premiamo il tasto INVIO.

Sempre attraverso i tasti freccia, navighiamo fra le funzionalità che vogliamo aggiungere al nostro progetto e confermiamo con la barra spaziatrice (Babel, Vuex, Linter / Formatter).

step 1 della procedura di inizializzazione di un nuovo progetto con Vue CLI

Selezioniamo poi ESLint + Prettier come linter/formatter.

step 2 della procedura di inizializzazione di un nuovo progetto con Vue CLI

Successivamente scegliamo l’opzione Lint on save.

step 3 della procedura di inizializzazione di un nuovo progetto con Vue CLI

Indichiamo a Vue di creare dei file separati per la configurazione di Eslint e Prettier.

step 4 della procedura di inizializzazione di un nuovo progetto con Vue CLI

Infine scegliamo di non salvare la configurazione per progetti successivi.

step 5 della procedura di inizializzazione di un nuovo progetto con Vue CLI

Aspettiamo che Vue CLI completi la procedura di generazione dei file e dopo qualche minuto verrà creata una nuova cartella vuex-example con i seguenti file (omettiamo la cartella node_modules per semplicità).

tree vuex-example -aF --dirsfirst -I node_modules
vuex-example
├── public/
│   ├── favicon.ico
│   └── index.html
├── src/
│   ├── assets/
│   │   └── logo.png
│   ├── components/
│   │   └── HelloWorld.vue
│   ├── store/
│   │   └── index.js
│   ├── App.vue
│   └── main.js
├── .browserslistrc
├── .eslintrc.js
├── .gitignore
├── .prettierrc.js
├── README.md
├── babel.config.js
├── package-lock.json
└── package.json

5 directories, 15 files

Procediamo eliminando i file non necessari ed aggiungendo quelli che poi useremo nel resto dell’applicazione. Otteniamo quindi la seguente struttura della cartella base del progetto.

tree vuex-example -aF --dirsfirst -I node_modules
vuex-example
├── public/
│   ├── favicon.ico
│   └── index.html
├── src/
│   ├── assets/
│   │   └── logo.png
│   ├── components/
│   │   ├── CounterButtons.vue
│   │   ├── CounterDisplay.vue
│   │   └── LoadingSpinner.vue
│   ├── services/
│   │   └── RandomNumberService.js
│   ├── store/
│   │   └── index.js
│   ├── App.vue
│   └── main.js
├── .browserslistrc
├── .env.development
├── .eslintrc.js
├── .gitignore
├── .prettierrc.js
├── README.md
├── babel.config.js
├── package-lock.json
├── package.json
└── server.js

6 directories, 20 files

Abbiamo aggiunto il file .env.development in cui sono presenti alcune variabili di sistema. Vue CLI preleva automaticamente le variabili di sistema dai file .env.[mode]. In particolare le variabili presenti in un file .env vengono sempre caricate. Al contrario, in base al valore della variabile NODE_ENV, vengono anche usate le variabili del file .env.[mode] in cui mode coincide con il valore di NODE_ENV.

Quando lanciamo il comando npm run serve all’interno di un progetto creato con Vue CLI, NODE_ENV viene settata al valore ‘development’. Per questo motivo vengono automaticamente lette le variabili di sistema presenti nel file .env.development.

Per convenzione, solo le variabili con prefisso ‘VUE_APP_‘ vengono prelevate lato client. Il file .env.development da noi definito presenta due variabili di sistema che useremo nel resto dell’applicazione all’interno del servizio preposto ad effettuare delle richieste al server locale.

VUE_APP_REMOTE_ADDRESS=127.0.0.1
VUE_APP_PORT=5555

Per illustrare il funzionamento asincrono delle Actions di Vuex, abbiamo deciso di ottenere un nuovo numero casuale effettuando una richiesta di tipo GET ad un server locale implementato in Node.js con il package Express.

Per questa ragione, nella cartella base del progetto abbiamo creato il file server.js per il quale è stato necessario installare i due package Express e Cors con il comando npm i cors express (comando lanciato nella cartella base del nostro progetto che aggiunge le dipendenze nel file package.json).

const http = require('http');
const express = require('express');
const cors = require('cors');

const app = express();

const ADDRESS = process.env.ADDRESS || '127.0.0.1';
const PORT = process.env.PORT || 5555;
const URL = `http://${ADDRESS}:${PORT}`;

app.use(cors());

const [, , , delay = 0] = process.argv;

app.get('/', (req, res) => {
  res.json({
    welcome: `Visit ${URL}/random to get a random number`
  });
});

app.get('/random', (req, res) => {
  setTimeout(() => {
    res.json({
      number: Math.random()
    });
  }, delay);
});

http.createServer(app).listen(PORT, ADDRESS, () => {
  console.log(`Listening on ${URL}`);
});

Il nostro server rudimentale utilizza il package CORS il quale è un middleware per Express che consente al server di ricevere richieste HTTP cross-origin. Ciò è necessario perché sia il server di sviluppo di Vue CLI che quello creato in Express sono raggiungibili all’indirizzo 127.0.0.1, ma su porte differenti. L’applicazione che realizziamo in Vue.js invierà una richiesta HTTP da un’origine diversa proprio a causa della porta.

Tramite il metodo app.get() definiamo il comportamento del server per due percorsi ed in particolare per l’endpoint /random. Ogni volta che viene ricevuta una richiesta di tipo GET all’indirizzo http://127.0.0.1:5555/random, viene restituito un numero casuale compreso fra 0 e 1. Per simulare un ritardo nella risposta e quindi visualizzare un semplice componente di caricamento nell’applicazione Vue.js, abbiamo racchiuso il metodo res.json() all’interno di setTimeout(). Quando lanciamo il server con il comando node server.js possiamo specificare un’opzione per indicare il ritardo della risposta. Per esempio node server.js -d 4000 indicherà al server di inviare la risposta solo dopo 4 secondi.

Creare un nuovo Store in Vuex

Torniamo a concentrare al nostra attenzione su Vuex e vediamo quali configurazioni sono state già realizzate da Vue CLI per il corretto funzionamento di Vuex.

Partiamo dal file main.js ed analizziamo quali sono le differenze rispetto ad un’applicazione che non usa Vuex.

// file: main.js
  import Vue from 'vue';
  import App from './App.vue';
  import store from './store';
  
  Vue.config.productionTip = false;
  
  new Vue({
    store,
    render: (h) => h(App)
  }).$mount('#app');

Notiamo che viene importato lo store dal file ./store/index.js.

// file: store/index.js
import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

export default new Vuex.Store({
  state: {
    num: 0,
    loading: false
  },
  mutations: {},
  actions: {},
  getters: {}
});

Nel file store/index.js viene creato il nuovo store che configuriamo tramite un oggetto di proprietà. Per il momento abbiamo definito solo due proprietà dell’opzione state che rappresenta lo stato della nostra applicazione. Abbiamo quindi un valore numerico inizializzato a 0 a cui in seguito assegneremo dei valori. È presente poi una proprietà booleana loading che useremo per segnalare quando è in corso una richiesta al server. In questo modo, prima di ricevere un nuovo valore, potremo mostrare un componente per segnalare che l’applicazione sta effettuando un’operazione che richiede un certo tempo di attesa.

Stato di un’applicazione

Prima di definire delle actions e mutations vediamo in che modo è possibile accedere ai dati dello store all’interno dei componenti.

Partiamo dal componente App che ha bisogno soltanto del valore della proprietà loading per decidere se mostrare il componente CounterDisplay oppure LoadingSpinner.

Dal momento che abbiamo installato Vuex nel file main.js attraverso l’istruzione Vue.use(Vuex), all’interno di ogni componente è possibile accedere ai dati dello store attraverso this.$store.state.propertyName.

Nel file App.vue possiamo allora usare la proprietà loading attraverso this.$store.state.loading. Quando questa verrà modificata da una specifica Mutation, Vuex provvederà ad aggiornare automaticamente le informazioni e verrà effettuato un nuovo rendering dei componenti che ne fanno uso. Ciò vuol dire che se inizialmente loading è pari a false, il componente App mostra CounterDisplay. Quando alla proprietà loading dello store viene assegnato il valore true, viene invece visualizzato il componente LoadingSpinner.

// file: App.vue
<template>
  <div id="app">
    <div class="counter-wrapper">
      <transition name="fade" mode="out-in">
        <LoadingSpinner v-if="isLoading" color="hsl(188, 58%, 62%)" />
        <CounterDisplay v-else />
      </transition>
    </div>
    <div class="btn-wrapper">
      <CounterButtons />
    </div>
  </div>
</template>

<script>
import CounterDisplay from '@/components/CounterDisplay.vue';
import CounterButtons from '@/components/CounterButtons.vue';
import LoadingSpinner from '@/components/LoadingSpinner.vue';

export default {
  name: 'App',
  components: {
    CounterDisplay,
    CounterButtons,
    LoadingSpinner
  },
  computed: {
    isLoading() {
      return this.$store.state.loading;
    }
  }
};
</script>

Per semplificare il template e migliorare la leggibilità del codice abbiamo creato una computed property isLoading che si limita a restituire il valore di this.$store.state.loading. Abbiamo poi usato isLoading come valore della direttiva v-if. Se this.$store.state.loading è pari a true, ovvero se abbiamo inviato una richiesta al server locale e non abbiamo ancora ricevuto risposta, viene mostrato il componente LoadingSpinner. In caso contrario visualizziamo il componente CounterDisplay.

Invece di replicare la stessa procedura per accedere ai valori dello state e definire in maniera ripetitiva delle computed properties, possiamo sfruttare una delle funzioni messe a disposizione da Vuex per generare automaticamente delle computed properties a partire da una o più proprietà dello state.

Vediamo allora come possiamo ristrutturare il componente App in maniera equivalente usando però la funzione mapState() che importiamo da Vuex.

// file: App.vue

// ...  
// ...

<script>
import { mapState } from 'vuex';
import CounterDisplay from '@/components/CounterDisplay.vue';
import CounterButtons from '@/components/CounterButtons.vue';
import LoadingSpinner from '@/components/LoadingSpinner.vue';

export default {
  name: 'App',
  components: {
    CounterDisplay,
    CounterButtons,
    LoadingSpinner
  },
  computed: mapState({
    isLoading: (state) => state.loading
  })
};
</script>

Importiamo la funzione mapState() che invochiamo ed assegnamo l’oggetto restituito alla proprietà computed. Importiamo poi tre componenti (Il percorso di ciascun componente ha come prefisso il carattere ‘@’. Si tratta di un alias che viene poi risolto da Webpack e sostituito con il percorso assoluto della cartella ‘src’).

La funzione mapState() riceve in ingresso un oggetto in cui ciascuna proprietà sarà una computed property del componente. Nel caso specifico isLoading è il nome della computed property che usiamo nel template del componente App.

A ciascuna proprietà possiamo assegnare un’arrow function che riceve come primo argomento l’intero oggetto state dello store Vuex.

In alternativa, se abbiamo bisogno di accedere a qualche altra proprietà o metodo del componente tramite this, dobbiamo utilizzare una normale funzione come mostrato sotto.

<script>
import { mapState } from 'vuex';
import CounterDisplay from '@/components/CounterDisplay.vue';
import CounterButtons from '@/components/CounterButtons.vue';
import LoadingSpinner from '@/components/LoadingSpinner.vue';

export default {
  name: 'App',
  components: {
    CounterDisplay,
    CounterButtons,
    LoadingSpinner
  },
  computed: mapState({
    isLoading(state) {
      // all'interno di questa funzione
      // possiamo accedere ad eventuali
      // proprietà o metodi
      // usando this.propertyName o this.method()
      return state.loading;
    }
  })
};
</script>

Ma Vuex consente di semplificare ulteriormente la procedura. Se infatti dobbiamo semplicemente accedere ad una proprietà dello state, possiamo semplicemente assegnare una stringa come mostrato nell’esempio sotto. In questo caso isLoading è il nome della computed property del componente a cui viene assegnata la proprietà state.loading

<script>
import { mapState } from 'vuex';
import CounterDisplay from '@/components/CounterDisplay.vue';
import CounterButtons from '@/components/CounterButtons.vue';
import LoadingSpinner from '@/components/LoadingSpinner.vue';

export default {
  name: 'App',
  components: {
    CounterDisplay,
    CounterButtons,
    LoadingSpinner
  },
  computed: mapState({
    // equivalente a: (state) => state.loading
    isLoading: 'loading'
  })
};
</script>

Dopo aver visto il file App.vue, analizziamo CounterDisplay.vue di cui riportiamo il codice sotto.

<template>
  <span class="value-container">{{ num }}</span>
</template>

<script>
import { mapState } from 'vuex';

export default {
  name: 'CounterDisplay',
  computed: mapState([
    // assegna `this.$store.state.num` 
    // alla computed property `this.num`
    'num'
  ])
};
</script>

In questo caso usiamo la funzione mapState() in modo ancora diverso passando come argomento un array di stringhe che rappresentano il nome di una computed property. Tale sintassi presume che il nome di una proprietà dello store concida con quello della rispettiva computed property del componente.

Il componente CounterDisplay si limita a prelevare il numero casuale presente nello state dello store Vuex e a mostrarlo nell’applicazione.

Prima di parlare delle Mutations e vedere in che modo possiamo assegnare un nuovo valore alla proprietà state.num dello store, vogliamo evidenziare che la funzione mapState() restituisce un oggetto che abbiamo assegnato alla proprietà computed.

Nel caso volessimo definire altre computed properties oltre a quelle create a partire dalle proprietà dello state dello store Vuex, possiamo sfruttare l’operatore javascript spread (…) che consente di unire le proprietà di più oggetti. In questo caso uniamo le computed properties del componente a quelle presenti nell’oggetto restituito da mapState() formando un unico oggetto finale che assegniamo poi alla proprietà computed.

computed: {
  otherComputedProperty () { /* ... */ },
  // usiamo l'operatore spread per unire le proprietà
  // dell'oggetto restituito da mapState()
  // alle altre già presenti
  ...mapState({
    // ...
  })
}

Cosa sono le ‘Mutations’ in Vuex

Ricapitolando quanto illustrato finora, abbiamo visto come creare uno store Vuex ed abbiamo definito due proprietà dello state. Abbiamo poi mostrato come possiamo accedere a tali proprietà all’interno dei componenti App e CounterDisplay.

Vediamo ora come rendere l’applicazione interattiva e in che modo ottenere un nuovo numero casuale dal server locale o come incrementarlo e decrementarlo.

Seguendo i principi di Vuex, è possibile modificare lo stato solo eseguendo direttamente una Mutation o lanciando un’Action che provvede poi ad invocare una o più Mutations.

Vediamo allora come definire delle Mutations all’interno dello store. Riprendiamo il codice presente nel file store/index.js e modifichiamolo come mostrato sotto.

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

export default new Vuex.Store({
  state: {
    num: 0,
    loading: false
  },
  mutations: {
    SET_NUM(state, value) {
      state.num = value;
    },
    INCREMENT_NUM(state, increment) {
      state.num += increment;
    },
    DECREMENT_NUM(state, decrement) {
      state.num -= decrement;
    },
    SET_LOADING(state, value) {
      state.loading = value;
    }
  },
  actions: {},
  getters: {}
});

Le mutations. sono delle funzioni che ricevono come primo argomento l’intero oggetto state. Possono ricevere un secondo argomento. Nel nostro caso si tratta di un semplice valore. Se fosse necessario, potremmo però passare un oggetto di proprietà. Abbiamo volutamente usato la notazione SNAKE_CASE per il nome delle mutations.

Nell’esempio riportato sopra, abbiamo definito 4 funzioni:

  • SET_NUM() assegna un nuovo valore alla proprietà state.num
  • INCREMENT_NUM() incrementa il valore alla proprietà state.num di una certa quantità increment
  • DECREMENT_NUM() decrementa il valore di state.num
  • SET_LOADING assegna a state.loading un nuovo valore.

Le Mutations rappresentano l’unico modo per modificare direttamete le proprietà dello state.

Non vegono mai invocate diretttamente, ma sono eseguite attraverso la funzione commit() che può essere chiamata dai componenti o all’interno di un’Action.

Le Mutations devono essere sempre sincrone. Ciò significa che non possono contenere richieste ad eventuali REST API o funzioni come setTimeout ecc…

Un componente può invocare direttamete la funzione $store.commit() per modificare lo stato per operazioni sincrone. Tuttavia, se vogliamo scrivere del codice che sia più flessibile e preveda la possibilità di poter effettuare operazioni asincrone in futuro, andremo ad invocare una mutation soltanto all’interno delle Actions lasciando ai componenti il compito di lanciare quest’ultime.

Cosa sono le ‘Actions’ in Vuex

Le Actions sono delle funzioni definite nell’omonimo oggetto di configurazione dello store Vuex. Esistono per sopperire alle mancanze delle Mutations. All’interno di un Action è possibile infatti eseguire del codice asincrono.

Le Actions possono eseguire delle Mutations visto che hanno accesso alla funzione commit. Infatti il primo argomento passato ad un’Action, che viene per convenzione nominato context, espone alcune proprietà e metodi come context.commit e context.state.

Un’Action deve essere sempre invocata tramite la funzione dispatch che è disponibile anche nell’oggetto context. Ciò significa che un’Action può lanciarne un’altra.

Vediamo allora come aggiungere delle Actions allo store. Modifichiamo il file store/index.js come segue.

import Vue from 'vue';
import Vuex from 'vuex';

import { getRandomNumber } from '@/services/RandomNumberService.js';

Vue.use(Vuex);

export default new Vuex.Store({
  state: {
    num: 0,
    loading: false
  },
  mutations: {
    SET_NUM(state, value) {
      state.num = value;
    },
    INCREMENT_NUM(state, increment) {
      state.num += increment;
    },
    DECREMENT_NUM(state, decrement) {
      state.num -= decrement;
    },
    SET_LOADING(state, value) {
      state.loading = value;
    }
  },
  actions: {
    async getNewRandomValue({ commit }, { min = 0, max = 1 }) {
      let number;
      commit('SET_LOADING', true);
      try {
        const response = await getRandomNumber();
        number = response.number;
      } catch (error) {
        number = Math.random();
      } finally {
        number = Math.round(number * (max - min)) + min;
        commit('SET_NUM', number);
        commit('SET_LOADING', false);
      }
    },
    incrementValue({ commit }, increment) {
      commit('INCREMENT_NUM', increment);
    },
    decrementValue({ commit }, decrement) {
      commit('DECREMENT_NUM', decrement);
    }
  },
  getters: {}
});

Per ogni Action usiamo l’ assegnamento di destrutturazione per estrarre soltanto la funzione commit dall’oggetto context passato come primo argomento.

In incrementValue() e decrementValue() eseguiamo semplicemente le due Mutations per incrementare o decrementare di una certa quantità il valore corrente di state.num. L’incremento/decremento viene passato dall componente che lancia l’Action.

Al contrario getNewRandomValue() è più interessante. Il componente che la invoca, dovrà passare un oggetto con due proprietà min e max per indicare quale deve essere l’intervallo di valori in cui deve essere generato un numero casuale. Anche qui usiamo l’assegnamento di destrutturazione per prelevare min e max che poi utilizziamo nell’espressione number = Math.round(number * (max - min)) + min;.

In getNewRandomValue() eseguiamo la mutazione SET_LOADING che setta il valore di state.loading al valore true. Così facendo nel componente App verrà mostrato LoadingSpinner fin quando getNewRandomValue() non invoca nuovamente commit('SET_LOADING', false);.

Per semplificare la gestione delle Promise, abbiamo usato async/await. Nel blocco try/catch invochiamo la funzione getRandomNumber() importata dal modulo javascript RandomNumberService.js. Questa restituisce una promise che aspettiamo venga completata. Preleviamo poi il valore casuale fra 0 e 1 restituito dal server e lo usiamo per generare il numero desiderato.

Se si verifica un errore e la Promise viene respinta, nel blocco catch() generiamo un numero casuale direttamente senza l’aiuto del server.

In ogni caso, una volta completato questo processo, eseguiamo la mutation commit('SET_NUM', number); che assegna un nuovo valore a state.num.

Per completezza, vediamo il contenuto del modulo @/services/RandomNumberService.js che usa la funzione fetch() per effettuare una richiesta di tipo GET al server locale e restituisce una promise.

const { VUE_APP_REMOTE_ADDRESS: ADDRESS, VUE_APP_PORT: PORT } = process.env;

const url = `//${ADDRESS}:${PORT}/random`;

export function getRandomNumber() {
  return fetch(url, {
    method: 'GET',
    credentials: 'omit',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json'
    }
  }).then((response) => response.json());
}

Una volta definite le Actions, non ci resta che lanciarle dal componente CounterButtons.

<template>
  <div class="btn-container">
    <button
      :disabled="disabled"
      class="btn btn--rnd"
      title="Decrementa il valore"
      @click="decrement"
    >
      -
    </button>
    <button
      @click="generateNumber"
      class="btn btn--primary"
      title="Genera un nuovo numero casuale"
    >
      rigenera numero
    </button>
    <button
      class="btn btn--rnd"
      title="Incrementa il valore"
      @click="increment"
    >
      +
    </button>
  </div>
</template>

<script>
import { mapState } from 'vuex';

export default {
  name: 'CounterButtons',
  computed: mapState({
    disabled: (state) => state.num <= 0
  }),
  methods: {
    generateNumber() {
      this.$store.dispatch('getNewRandomValue', {
        min: 10,
        max: 500
      });
    },
    increment() {
      this.$store.dispatch('incrementValue', 1);
    },
    decrement() {
      this.$store.dispatch('decrementValue', 1);
    }
  }
};
</script>

Possiamo notare che sono presenti tre pulsanti ognuno dei quali registra un metodo da eseguire in risposta al click del mouse. Il primo pulsante usa inoltre una computed property che viene assegnata all’attributo disabled tramite la direttiva v-bind. Se il valore di state.num raggiunge il valore 0, il pulsante ‘-‘ viene disabilitato (Si tratta di una nostra scelta di progetto).

Quando clicchiamo sul pulsante ‘-‘ viene lanciata un’azione tramite this.$store.dispatch() passando il valore 1 come payload. In questo modo viene chiamata l’Action decrementValue che poi esegue la Mutation commit('DECREMENT_NUM', decrement); in cui decrement ha come valore 1.

Il pulsante ‘+’ si comporta in maniera simile, in questo caso lanciamo però un’Action per incrementare il valore di state.num.

Infine, se clicchiamo sul pulsante centrale, lanciamo l’Action getNewRandomValue() passando come payload un oggetto con le due proprietà min e max. In condizioni normali verrà quindi eseguita la richiesta al server, sarà generato un nuovo numero casuale che sarà poi assegnato a state.num. Non appena questo assume un nuovo valore, l’applicazione viene automaticamente aggiornata.

In modo simile a quanto visto con la funzione mapState(), Vuex fornisce mapActions che consente di semplificare ulteriormente il codice di un componente.

Vediamo allora come modificare il componente CounterButtons usando mapActions.

<template>
  <div class="btn-container">
    <button
      :disabled="disabled"
      class="btn btn--rnd"
      title="Decrementa il valore"
      @click="decrementValue(1)"
    >
      -
    </button>
    <button
      @click="getNewRandomValue(range)"
      class="btn btn--primary"
      title="Genera un nuovo numero casuale"
    >
      rigenera numero
    </button>
    <button
      class="btn btn--rnd"
      title="Incrementa il valore"
      @click="incrementValue(1)"
    >
      +
    </button>
  </div>
</template>

<script>
import { mapState, mapActions } from 'vuex';

export default {
  name: 'CounterButtons',
  data() {
    return {
      range: {
        min: 10,
        max: 500
      }
    };
  },
  computed: mapState({
    disabled: (state) => state.num <= 0
  }),
  methods: mapActions([
    'getNewRandomValue', 
    'incrementValue', 
    'decrementValue'
  ])
};
</script>
https://vimeo.com/462644293

Vuex e Vue Dev Tools

Vuex è stato ideato avendo il mente la possibilità di tenere traccia di tutte le Mutations eseguite. Per questo motivo in Vue devtools è presente un intero pannello dedicato a Vuex in cui ritroviamo l’intera cronologia delle Mutations.

screenshot del pannello vuex nell'estensione vue devtools

Vue devtools fornisce la possibilità di ‘navigare nel tempo’ e ripetere tutte le Mutations eseguite nel ciclo di vita di un’applicazione riportando il suo stato ad un determinato momento.

screenshot del pannello vuex nell'estensione vue devtools

Vuex Getters

Trattandosi di un esempio abbastanza semplice, non abbiamo avuto la necessità di definire dei Getters nello store Vuex.

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

export default new Vuex.Store({
  /* state, mutations, actions */
  getters: {
    numberOfEnglishQuotes(state, getters) {
      return getters.englishQuotes.length;
    }
  }
});

I Getters di uno store Vuex hanno lo stesso ruolo che ricoprono le Computed Properties nei componenti. Permettono infatti di ricavare delle nuove informazioni a partire da una certa proprietà dello state. Per esempio, se avessimo un array di dati, potremmo definire un certo Getter che filtra le informazioni dell’array in base a qualche parametro.

Ciascun getter riceve come primo argomento l’oggetto state e come secondo eventuali altri getters definiti nello store.

IN un componente i Getters sono disponibili all’interno dell’array this.$store.getters

computed: {
  numberOfEnglishQuotes () {
    return this.$store.getters.numberOfEnglishQuotes
  }
}

E come già visto per lo state e per le actions (vale anche per le Mutations grazie a mapMutations), all’interno di un componente possiamo utilizzare la funzione mapGetters per assegnare dei Getters dello store Vuex a delle Computed Properties.

Organizzare il codice Vuex tramite i moduli

L’esempio appena visto è piuttosto semplice e lo stato dell’applicazione è definito tramite due sole proprietà.

Al crescere della complessità, l’oggetto che definisce lo store Vuex rischia di diventare eccessivamente affollato e difficile da leggere.

Vuex permette però di migliorare l’organizzazione dello store attraverso i Moduli. Possiamo allora ristrutturare il nostro esempio creando all’interno della cartella store una directory modules con due file loading.js e randomNumber.js in cui andremo a definire due moduli separati per la gestione di loading e num.

tree store                                       
store
├── index.js
└── modules
    ├── loading.js
    └── randomNumber.js

1 directory, 3 files

Nel file index.js importiamo i due moduli Vuex apppena creati e li inseriamo nell’oggetto modules che usiamo per la configurazione dello store.

import Vue from 'vue';
import Vuex from 'vuex';

import loadingModule from '@/store/modules/loading.js';
import randomNumberModule from '@/store/modules/randomNumber.js';

Vue.use(Vuex);

export default new Vuex.Store({
  modules: {
    loadingModule,
    randomNumberModule
  }
});

Ciascun modulo può avere un proprio oggetto state, mutations, actions e getters o addirittura altri moduli.

All’interno del modulo, il primo argomento passato a Mutations e Getters è l’oggetto State locale del modulo stesso.

I Getters ricevono inoltre un terzo argomento, ovvero rootState che rappresenta lo stato base dello store e permette di accedere a proprietà all’esterno dello stato locale.

const myModule = {
  // ...
  getters: {
    getterExample (state, getters, rootState) {
      // ...
    }
  }
}

Per le Actions viene invece aggiunta una proprietà rootState all’oggetto context.

const myModule = {
  // ...
  getters: {
    actionExample (context) {
      // Possiamo accedere alle proprietà
      // context.rootState per lo stato base
      // context.state per lo stato locale
      // context.commit per lanciare una mutazione
    }
  }
}

Tornando al nostro esempio riportiamo di seguito il contenuto dei file loading.js e randomNumber.js

export default {
  state: () => ({
    loading: false
  }),
  mutations: {
    SET_LOADING(state, value) {
      state.loading = value;
    }
  }
};

Per la proprietà state abbiamo usato una funzione. Ciò permette di riutilizzare eventualmente il modulo in diversi store. Nel nostro caso avremmo potuto utilizzare anche un semplice oggetto, ma bisogna ricordare che se state è un oggetto, un’unica istanza viene condivisa fra più store.

import { getRandomNumber } from '@/services/RandomNumberService.js';

export default {
  state: () => ({
    num: 0
  }),
  mutations: {
    SET_NUM(state, value) {
      state.num = value;
    },
    INCREMENT_NUM(state, increment) {
      state.num += increment;
    },
    DECREMENT_NUM(state, decrement) {
      state.num -= decrement;
    }
  },
  actions: {
    async getNewRandomValue({ commit }, { min = 0, max = 1 }) {
      let number;
      commit('SET_LOADING', true);
      try {
        const response = await getRandomNumber();
        number = response.number;
      } catch (error) {
        number = Math.random();
      } finally {
        number = Math.round(number * (max - min)) + min;
        commit('SET_NUM', number);
        commit('SET_LOADING', false);
      }
    },
    incrementValue({ commit }, increment) {
      commit('INCREMENT_NUM', increment);
    },
    decrementValue({ commit }, decrement) {
      commit('DECREMENT_NUM', decrement);
    }
  }
};

Per il secondo modulo notiamo che eseguiamo direttamente la Mutation SET_LOADING dell’altro modulo.

Ciò è possibile perché, in presenza di moduli, il comportamento predefinito prevede di definire comunque Actions, Mutations e Getters nel namespace globale. Questo significa che se lanciamo una certa Action o eseguiamo una Mutation, tutte le Action o Mutation con quel nome verranno eseguite anche se sono all’interno di moduli diversi.

Dopo aver ristrutturato lo store Vuex suddividendolo in moduli, dovremo apportare delle modifiche ai componenti per poter accedere alle proprietà dello state. Infatti se prima la proprietà num era direttamente accessibile tramite state.num, ora farà parte del modulo randomNumberModule e dovremo quindi usare state.randomNumberModule.num.

Riportiamo allora di seguito il codice dei componenti aggiornati in cui abbiamo dovuto modificare solo le sezioni relative alla funzione mapState().

// file: App.vue
<template>
  <div id="app">
    <div class="counter-wrapper">
      <transition name="fade" mode="out-in">
        <LoadingSpinner v-if="isLoading" color="hsl(188, 58%, 62%)" />
        <CounterDisplay v-else />
      </transition>
    </div>
    <div class="btn-wrapper">
      <CounterButtons />
    </div>
  </div>
</template>

<script>
import { mapState } from 'vuex';
import CounterDisplay from '@/components/CounterDisplay.vue';
import CounterButtons from '@/components/CounterButtons.vue';
import LoadingSpinner from '@/components/LoadingSpinner.vue';

export default {
  name: 'App',
  components: {
    CounterDisplay,
    CounterButtons,
    LoadingSpinner
  },
  computed: mapState({
    // accediamo alla proprietà del modulo
    isLoading: (state) => state.loadingModule.loading
  })
};
</script>
<template>
  <span class="value-container">{{ num }}</span>
</template>

<script>
import { mapState } from 'vuex';

export default {
  name: 'CounterDisplay',
  computed: mapState({ num: (state) => state.randomNumberModule.num })
};
</script>
<template>
  <div class="btn-container">
    <button
      :disabled="disabled"
      class="btn btn--rnd"
      title="Decrementa il valore"
      @click="decrementValue(1)"
    >
      -
    </button>
    <button
      @click="getNewRandomValue(range)"
      class="btn btn--primary"
      title="Genera un nuovo numero casuale"
    >
      rigenera numero
    </button>
    <button
      class="btn btn--rnd"
      title="Incrementa il valore"
      @click="incrementValue(1)"
    >
      +
    </button>
  </div>
</template>

<script>
import { mapState, mapActions } from 'vuex';

export default {
  name: 'CounterButtons',
  data() {
    return {
      range: {
        min: 10,
        max: 500
      }
    };
  },
  computed: mapState({
    disabled: (state) => state.randomNumberModule.num <= 0
  }),
  methods: mapActions([
    'getNewRandomValue', 
    'incrementValue', 
    'decrementValue'
  ])
};
</script>

Notiamo che per il componente CounterButtons abbiamo dovuto solo modificare la sezione relativa alla funzione mapState(). Per quanto riguarda le Actions ricordiamo che hanno visibilità globale e quindi non è necessario apportare alcun cambiamento.

Namespacing

Abbiamo visto che il comportamento predefinito di Actions, Mutations e Getters definiti nei moduli prevede la loro registrazione nel namespace globale.

All’interno di un modulo possiamo però settare la proprietà namespaced: true restringendo la visibilità di Actions, Mutations e Getters. Così facendo, se vogliamo lanciare un’Action o eseguire una Mutation di un altro modulo, dovremo specificare con esattezza qual è il modulo in cui sono definiti.

Vediamo allora come aggiungere namespaced: true ai due moduli e quali modifiche sono necessarie nel resto dell’applicazione affinché tutto funzioni correttamente.

Il file store/index.js resta invariato. Dobbiamo invece aggiornare i moduli come riportato sotto. Per loadingModule basterà usare solo namespaced: true.

export default {
  namespaced: true,
  state: () => ({
    loading: false
  }),
  mutations: {
    SET_LOADING(state, value) {
      state.loading = value;
    }
  }
};

Per randomNumberModule dobbiamo prestare un po’ di attenzione perché in questo caso vogliamo eseguire una mutazione che fa parte di un modulo con opzione namespaced: true. Il nome completo della mutazione è quindi 'loadingModule/SET_LOADING'. Dobbiamo inoltre passare un terzo argomento { root: true } alla funzione commit() per indicare che deve cercare la mutazione a partire dal namespace globale.

import { getRandomNumber } from '@/services/RandomNumberService.js';

export default {
  namespaced: true,
  state: () => ({
    num: 0
  }),
  mutations: {
    SET_NUM(state, value) {
      state.num = value;
    },
    INCREMENT_NUM(state, increment) {
      state.num += increment;
    },
    DECREMENT_NUM(state, decrement) {
      state.num -= decrement;
    }
  },
  actions: {
    async getNewRandomValue({ commit }, { min = 0, max = 1 }) {
      let number;
      // per le mutazioni presenti in altri moduli
      // dobbiamo indicare il nome completo
      commit('loadingModule/SET_LOADING', true, { root: true });
      try {
        const response = await getRandomNumber();
        number = response.number;
      } catch (error) {
        number = Math.random();
      } finally {
        number = Math.round(number * (max - min)) + min;
        commit('SET_NUM', number);
        // per le mutazioni presenti in altri moduli
        // dobbiamo indicare il nome completo
        commit('loadingModule/SET_LOADING', false, { root: true });
      }
    },
    incrementValue({ commit }, increment) {
      commit('INCREMENT_NUM', increment);
    },
    decrementValue({ commit }, decrement) {
      commit('DECREMENT_NUM', decrement);
    }
  }
};

Non ci resta che modificare il componente CounterButton. Infatti le Actions non sono più registrate nel namespace globale. Per questo motivo dovremo specificare l’intero nome di ciascuna Action, ricordando di indicare il namespace completo.

<template>
  <div class="btn-container">
    <button
      :disabled="disabled"
      class="btn btn--rnd"
      title="Decrementa il valore"
      @click="decrementValue(1)"
    >
      -
    </button>
    <button
      @click="getNewRandomValue(range)"
      class="btn btn--primary"
      title="Genera un nuovo numero casuale"
    >
      rigenera numero
    </button>
    <button
      class="btn btn--rnd"
      title="Incrementa il valore"
      @click="incrementValue(1)"
    >
      +
    </button>
  </div>
</template>

<script>
import { mapState, mapActions } from 'vuex';

export default {
  name: 'CounterButtons',
  data() {
    return {
      range: {
        min: 10,
        max: 500
      }
    };
  },
  computed: mapState({
    disabled: (state) => state.randomNumberModule.num <= 0
  }),
  methods: mapActions('randomNumberModule', [
    'getNewRandomValue',
    'incrementValue',
    'decrementValue'
  ])
};
</script>

Alla funzione mapActions passiamo quindi come primo argomento il nome del modulo e come secondo argomento un array contenente il nome delle Actions.

La versione finale dell’applicazione è disponibile su Bitbucket.

Riepilogo

In questa lezione abbiamo parlato di Vuex che è un pattern (ed una libreria) inspirato da Redux. Rappresenta una soluzione affidabile per la gestione dello stato di un’applicazione. Abbiamo infatti visto quali difficoltà sorgono, al crescere del numero di componenti, per la comunicazione e la trasmissione dei dati in modo prevedibile e riproducibile.

In Vuex abbiamo un’entità principale costituita dallo Store che è compoosto da 4 parti fondamentali:

  • State che contiene i dati dell’applicazione disponibili in qualsiasi componente
  • Getters che si comportano come le computed properties e vengono automaticamente ricalcolati ogni volta che le proprietà dello store da cui dipendono subiscono una modifica. In questo modo possiamo ottenere delle informazioni derivate dalle proprietà base contenute nell’oggetto state dello Store
  • Mutations che rappresentano il metodo per effettuare delle modifiche ai dati dell’oggetto state. Vengono invocate attraverso la funzione commit() fornita dalla libreria. Devono essere sincrone.
  • Actions che permettono di eseguire delle operazioni asincrone per poi aggiornare i dati dello stato invocando delle Mutations.

Nella prossima lezione illustreremo un altro esempio più complesso che fa sempre uso di Vuex.

Pubblicitร