Alcuni trucchi per velocizzare l'applicazione JavaScript
Bartosz Slysz
Software Engineer
Con il progresso della tecnologia dei browser, le applicazioni web hanno iniziato a trasferire sempre più logica al front-end, alleggerendo così il server e riducendo il numero di operazioni che deve eseguire. Nelle CRUD di base, il ruolo del server si riduce all'autorizzazione, alla convalida, alla comunicazione con i database e alla logica aziendale richiesta. Il resto della logica dei dati può essere facilmente gestito dal codice responsabile della rappresentazione dell'applicazione sul lato dell'interfaccia utente.
In questo articolo, cercherò di mostrarvi alcuni esempi e schemi che ci aiuteranno a mantenere il nostro codice efficiente, ordinato e veloce.
Prima di addentrarci in esempi specifici, in questo articolo vorrei concentrarmi solo sui casi che, a mio parere, possono influenzare la velocità dell'applicazione in modo sorprendente. Tuttavia, questo non significa che l'uso di soluzioni più veloci sia la scelta migliore in ogni caso possibile. I suggerimenti che seguono vanno piuttosto considerati come qualcosa da prendere in considerazione quando la nostra applicazione funziona lentamente, ad esempio in prodotti che richiedono il rendering di giochi o grafici più avanzati sul canvas, operazioni video o attività che si desidera sincronizzare in tempo reale il prima possibile.
Prima di tutto, i metodi di Array.prototype
Basiamo gran parte della logica dell'applicazione sugli array: la loro mappatura, l'ordinamento, il filtraggio, la somma di elementi e così via. In modo semplice, trasparente e naturale, utilizziamo i loro metodi incorporati, che ci consentono semplicemente di eseguire vari tipi di calcoli, raggruppamenti e così via. Funzionano in modo simile in ogni istanza: come argomento si passa una funzione in cui, nella maggior parte dei casi, il valore dell'elemento, l'indice e l'array vengono spinti a turno durante ogni iterazione. La funzione specificata viene eseguita per ogni elemento dell'array e il risultato viene interpretato in modo diverso a seconda del metodo. Non mi soffermerò sui metodi di Array.prototype perché voglio concentrarmi sul motivo per cui viene eseguito lentamente in un gran numero di casi.
I metodi Array sono lenti perché eseguono una funzione per ogni elemento. Una funzione chiamata dal punto di vista del motore deve preparare una nuova chiamata, fornire l'ambito appropriato e molte altre dipendenze, il che rende il processo molto più lungo rispetto alla ripetizione di un blocco di codice specifico in un ambito specifico. Probabilmente queste conoscenze di base sono sufficienti per comprendere l'esempio seguente:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({valore: Math.random() }));
console.time('Somma per riduzione');
const reduceSum = randomArray
.map(({valore }) => valore)
.reduce((a, b) => a + b);
console.timeEnd('Somma per riduzione');
console.time('Somma per ciclo for');
let forSum = randomArray[0].value;
per (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Somma per ciclo for');
console.log(reduceSum === forSum);
})();
So che questo test non è affidabile come i benchmark (ci torneremo più avanti), ma fa scattare una spia. Per un caso a caso sul mio computer, risulta che il codice con il ciclo for può essere circa 50 volte più veloce se confrontato con la mappatura e la successiva riduzione degli elementi che ottengono lo stesso effetto! Si tratta di operare su qualche strano oggetto creato solo per raggiungere uno specifico obiettivo di calcolo. Quindi, creiamo qualcosa di più legittimo per essere obiettivi sui metodi Array:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({valore: Math.random() }));
console.time('Somma per riduzione');
const reduceSum = randomArray
.reduce((a, b) => ({ valore: a.valore + b.valore })).value
console.timeEnd('Somma per riduzione');
console.time('Somma per ciclo for');
let forSum = randomArray[0].value;
per (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Somma per ciclo for');
console.log(reduceSum === forSum);
})();
So che questo test non è affidabile come i benchmark (ci torneremo più avanti), ma fa scattare una spia. Per un caso a caso sul mio computer, è emerso che il codice con il ciclo for può essere circa 50 volte più veloce rispetto alla mappatura e alla successiva riduzione degli elementi che ottengono lo stesso effetto! In questo caso particolare, infatti, per ottenere la somma con il metodo reduce è necessario mappare l'array per i valori puri che vogliamo riassumere. Quindi, creiamo qualcosa di più legittimo per essere obiettivi sui metodi Array:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({valore: Math.random() }));
console.time('Somma per riduzione');
const reduceSum = randomArray
.reduce((a, b) => ({ valore: a.valore + b.valore })).value
console.timeEnd('Somma per riduzione');
console.time('Somma per ciclo for');
let forSum = randomArray[0].value;
per (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Somma per ciclo for');
console.log(reduceSum === forSum);
})();
E, a quanto pare, il nostro boost di 50x si è ridotto a un boost di 4x. Ci scusiamo se siete rimasti delusi! Per rimanere obiettivi fino in fondo, analizziamo nuovamente entrambi i codici. Innanzitutto, differenze apparentemente innocenti hanno raddoppiato il calo della nostra complessità computazionale teorica; invece di mappare e poi sommare elementi puri, operiamo ancora su oggetti e su un campo specifico, per poi estrarre la somma che ci interessa. Il problema sorge quando un altro programmatore dà un'occhiata al codice - allora, rispetto ai codici mostrati in precedenza, quest'ultimo perde a un certo punto la sua astrazione.
Questo perché dalla seconda operazione che operiamo su un oggetto strano, con il campo di nostro interesse e il secondo oggetto standard dell'array iterato. Non so cosa ne pensiate voi, ma dal mio punto di vista, nel secondo esempio di codice, la logica del ciclo for è molto più chiara e intenzionale di questa strana riduzione. E comunque, anche se non è più il mitico 50, è sempre 4 volte più veloce in termini di tempo di calcolo! Poiché ogni millisecondo è prezioso, la scelta in questo caso è semplice.
L'esempio più sorprendente
La seconda cosa che volevo confrontare riguarda il metodo Math.max o, più precisamente, il riempimento di un milione di elementi e l'estrazione dei più grandi e dei più piccoli. Ho preparato il codice, i metodi per misurare il tempo, poi ho avviato il codice e ho ricevuto un errore molto strano: la dimensione dello stack è stata superata. Ecco il codice:
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max con operatore di spread ES6');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max con operatore di spread ES6');
console.time('Math.max con ciclo for');
maxByFor = randomValues[0];
per (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max con ciclo for');
console.log(maxByFor === maxBySpread);
})();
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max con operatore di spread ES6');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max con operatore di spread ES6');
console.time('Math.max con ciclo for');
maxByFor = randomValues[0];
per (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max con ciclo for');
console.log(maxByFor === maxBySpread);
})();
Si scopre che i metodi nativi utilizzano la ricorsione, che in v8 è limitata dagli stack di chiamate e il suo numero dipende dall'ambiente. Questo è un aspetto che mi ha sorpreso molto, ma che porta a una conclusione: il metodo nativo è più veloce, a patto che il nostro array non superi un certo numero magico di elementi, che nel mio caso si è rivelato essere 125375. Per questo numero di elementi, il risultato era 5 volte più veloce rispetto al ciclo. Tuttavia, al di sopra del numero di elementi indicato, il ciclo for vince decisamente: a differenza dell'avversario, ci permette di ottenere risultati corretti.
Ricorsione
Il concetto che voglio menzionare in questo paragrafo è la ricorsione. Nell'esempio precedente, l'abbiamo vista nel metodo Math.max e nel ripiegamento degli argomenti, dove è emerso che è impossibile ottenere un risultato per le chiamate ricorsive che superano un numero specifico a causa della limitazione della dimensione dello stack.
Vedremo ora come si presenta la ricorsione nel contesto del codice scritto in JS, e non con i metodi incorporati. Forse la cosa più classica che possiamo mostrare qui è, naturalmente, trovare l'ennesimo termine della sequenza di Fibonacci. Quindi, scriviamola!
(() => {
const fiboIterative = (n) => {
lasciare [a, b] = [0, 1];
for (let i = 0; i {
if(n < 2) {
return n;
}
return fiboRecursive(n - 2) + fiboRecursive(n - 1);
};
console.time('Sequenza di Fibonacci tramite ciclo for');
const resultIterative = fiboIterative(30);
console.timeEnd('Sequenza di Fibonacci con ciclo for');
console.time('Sequenza di Fibonacci per ricorsione');
const resultRecursive = fiboRecursive(30);
console.timeEnd('Sequenza di Fibonacci per ricorsione');
console.log(resultRecursive === resultIterative);
})();
Ok, in questo caso particolare di calcolo del 30° elemento della sequenza sul mio computer, otteniamo il risultato in un tempo circa 200 volte inferiore con l'algoritmo iterativo.
Tuttavia, c'è una cosa che può essere corretta nell'algoritmo ricorsivo: si scopre che funziona in modo molto più efficiente quando si usa una tattica chiamata ricorsione di coda. Ciò significa che passiamo il risultato ottenuto nell'iterazione precedente come argomento per le chiamate più profonde. Questo ci permette di ridurre il numero di chiamate necessarie e, di conseguenza, di velocizzare il risultato. Correggiamo il nostro codice di conseguenza!
(() => {
const fiboIterative = (n) => {
lasciare [a, b] = [0, 1];
for (let i = 0; i {
if(n === 0) {
return first;
}
return fiboTailRecursive(n - 1, second, first + second);
};
console.time('Sequenza di Fibonacci tramite ciclo for');
const resultIterative = fiboIterative(30);
console.timeEnd('Sequenza di Fibonacci con ciclo for');
console.time('Sequenza di Fibonacci per ricorsione di coda');
const resultRecursive = fiboTailRecursive(30);
console.timeEnd('Sequenza di Fibonacci per ricorsione della coda');
console.log(resultRecursive === resultIterative);
})();
È successo qualcosa che non mi aspettavo: il risultato dell'algoritmo di ricorsione in coda è stato in grado di fornire il risultato (calcolo del trentesimo elemento di una sequenza) quasi due volte più velocemente dell'algoritmo iterativo in alcuni casi. Non sono del tutto sicuro se ciò sia dovuto all'ottimizzazione della ricorsione in coda da parte di v8 o alla mancanza di ottimizzazione del ciclo for per questo specifico numero di iterazioni, ma il risultato è inequivocabile: la ricorsione in coda vince.
Questo è strano perché, essenzialmente, il ciclo for impone un'astrazione molto minore sulle attività di calcolo di livello inferiore e si potrebbe dire che è più vicino alle operazioni di base del computer. Tuttavia, i risultati sono innegabili: la ricorsione, progettata in modo intelligente, si rivela più veloce dell'iterazione.
Usate le istruzioni di chiamata asincrona il più spesso possibile.
Vorrei dedicare l'ultimo paragrafo a un breve promemoria su un metodo di esecuzione delle operazioni che può influire notevolmente sulla velocità della nostra applicazione. Come è noto, JavaScript è un linguaggio a thread singolo che mantiene tutte le operazioni con il meccanismo dell'event-loop. Si tratta di un ciclo che si ripete in continuazione e tutti i passaggi di questo ciclo riguardano azioni specificate.
Per rendere questo ciclo veloce e consentire a tutti i cicli di attendere meno il proprio turno, tutti gli elementi devono essere il più possibile veloci. Evitate di eseguire operazioni lunghe sul thread principale: se qualcosa richiede troppo tempo, cercate di spostare questi calcoli nel WebWorker o di dividerli in parti da eseguire in modo asincrono. Questo può rallentare alcune operazioni, ma migliora l'intero ecosistema di JS, comprese le operazioni di IO, come la gestione dello spostamento del mouse o delle richieste HTTP in attesa.
Sintesi
In conclusione, come detto in precedenza, la ricerca di millisecondi che si possono risparmiare selezionando un algoritmo può rivelarsi insensata in alcuni casi. D'altra parte, trascurare questi aspetti in applicazioni che richiedono un funzionamento fluido e risultati rapidi può essere letale per l'applicazione. In alcuni casi, oltre alla velocità dell'algoritmo, occorre porsi un'altra domanda: l'astrazione è gestita al giusto livello? Il programmatore che legge il codice sarà in grado di comprenderlo senza problemi?
L'unico modo è garantire l'equilibrio tra prestazioni, facilità di implementazione e astrazione appropriata, ed essere sicuri che l'algoritmo funzioni correttamente sia per piccole che per grandi quantità di dati. Il modo per farlo è abbastanza semplice: essere intelligenti, considerare i diversi casi quando si progetta l'algoritmo e organizzarlo in modo che si comporti nel modo più efficiente possibile per le esecuzioni medie. Inoltre, è consigliabile progettare dei test: assicuratevi che l'algoritmo restituisca le informazioni appropriate per i diversi dati, indipendentemente dal modo in cui funziona. Occuparsi delle interfacce giuste, in modo che sia l'input che l'output dei metodi siano leggibili, chiari e riflettano esattamente ciò che stanno facendo.
Ho accennato in precedenza che tornerò sull'affidabilità della misurazione della velocità degli algoritmi negli esempi precedenti. Misurarli con console.time non è molto affidabile, ma riflette meglio il caso d'uso standard. In ogni caso, presento i benchmark qui di seguito - alcuni di essi appaiono un po' diversi rispetto a una singola esecuzione a causa del fatto che i benchmark ripetono semplicemente una determinata attività a un certo tempo e utilizzano l'ottimizzazione v8 per i loop.
https://jsben.ch/KhAqb - ridurre vs. ciclo for
https://jsben.ch/F4kLY - riduzione ottimizzata rispetto al ciclo for
https://jsben.ch/MCr6g - Math.max vs ciclo for
https://jsben.ch/A0CJB - fibo ricorsivo vs fibo iterativo