Quelques astuces pour accélérer votre application JavaScript
Bartosz Slysz
Software Engineer
Avec les progrès de la technologie des navigateurs, les applications web ont commencé à transférer de plus en plus de logique vers le front-end, soulageant ainsi le serveur et réduisant le nombre d'opérations qu'il doit effectuer. Dans les applications CRUD de base, le rôle du serveur se résume à l'autorisation, à la validation, à la communication avec les bases de données et à la logique commerciale requise. Le reste de la logique des données peut être facilement géré par le code responsable de la représentation de l'application du côté de l'interface utilisateur.
Dans cet article, j'essaierai de vous montrer quelques exemples et modèles qui vous aideront à garder nos code efficace, soigné et rapide.
Avant d'aller plus loin dans les exemples spécifiques - dans cet article, je voudrais me concentrer uniquement sur des exemples qui, à mon avis, peuvent affecter la vitesse de l'application d'une manière surprenante. Toutefois, cela ne signifie pas que l'utilisation de solutions plus rapides est le meilleur choix dans tous les cas possibles. Les conseils ci-dessous devraient plutôt être considérés comme des éléments à prendre en compte lorsque notre application fonctionne lentement, par exemple dans les produits qui nécessitent un rendu de jeu ou des graphiques plus avancés sur le canevas, des opérations vidéo ou des activités que vous souhaitez synchroniser en temps réel dès que possible.
Tout d'abord, les méthodes Array.prototype
Nous basons une grande partie de la logique de l'application sur les tableaux - leur mise en correspondance, leur tri, leur filtrage, la somme des éléments, etc. De manière simple, transparente et naturelle, nous utilisons leurs méthodes intégrées qui nous permettent simplement d'effectuer divers types de calculs, de regroupements, etc. Elles fonctionnent de manière similaire dans chaque cas - en tant qu'argument, nous passons une fonction où, dans la plupart des cas, la valeur de l'élément, l'index et le tableau sont poussés à tour de rôle au cours de chaque itération. La fonction spécifiée est exécutée pour chaque élément du tableau et le résultat est interprété différemment selon la méthode. Je ne m'étendrai pas sur les méthodes de Array.prototype car je veux me concentrer sur la raison pour laquelle il fonctionne lentement dans un grand nombre de cas.
Les méthodes de tableau sont lentes car elles exécutent une fonction pour chaque élément. Une fonction appelée du point de vue du moteur doit préparer un nouvel appel, fournir la portée appropriée et un grand nombre d'autres dépendances, ce qui rend le processus beaucoup plus long que la répétition d'un bloc de code spécifique dans une portée spécifique. Ceci est probablement suffisant pour nous permettre de comprendre l'exemple suivant :
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value : Math.random() })) ;
console.time('Somme par réduction') ;
const reduceSum = randomArray
.map(({ valeur }) => valeur)
.reduce((a, b)) => a + b) ;
console.timeEnd('Somme par réduction') ;
console.time('Somme par la boucle for') ;
let forSum = randomArray[0].value ;
for (let index = 1 ; index < randomArray.length ; index++) {
forSum += randomArray[index].value ;
}
console.timeEnd('Somme par boucle for') ;
console.log(reduceSum === forSum) ;
})() ;
Je sais que ce test n'est pas aussi fiable que les benchmarks (nous y reviendrons plus tard), mais il déclenche un signal d'alarme. Pour un cas aléatoire sur mon ordinateur, il s'avère que le code avec la boucle for peut être environ 50 fois plus rapide si on le compare au mappage puis à la réduction d'éléments qui permettent d'obtenir le même effet ! Il s'agit d'opérer sur un objet étrange créé uniquement pour atteindre un objectif de calcul spécifique. Créons donc quelque chose de plus légitime pour être objectif à propos des méthodes Array :
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value : Math.random() })) ;
console.time('Somme par réduction') ;
const reduceSum = randomArray
.reduce((a, b) => ({ value : a.value + b.value })).value
console.timeEnd('Somme par réduction') ;
console.time('Somme par la boucle for') ;
let forSum = randomArray[0].value ;
for (let index = 1 ; index < randomArray.length ; index++) {
forSum += randomArray[index].value ;
}
console.timeEnd('Somme par boucle for') ;
console.log(reduceSum === forSum) ;
})() ;
Je sais que ce test n'est pas aussi fiable que les benchmarks (nous y reviendrons plus tard), mais il déclenche un signal d'alarme. Pour un cas aléatoire sur mon ordinateur, il s'avère que le code avec la boucle for peut être environ 50 fois plus rapide si on le compare au mappage puis à la réduction des éléments qui permettent d'obtenir le même effet ! En effet, pour obtenir la somme dans ce cas particulier à l'aide de la méthode de réduction, il faut mapper le tableau pour les valeurs pures que nous voulons résumer. Créons donc quelque chose de plus légitime pour être objectif à propos des méthodes Array :
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value : Math.random() })) ;
console.time('Somme par réduction') ;
const reduceSum = randomArray
.reduce((a, b) => ({ value : a.value + b.value })).value
console.timeEnd('Somme par réduction') ;
console.time('Somme par la boucle for') ;
let forSum = randomArray[0].value ;
for (let index = 1 ; index < randomArray.length ; index++) {
forSum += randomArray[index].value ;
}
console.timeEnd('Somme par boucle for') ;
console.log(reduceSum === forSum) ;
})() ;
Et il s'avère que l'augmentation de 50x est tombée à 4x. Je vous prie de m'excuser si vous êtes déçu ! Pour rester objectif jusqu'au bout, analysons à nouveau les deux codes. Tout d'abord, des différences d'apparence anodine ont doublé la baisse de notre complexité informatique théorique ; au lieu de mettre en correspondance puis d'additionner des éléments purs, nous opérons toujours sur des objets et un champ spécifique, pour finalement les extraire et obtenir la somme qui nous intéresse. Le problème survient lorsqu'un autre programmeur jette un coup d'œil au code - alors, comparé aux codes présentés plus haut, ce dernier perd son abstraction à un moment donné.
C'est parce que depuis la deuxième opération que nous opérons sur un objet étrange, avec le champ qui nous intéresse et le deuxième objet, standard, du tableau itéré. Je ne sais pas ce que vous en pensez, mais de mon point de vue, dans le deuxième exemple de code, la logique de la boucle for est beaucoup plus claire et plus intentionnelle que cette réduction à l'aspect étrange. Et pourtant, même si ce n'est plus le mythique 50, c'est toujours 4 fois plus rapide en termes de temps de calcul ! Comme chaque milliseconde est précieuse, le choix dans ce cas est simple.
L'exemple le plus surprenant
La deuxième chose que je voulais comparer concerne la méthode Math.max ou, plus précisément, l'insertion d'un million d'éléments et l'extraction des plus grands et des plus petits. J'ai préparé le code, les méthodes de mesure du temps également, puis je lance le code et j'obtiens une erreur très étrange - la taille de la pile est dépassée. Voici le code :
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5) ;
console.time('Math.max with ES6 spread operator') ;
const maxBySpread = Math.max(...randomValues) ;
console.timeEnd('Math.max avec l'opérateur d'étalement ES6') ;
console.time('Math.max avec boucle for') ;
let maxByFor = randomValues[0] ;
for (let index = 1 ; index maxByFor) {
maxByFor = randomValues[index] ;
}
}
console.timeEnd('Math.max avec boucle for') ;
console.log(maxByFor === maxBySpread) ;
})() ;
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5) ;
console.time('Math.max with ES6 spread operator') ;
const maxBySpread = Math.max(...randomValues) ;
console.timeEnd('Math.max avec l'opérateur d'étalement ES6') ;
console.time('Math.max avec boucle for') ;
let maxByFor = randomValues[0] ;
for (let index = 1 ; index maxByFor) {
maxByFor = randomValues[index] ;
}
}
console.timeEnd('Math.max avec boucle for') ;
console.log(maxByFor === maxBySpread) ;
})() ;
Il s'avère que les méthodes natives utilisent la récursivité, qui dans la v8 est limitée par les piles d'appels et dont le nombre dépend de l'environnement. C'est quelque chose qui m'a beaucoup surpris, mais qui amène une conclusion : la méthode native est plus rapide, tant que notre tableau ne dépasse pas un certain nombre magique d'éléments, qui dans mon cas s'est avéré être 125375. Pour ce nombre d'éléments, le résultat pour était 5 fois plus rapide que la boucle. Cependant, au-delà de ce nombre d'éléments, la boucle for l'emporte définitivement - contrairement à son adversaire, elle nous permet d'obtenir des résultats corrects.
Récursion
Le concept que je souhaite mentionner dans ce paragraphe est la récursivité. Dans l'exemple précédent, nous l'avons vu dans la méthode Math.max et le pliage des arguments, où il s'est avéré qu'il est impossible d'obtenir un résultat pour les appels récursifs dépassant un nombre spécifique en raison de la limitation de la taille de la pile.
Nous allons maintenant voir à quoi ressemble la récursion dans le contexte d'un code écrit en JS, et non avec des méthodes intégrées.La chose la plus classique que nous puissions montrer ici est, bien sûr, la recherche du nième terme de la suite de Fibonacci. Alors, écrivons cela !
(() => {
const fiboIterative = (n) => {
let [a, b] = [0, 1] ;
for (let i = 0 ; i {
if(n < 2) {
retourne n ;
}
return fiboRecursive(n - 2) + fiboRecursive(n - 1) ;
} ;
console.time('Fibonacci sequence by for loop') ;
const resultIterative = fiboIterative(30) ;
console.timeEnd('Séquence de Fibonacci en boucle') ;
console.time('Suite de Fibonacci par récursion') ;
const resultRecursive = fiboRecursive(30) ;
console.timeEnd('Suite de Fibonacci par récursion') ;
console.log(resultRecursive === resultIterative) ;
})() ;
D'accord, dans ce cas particulier de calcul du 30e élément de la séquence sur mon ordinateur, nous obtenons le résultat en un temps environ 200 fois plus court avec l'algorithme itératif.
Il y a cependant une chose qui peut être rectifiée dans l'algorithme récursif - il s'avère qu'il fonctionne beaucoup plus efficacement lorsque nous utilisons une tactique appelée récursion de queue. Cela signifie que nous transmettons le résultat obtenu lors de l'itération précédente en tant qu'argument pour les appels plus profonds. Cela nous permet de réduire le nombre d'appels nécessaires et, par conséquent, d'accélérer le résultat. Corrigeons notre code en conséquence !
(() => {
const fiboIterative = (n) => {
let [a, b] = [0, 1] ;
for (let i = 0 ; i {
if(n === 0) {
return first ;
}
return fiboTailRecursive(n - 1, second, first + second) ;
} ;
console.time('Fibonacci sequence by for loop') ;
const resultIterative = fiboIterative(30) ;
console.timeEnd('Séquence de Fibonacci en boucle') ;
console.time('Séquence de Fibonacci par récursion de queue') ;
const resultRecursive = fiboTailRecursive(30) ;
console.timeEnd('Suite de Fibonacci par récursion de la queue') ;
console.log(resultRecursive === resultIterative) ;
})() ;
Quelque chose que je n'attendais pas s'est produit ici - le résultat de l'algorithme de récursion de queue a été capable de fournir le résultat (calcul du 30ème élément d'une séquence) presque deux fois plus vite que l'algorithme itératif dans certains cas. Je ne sais pas si cela est dû à l'optimisation de la récursivité de la queue de la part de v8 ou au manque d'optimisation de la boucle for pour ce nombre spécifique d'itérations, mais le résultat est sans ambiguïté - la récursivité de la queue l'emporte.
C'est bizarre parce que, essentiellement, la boucle for impose beaucoup moins d'abstraction sur les activités de calcul de niveau inférieur, et on pourrait dire qu'elle est plus proche de l'opération de base de l'ordinateur. Pourtant, les résultats sont indéniables : une récursion intelligemment conçue s'avère plus rapide que l'itération.
Utilisez des instructions d'appel asynchrones aussi souvent que possible
Je voudrais consacrer le dernier paragraphe à un bref rappel sur une méthode d'exécution des opérations qui peut également affecter grandement la vitesse de notre application. Comme vous devez le savoir, JavaScript est un langage à un seul fil qui conserve toutes les opérations avec un mécanisme de boucle d'événements. Il s'agit d'un cycle qui se répète sans cesse et dont toutes les étapes concernent des actions spécifiques dédiées.
Pour que cette boucle soit rapide et que tous les cycles attendent moins longtemps leur tour, tous les éléments doivent être aussi rapides que possible. Évitez d'exécuter de longues opérations sur le thread principal - si quelque chose prend trop de temps, essayez de déplacer ces calculs dans le WebWorker ou de les diviser en parties qui s'exécutent de manière asynchrone. Cela peut ralentir certaines opérations mais améliorer l'ensemble de l'écosystème JS, y compris les opérations d'E/S, telles que la gestion du déplacement de la souris ou des requêtes HTTP en attente.
Résumé
En conclusion, comme nous l'avons déjà mentionné, la chasse aux millisecondes qui peuvent être économisées en sélectionnant un algorithme peut s'avérer insensée dans certains cas. D'un autre côté, négliger ce genre de choses dans des applications qui nécessitent un fonctionnement fluide et des résultats rapides peut s'avérer mortel pour votre application. Dans certains cas, outre la vitesse de l'algorithme, il convient de se poser une question supplémentaire : l'abstraction est-elle exploitée au bon niveau ? Le programmeur qui lira le code pourra-t-il le comprendre sans problème ?
Le seul moyen est de garantir l'équilibre entre les performances, la facilité de mise en œuvre et l'abstraction appropriée, et de s'assurer que l'algorithme fonctionne correctement pour les petites et les grandes quantités de données. La manière d'y parvenir est assez simple : soyez intelligent, envisagez les différents cas lors de la conception de l'algorithme et faites en sorte qu'il se comporte de la manière la plus efficace possible pour les exécutions moyennes. Il est également conseillé de concevoir des tests - assurez-vous que l'algorithme renvoie les informations appropriées pour différentes données, quel que soit son mode de fonctionnement. Veillez à ce que les interfaces soient adéquates - de sorte que les entrées et les sorties des méthodes soient lisibles, claires et reflètent exactement ce qu'elles font.
J'ai mentionné plus tôt que je reviendrai sur la fiabilité de la mesure de la vitesse des algorithmes dans les exemples ci-dessus. Les mesurer avec console.time n'est pas très fiable, mais c'est ce qui reflète le mieux le cas d'utilisation standard. Quoi qu'il en soit, je présente les benchmarks ci-dessous - certains d'entre eux semblent un peu différents d'une exécution unique en raison du fait que les benchmarks répètent simplement une activité donnée à un certain moment et utilisent l'optimisation v8 pour les boucles.
https://jsben.ch/KhAqb - réduction vs boucle for
https://jsben.ch/F4kLY - réduction optimisée par rapport à la boucle for
https://jsben.ch/MCr6g - Math.max vs for loop
https://jsben.ch/A0CJB - fibo récursif vs fibo itératif