Nei precedenti articoli abbiamo introdotto Redux e abbiamo visto come usarlo all’interno delle applicazioni React grazie a React-redux. Al fine di spiegare in maniera più semplice possibile come funziona Redux, abbiamo volontariamente trascurato una funzionalità importante: i Middleware. Negli articoli precedenti abbiamo affermato che, attraverso la funzione store.dispatch(action), passiamo un’Action a quello che abbiamo chiamato rootReducer il quale, a sua volta, elabora il nuovo valore dell’oggetto State. In realtà, prima di raggiungere il reducer, un’action può essere intercettata e usata da una serie di funzioni dette Middleware.
Cosa sono i Redux Middleware
Il concetto di Middleware non è stato introdotto da Redux e sono diversi i framework e librerie che ne fanno uso, per esempio Express, un framework per realizzare applicazioni web con Node.js.
In Redux, i Middleware sono delle funzioni che, come già detto, possono intercettare le Action lanciate da store.dispatch(), prima che queste vengano passate al Reducer. In questo modo, i Middleware consentono di estendere le funzionalità dell’applicazione.
Tenendo quindi in considerazione l’uso di uno o più Middleware, lo schema di funzionamento di un’applicazione Redux può essere riassunto come nell’immagine seguente.
Come potete vedere dall’immagine, l’action lanciata da store.dispatch() attraversa una serie di Middleware. Per far in modo che l’Action venga inviata al Middleware successivo, o al Reducer nel caso dell’ultimo Middleware, bisogna invocare la funzione next(action) che viene passata da Redux a ciascun Middleware. Cerchiamo di capire meglio di cosa si tratta.
Vediamo intanto come creare una funzione Middleware. Per far ciò dovremo definire una funzione come la seguente.
// Redux richiede che i Middleware abbiano una particolare segnatura
// Middleware definito usando le arrow function
const mioMiddleware = store => next => action => {
// corpo della funzione Middleware
}
// o in maniera equivalente
var mioMiddleware = function mioMiddleware(store) {
return function (next) {
return function (action) {
// corpo della funzione Middleware
};
};
};
Redux richiede che le funzioni Middleware siano delle funzioni che ricevono come unico argomento un’oggetto store (in sostanza è una copia dell’oggetto store globale creato con la funzione createStore(), privato però del metodo subscribe()) e restituiscono un nuova funzione che riceve come argomento il riferimento a una funzione next. Questa è la funzione che dovremo invocare per passare l’Action al prossimo middleware della catena o al Reducer (nel caso dell’ultimo Middleware). Una volta che il Middleware ha usato l’Action o se non è interessato al tipo di Action ricevuta, viene invocata la funzione next(action). Infine, viene restituita una nuova funzione che riceve come unico argomento l’action che è stata precedentemente lanciata dalla funzione store.dispatch(action).
Per la definizione dei middleware vengono sfruttate tutte le potenzialità di Javascript che permette di passare un riferimento a una funzione come argomento di un’altra funzione che può a sua volta restituire una nuova funzione.
I Middleware, così definiti, sono un esempio di Curried Function ovvero funzioni ad applicazione parziale che restituiscono un’altra funzione.
Un esempio concettualmente simile ai Middleware è il seguente.
const moltiplica = (op1, op2) => op1 * op2;
const eseguiOperazione = primoOperando =>
operazione =>
secondoOperando => {
return operazione(primoOperando, secondoOperando);
}
/* O in maniera equivalente
*
*var eseguiOperazione = function eseguiOperazione(primoOperando) {
* return function (operazione) {
* return function (secondoOperando) {
* return operazione(primoOperando, secondoOperando);
* };
* };
*};
*
*/
const moltiplicaPer2 = eseguiOperazione(2)(moltiplica);
const risultato = moltiplicaPer2(4); // 8
Il motivo per cui un Middleware deve essere definito come abbiamo appena visto è dovuto al modo in cui viene usato all’interno della funzione applyMiddleware(). Questa è la funzione che permette di usare i Middleware in Redux. Quando creiamo lo store nelle nostre applicazioni con la funzione createStore(), passeremo la funzione restituita da applyMiddleware(…middlewares) come parametro. (ATTENZIONE: la funzione createStore vista negli articoli precedenti è una versione semplificata della funzione createStore reale e non tiene conto dei middleware)
Vediamo come è definita la funzione applyMiddleware. Prenderemo il codice della vera applyMiddleware da Github e lo semplificheremo leggermente.
import compose from './compose'
export default function applyMiddleware(...middlewares) {
return (createStore) => (rootReducer, initialState) => {
const store = createStore(rootReducer, initialState);
let dispatch = store.dispatch;
let chain = [];
const middlewareAPI = {
getState: store.getState,
dispatch: (action) => dispatch(action)
};
chain = middlewares.map(middleware => middleware(middlewareAPI));
dispatch = compose(...chain, store.dispatch);
return {
...store,
dispatch
};
}
}
Dall’analisi della funzione applyMiddleware, possiamo osservare che dovremo passare i Middleware come argomento della funzione applyMiddleware.
// supponiamo di aver definito tre middleware come visto sopra
applyMiddleware(middleware1, middleware2, middleware3);
Alla funzione restituita da applyMiddleware verrà passato come argomento il riferimento alla funzione createStore che come abbiamo visto è una funzione che accetta due parametri (trascuriamo per semplicità un terzo parametro che può essere passato alla funzione createStore). Ricordiamo che la segnatura della funzione createStore è la seguente.
function createStore(rootReducer, initialState){
//...
}
La funzione che ha ricevuto un riferimento alla funzione createStore, restituisce una nuova funzione, avente gli stessi parametri della funzione createStore, che sarà la nuova funzione createStore. All’interno di questa funzione, per prima cosa viene creato l’oggetto store usando la funzione createStore originale e viene salvato un riferimento alla funzione store.dispatch all’interno di una variabile dispatch. La viariabile middlewareAPI contiene un oggetto simile all’oggetto store ma privato della funzione subscribe. I metodi definiti in questo oggetto provvederanno a invocare gli omonimi metodi dell’oggetto store.
A questo punto, ad ogni middleware verrà passato come argomento l’oggetto middlewareAPI (ricordate che i middleware ricevono un argomento store) e verranno salvate in un array le funzioni restituite dall’invocazione della funzione più esterna del middleware. La variabile chain sarà dunque un array come il seguente.
// supponiamo di avere 3 middleware
/* nel seguito dell'articolo faremo riferimento
* alle funzioni dell'array 'chain'
* con i nomi fn1, fn2, fn3
*/
chain = [
function (next) { // fn1
return function (action) {
// corpo della funzione Middleware 1
};
},
function (next) { // fn2
return function (action) {
// corpo della funzione Middleware 2
};
},
function (next) { // fn3
return function (action) {
// corpo della funzione Middleware 3
};
}
]
Le funzioni all’interno dell’array chain vengono passate come argomento della funzione compose usando l’operatore spread.
dispatch = compose(...chain, store.dispatch);
Usando l’operatore spread passeremo ogni elemento dell’array chain come argomento della funzione compose. Per cui possiamo scrivere in maniera equivalente.
dispatch = compose(fn1, fn2, fn3, store.dispatch);
La funzione compose è definita in Redux e permette di comporre due funzioni così come avverrebbe in matematica. Supponiamo di avere due funzioni f(x) g(y). La funzione compose() sarà simile a quella mostrata qui sotto.
function f(x) {
return x;
}
function g(y) {
return y * y;
}
function compose(f, g) {
return function(z) {
return f(g(z))
}
}
const h = compose(f,g);
h(2) // 4
Gli argomenti della funzione compose devono essere delle funzioni ognuna delle quali deve ricevere un solo argomento ad eccezione dell’ultima funzione a cui possono essere passati più argomenti. Il valore restituito da ogni funzione verrà passato come argomento alla funzione alla sua sinistra nell’elenco degli argomenti della funzione compose.
Per cui la variabile dispatch conterrà un riferimento a una funzione come la seguente.
dispatch = (...args) => fn1(fn2(fn3(store.dispatch(...args))));
Ora dovrebbe essere più chiaro come fa Redux a passare un’Action da un middleware all’altro e poi al rootReducer. In ogni middleware invochiamo la funzione next(action) che in pratica invoca quella che era la funzione più interna del middleware (function(action){// corpo della funzione Middleware}), tranne nel caso dell’ultimo middleware dell’array chain in cui la chiamata alla funzione next(action) si traduce in una chiamata alla funzione store.dispatch(action).
Alla fine della funzione applyMiddleware, restituiamo quello che sarà il nuovo oggetto store avente tutte le proprietà del vecchio oggetto store (notate che abbiamo usato l’operatore spread (…)). È stata però sostituita la funzione dispatch originale con la nuova funzione dispatch ottenuta per composizione.
Come usare un middleware
Ricapitoliamo quindi come usare i middleware in Redux.
Definire la funzione middleware
Per prima cosa dobbiamo definire la funzione middleware che dovrà avere una precisa segnatura.
const mioMiddleware = store => next => action => {
// corpo della funzione Middleware
}
All’interno del middleware abbiamo accesso all’Action lanciata attraverso store.dispatch(action). Possiamo usere la funzione next(action) per far scorrere l’Action lungo la catena dei middleware. Eseguendo la funzione next(action) all’interno del nostro middleware, verrà invocato il prossimo middleware. È importante sottolineare che se all’interno del middleware non viene invocata la funzione next(action), l’Action non viene passata al prossimo middleware. Tutti i middleware successivi al middleware corrente non verranno invocati. A volte può risultare utile bloccare un’action, in questi casi non chiameremo la funzione next(). All’interno di ogni middleware abbiamo però accesso anche ai metodi store.getState() per recuperare l’oggetto State corrente e store.dispatch() che è la funzione dispatch() originale (in realtà è la funzione dispatch dell’oggetto middlewareAPI che comunque si limita ad invocare store.dispatch(action)). Utilizzando questa funzione, potremo far ripercorrere all’oggetto action tutta la catena dei middleware già attraversati prima del middleware corrente.
Per quanto possibile, quando si definisce un middleware, bisogna farlo in modo che questo non dipenda dal preciso ordine in cui viene invocato.
Invocare la funzione applyMiddleware()
Dopo aver definito uno o più middleware, invocheremo la funzione applyMiddleware(…middleware) a cui passeremo come argomento i diversi middleware creati. Passeremo la funzione restituita da applyMiddleware alla funzione createStore. (ATTENZIONE: la funzione createStore vista negli articoli precedenti è una versione semplificata della funzione createStore reale e non tiene conto dei middleware)
const store = createStore(reducer, initialState, applyMiddleware(...middleware));
In alternativa possiamo, in maniera equivalente, passare la funzione createStore alla funzione restituita da applyMiddleware(…middleware).
const store = applyMiddleware(...middleware)(createStore)(reducer, initialState)
Un semplice esempio di middleware
Vediamo infine un semplicissimo esempio di middleware, presenteremo qualche altro esempio nel prossimo articolo.
Creiamo un middleware che si limita a stampare l’Action ricevuta nella console degli strumenti per sviluppatori.
// file: middlewares/index.js
export const simpleLogger = store => next => action => {
console.group(action.type);
console.log('Action lanciata', action);
console.log('Oggetto state', store.getState);
console.groupEnd(action.type);
return next(action);
}
Riprendendo l’esempio visto nell’articolo precedente, inseriamo il codice del middleware in un file index.js all’interno di una cartella middlewares. Modifichiamo poi il file src/index.js.
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import rootReducer from './reducers';
// importiamo i middleware e altre funzioni necessarie da Redux
import { createStore, applyMiddleware, compose } from 'redux';
import { simpleLogger, noRemove } from './middlewares';
import App from './App';
const initialState = {
currentDriver: "",
drivers: {
"Sebastian Vettel": {
Team: "Ferrari",
Country: "Germany",
Podiums: 89,
Points: 2176,
'World Championships': 4
},
"Lewis Hamilton": {
Team: "Mercedes",
Country: "United Kingdom",
Podiums: 107,
Points: 2308,
'World Championships': 3
},
"Max Verstappen": {
Team: "Red Bull",
Country: "Netherlands",
Podiums: 8,
Points: 278,
'World Championships': 0
}
}
}
// applichiamo i middleware e usiamo Redux DevTools Extension
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
rootReducer,
initialState,
composeEnhancers(applyMiddleware(simpleLogger, noRemove))
);
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
Seguendo le istruzioni presenti sulla documentazione di Redux DevTools Extension, abbiamo applicato i middleware. Se non avessimo usato Redux DevTools Extension, avremmo potuto semplicemente usare il codice seguente per la chiamata alla funzione createStore.
const store = createStore(
rootReducer,
initialState,
applyMiddleware(simpleLogger)
);
Oppure avremmo potuto invocare applyMiddleware al posto di createStore nel seguente modo.
const store = applyMiddleware(simpleLogger)(createStore)(rootReducer, initialState);
A questo punto, ogni volta che lanciamo un’Action, verrà stampato un messaggio nella console per sviluppatori.
Notate che abbiamo aggiunto anche il middleware noRemove, il codice di questo middleware è il seguente.
export const noRemove = store => next => action => {
if (action.type === REMOVE_DRIVER) {
console.group(action.type);
console.log('%c Non è più permesso rimuovere un pilota',
"color: white; background-color: red"
);
console.groupEnd(action.type);
return action;
}
return next(action);
}
Se proviamo a rimuovere un pilota, noRemove intercetta l’action, stampa un messaggio di errore e non permette che l’action prosegua il suo cammino verso il rootReducer. Nel caso action.type sia uguale a REMOVE_DRIVER, l’action viene bloccata perché non invochiamo la funzione next(action) ma ci limitiamo a restituire l’oggetto action al middleware che precede noRemove, ovvero simpleLogger.
In conseguenza dell’introduzione di questo middleware, al tentativo di rimozione di un pilota, verrà mostrato un messaggio nella console per sviluppatori e nessun pilota verrà rimosso dalla lista.
Notate che nella console vengono stampati i messaggi di entrambi i middleware visto che simpleLogger precede noRemove.
Nel prossimo articolo vedremo come usare i middleware nel caso di Action asincrone.