Le ottimizzazioni di performance portano SEMPRE un costo, ma non necessariamente dei benefici. Parliamo di costi e benefici di useMemo e useCallback.
@June 4, 2019 • 11 min read
Questo articolo rappresenta la traduzione in italiano del post originale When to useMemo and useCallback di Kent C. Dodds
Ecco un dispenser di caramelle implementato in React:
function CandyDispenser() {
const initialCandies = ['snickers', 'skittles', 'twix', 'milky way'];
const [candies, setCandies] = React.useState(initialCandies);
const dispense = (candy) => {
setCandies((allCandies) => allCandies.filter((c) => c !== candy));
};
return (
<div>
<h1>Candy Dispenser</h1>
<div>
<div>Available Candy</div>
{candies.length === 0 ? (
<button onClick={() => setCandies(initialCandies)}>refill</button>
) : (
<ul>
{candies.map((candy) => (
<li key={candy}>
<button onClick={() => dispense(candy)}>grab</button> {candy}
</li>
))}
</ul>
)}
</div>
</div>
);
}
Ora, voglio fare una domanda e vorrei che rifletteste bene sulla risposta prima di andare avanti. Sto per apportare un piccolo cambiamento e voglio che proviate a capire quale codice sia più performante.
Il cambiamento è un wrap della funzione dispense
all'interno di React.useCallback
:
const dispense = React.useCallback((candy) => {
setCandies((allCandies) => allCandies.filter((c) => c !== candy));
}, []);
Ecco di nuovo l'originale:
const dispense = (candy) => {
setCandies((allCandies) => allCandies.filter((c) => c !== candy));
};
La domanda è: qual è più performante?
Lascio un po' di spazio per non fare spoiler sulla risposta...
Continua a scrollare... Hai ormai risposto, giusto?
Ecco la risposta...
Perché useCallback
ha performance peggiori?!
Si legge spesso che l'uso di React.useCallback
migliora le performance e che "le funzioni inline possono essere problematiche per le performance", quindi perché è meglio non usare useCallback
?
Facciamo un passo indietro dall'esempio proposto e da React in generale.
Consideriamo che: Ogni linea di codice che viene eseguita ha un costo. Proviamo ad esplodere l'esempio che usa useCallback
al fine di illustrare meglio le istruzioni che vengono eseguite (senza cambiare il comportamento):
const dispense = (candy) => {
setCandies((allCandies) => allCandies.filter((c) => c !== candy));
};
const dispenseCallback = React.useCallback(dispense, []);
Di nuovo l'originale:
const dispense = (candy) => {
setCandies((allCandies) => allCandies.filter((c) => c !== candy));
};
Notate qualcosa di strano? Guardiamo la diff:
const dispense = (candy) => {
setCandies((allCandies) => allCandies.filter((c) => c !== candy));
};
+ const dispenseCallback = React.useCallback(dispense, []);
Sono esattamente le stesse a part che la versione con useCallback
esegue più istruzioni. Non solo la definizione della funzione, ma anche la definizione di un array ([]
) e la chiamata a React.useCallback
che sta a sua volta settando properties ed eseguendo altre istruzioni etc.
In entrambi i casi JavaScript deve allocare memoria per la definizione della funzione ad ogni render e in base a come useCallback
è implementata, si ottiene altra allocazione per le definizioni delle funzioni (non è questo il caso, ma il punto resta valido).
Questo è stato anche oggetto di un sondaggio su Twitter da parte di Kent
Altra menzione è il fatto che nel secondo render del componente, la funzione dispense
originale viene garbage collected (liberando spazio in memoria) ed un'altra viene creata. Invece, con utilizzo di useCallback
la funzione dispense
originale non viene garbage collected ed un'altra viene creata, quindi anche dal punto di vista dello spazio di memoria utilizzato le performance sono peggiori.
TODO As a related note, if you have dependencies then it's quite possible React is hanging on to a reference to previous functions because memoization typically means that we keep copies of old values to return in the event we get the same dependencies as given previously. The especially astute of you will notice that this means React also has to hang on to a reference to the dependencies for this equality check (which incidentally is probably happening anyway thanks to your closure, but it's something worth mentioning anyway).
In che modo useMemo
è differente, ma allo stesso tempo simile?
useMemo
è simile useCallback
tranne per il fatto che applica la memoization a qualsiasi tipo (non solo funzioni). Lo permette accettando una funzione che ritorna il valore e in seguito la funzione viene richiamata solo quando il valore deve essere recuperato: tipicamente succede una volta sola per ogni cambio degli elementi dell'array delle dipendenze tra un render e l'altro.
Quindi, se non volessi inizializzare initialCandies
ad ogni render, potrei fare così:
- const initialCandies = ['snickers', 'skittles', 'twix', 'milky way'];
+ const initialCandies = React.useMemo(
+ () => ['snickers', 'skittles', 'twix', 'milky way'],
+ []
+ );
E mi eviterei il problema, ma il risparmio sarebbe così minimo da non giustificare l'aggiunta di complessità al codice. Di fatti, è probabilmente peggio usare useMemo
perché nuovamente stiamo chiamando una funzione che a sua volta sta assegnando properties etc.
In questo caso particolare, la cosa migliore è probabilmente questa:
+ const initialCandies = ['snickers', 'skittles', 'twix', 'milky way']
function CandyDispenser() {
- const initialCandies = ['snickers', 'skittles', 'twix', 'milky way']
const [candies, setCandies] = React.useState(initialCandies)
Ma non sempre si ha questa fortuna perché il valore è derivato dalle props
o da altre variabili inizializzate nel body della funzione.
Il punto resta comunque che i benefici di ottimizzare quel pezzo di codice sono talmente pochi che è meglio investire il tempo nel creare un prodotto migliore.
Cosa mi porto a casa?
Le ottimizzazioni di performance non sono mai gratuite. Hanno SEMPRE un costo, ma il beneficio apportato NON sempre lo ripaga.
Perciò, ottimizza responsabilmente.
Quindi quando dovrei usare useMemo
e useCallback
?
Ci sono ragione specifiche per cui questi hooks sono built-in in React:
- Uguaglianza referenziale
- Calcoli computazionalmente pesanti
Uguaglianza referenziale
Se siete nuovi alla programmazione/Javascript, imparerete velocemente che non tutti i tipi primitivi si comportano allo stesso modo quando si applica l'operatore di uguaglianza:
// EXPECTED
true === true // true
false === false // true
1 === 1 // true
'a' === 'a' // true
// UNEXPECTED(?)
{} === {} // false
[] === [] // false
() => {} === () => {} // false
const z = {}
z === z // true
// NOTE: React in realtà usa Object.is, ma è molto simile a ===
Senza scendere troppo nei dettagli, basta ricordare che ogni volta che si definisce un oggetto dentro un Function Component React, questo non sarà referenzialmente uguale all'ultima volta in cui lo stesso oggetto è stato definito, anche se ha le stesse properties con gli stessi valori.
Ci sono due casi in cui l'uguaglianza referenziale ha impatti in React, vediamoli uno alla volta.
Liste di dipendenze
Proviamo a partire da un esempio.
Attenzione, non fate troppo caso al codice che è volutamente complesso. Concentratevi sui concetti.
function Foo({ bar, baz }) {
const options = { bar, baz };
React.useEffect(() => {
buzz(options);
}, [options]); // dobbiamo eseguire nuovamente questo effetto se bar o baz cambiano
return <div>foobar</div>;
}
function Blub() {
return <Foo bar="bar value" baz={3} />;
}
Il problema sta nel fatto che useEffect
applica un controllo di uguaglianza referenziale su options
ad ogni render e, per come funziona Javascript, options
risulterà sempre diverso tra i renders, invocando useEffect
dopo ogni render invece che esclusivamente al cambio di bar
o baz
.
Due modi per sistemare questo comportamento:
// opzione 1
function Foo({ bar, baz }) {
React.useEffect(() => {
const options = { bar, baz };
buzz(options);
}, [bar, baz]); // uso le singole props invece dell' oggetto combinato
return <div>foobar</div>;
}
Questa è l'opzione migliore da applicare ai casi reali.
Ma esiste una situazione che rende questo approccio impraticabile: se bar
o baz
sono tipi non primitivi come oggetti/array/funzioni/etc:
function Blub() {
const bar = () => {};
const baz = [1, 2, 3];
return <Foo bar={bar} baz={baz} />;
}
Questo è il motivo per cui esistono useCallback
e useMemo
.
Quindi ecco l'opzione 2 che li utilizza:
// opzione 2
function Foo({ bar, baz }) {
React.useEffect(() => {
const options = { bar, baz };
buzz(options);
}, [bar, baz]);
return <div>foobar</div>;
}
function Blub() {
const bar = React.useCallback(() => {}, []);
const baz = React.useMemo(() => [1, 2, 3], []);
return <Foo bar={bar} baz={baz} />;
}
NOTA: Lo stesso si applica per l'array di dipendenze passato a useEffect, useLayoutEffect, useCallback e useMemo.
React.memo
(e i suoi amici)
Attenzione, non fate troppo caso al codice che è volutamente complesso. Concentratevi sui concetti.
Prendiamo questo:
function CountButton({ onClick, count }) {
return <button onClick={onClick}>{count}</button>;
}
function DualCounter() {
const [count1, setCount1] = React.useState(0);
const increment1 = () => setCount1((c) => c + 1);
const [count2, setCount2] = React.useState(0);
const increment2 = () => setCount2((c) => c + 1);
return (
<>
<CountButton count={count1} onClick={increment1} />
<CountButton count={count2} onClick={increment2} />
</>
);
}
Ogni volta che clicchi uno qualsiasi tra i due pulsanti, lo stato di DualCounter
cambia e quindi viene eseguito un re-render che si applica ad entrambi i CountButton
. Però, l'unico che davvero ha bisogno del re-render è quello cliccato, giusto? Quindi se clicchi il primo, il secondo viene re-renderizzato, anche se nulla è cambiato.
Questo si chiama "re-rendering non necessario".
LA MAGGIOR PARTE DELLE VOLTE NON DOVRESTI PREOCCUPARTI DI OTTIMIZZARE I RE-RENDER NON NECESSARI. React è SUPER veloce e perderesti solo tempo ad ottimizzare queste cose. Anche Kent spiega come nei suoi 3 anni di lavoro in PayPal non ha mai dovuto applicare ottimizzazioni simili.
Ma ci sono situazioni per cui un rendering può impiegare molto tempo (considera Grafici/Animazioni molto interattive). Grazie alla natura di React esiste un escamotage:
const CountButton = React.memo(function CountButton({ onClick, count }) {
return <button onClick={onClick}>{count}</button>;
});
Ora CountButton
verrà re-renderizzato solo se le sue props cambiano! Ma non abbiamo finito.
Ricordate il discorso sull'uguaglianza referenziale? Nel componente DualCounter
, le funzioni increment1
e increment2
sono definite all'interno del Function Component portando a crearle nuovamente ad ogni re-render di DualCounter
. Questo significa che React farà re-rendering dei CountButton
in ogni caso.
Questo è l'altro caso in cui useCallback
e useMemo
sono d'aiuto:
const CountButton = React.memo(function CountButton({ onClick, count }) {
return <button onClick={onClick}>{count}</button>;
});
function DualCounter() {
const [count1, setCount1] = React.useState(0);
- const increment1 = () => setCount1((c) => c + 1);
+ const increment1 = React.useCallback(() => setCount1((c) => c + 1), []);
const [count2, setCount2] = React.useState(0);
- const increment2 = React.useCallback(() => setCount2((c) => c + 1), []);
+ const increment2 = React.useCallback(() => setCount1((c) => c + 1), []);
return (
<>
<CountButton count={count1} onClick={increment1} />
<CountButton count={count2} onClick={increment2} />
</>
);
}
In questo modo evitiamo il "re-rendering non necessario" di CountButton
.
Voglio reiterare sul fatto che consiglio vivamente di non usare React.memo
(o i suoi amici PureComponent
e shouldComponentUpdate
) senza aver prima misurato un problema di performance. Queste ottimizzazioni portano un costo, dovete quindi valutare opportunamente il beneficio conseguente. Il rischio è di fare più danni che altro.
Calcoli computazionalmente pesanti
Questa è un'altra ragione per cui useMemo
è un hook built-in di React (questa cosa non si applica a useCallback
). Il vantaggio di useMemo
è evidente nel momento in cui hai una funzione sincrona che esegue dei calcoli computazionalmente costosi.
Un esempio, anche se ovviamente poco comune, è il calcolo dei numeri primi:
function RenderPrimes({ iterations, multiplier }) {
const primes = calculatePrimes(iterations, multiplier);
return <div>Primes! {primes}</div>;
}
Passando grandi valori di iterations
o multiplier
si rischia di mandare l'utilizzo CPU alle stelle. Ovviamente non possiamo agire sull'hardware che sta eseguendo la nostra funzione, ma possiamo fare in modo che a parità di parametri, il calcolo effettivo verrà eseguito solo la prima volta, mentre le successive esecuzioni ritorneranno il valore "memoizzato". No, non ho dimenticato una "r", memoizzare (in inglese memoization) è una tecnica che esiste da sempre in programmazione e fin dai tempi di lodash in Javascript.
function RenderPrimes({ iterations, multiplier }) {
const primes = React.useMemo(() => calculatePrimes(iterations, multiplier), [iterations, multiplier]);
return <div>Primes! {primes}</div>;
}
In questo modo anche se stiamo calcolando i numeri primi ad ogni render, useMemo
fa in modo di essere super veloci nel caso in cui i valori di iterator
e multiplier
sono già stati passati almeno una volta.
Conclusioni
Ricorda sempre che ogni astrazione ed ottimizzazione di performance hanno un costo. Non over-astrarre e non over-ottimizzare, non prematuramente almeno o non finchè non hai misurato essere necessario.
Per non parlare la complessità in lettura del codice aggiunta da useMemo
e useCallback
: i tuoi colleghi non gradiranno!
Letture collegate: