Tutoriel pour apprendre et maîtriser les boucles en JavaScript

Pour réagir à ce tutoriel, un espace de dialogue vous est proposé sur le forum. 1 commentaire Donner une note  l'article (5)

Article lu   fois.

L'auteur

Profil ProSite personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Introduction

Le JavaScript dispose d’une demi-dizaine d’instructions distinctes permettant d’effectuer une boucle sur une variable. Quelles sont les méthodes les plus adaptées aux différents types de valeurs ? Quelles sont les implications de telle ou telle méthode ? Dans quel contexte préférer une instruction plutôt qu’une autre ? Autant de questions auxquelles nous allons apporter une réponse.

Le JavaScript comporte de très nombreuses instructions permettant d’effectuer une boucle sur une valeur. Cependant, si nous considérons seulement celles dont c’est l’objet premier – en omettant celles dont la boucle est inhérente à leur fonctionnement (.map, .filter…) – nous en dénombrons exactement cinq :

À cela s’ajoute une méthode dont disposent de nombreux itérables : .forEach. Voyons donc quelles sont les différences fondamentales entre ces différentes instructions.

II. while, le basique

Le while est l’instruction de boucle la plus simple que l’on retrouve en JavaScript. Cette instruction ne prend qu’un seul argument : une condition. Tant que la condition retourne true, la boucle se poursuit.

Toute variable à initialiser, valeur à actualiser, test à effectuer (hors celui de la condition) doivent s’effectuer avant ou dans le corps de la boucle.

 
Sélectionnez
const countUntil = 10;
let i = 0;

while (i <= countUntil) {
    console.log(i++); // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
}

Étant donné que la condition attendue est une expression, on peut tout à fait y placer une expression qui modifie notre structure de contrôle, comme incrémenter notre variable par exemple.

 
Sélectionnez
const countUntil = 10;
let i = 0;

while (i++ < countUntil) {
    console.log(i); // 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
}

Vous avez peut être remarqué que la comparaison est passé d’un inférieur ou égal à un exclusivement inférieur à. En effet, la condition est exécutée avant chaque tour de boucle. C’est d’ailleurs pour cela que le premier exemple compte à partir de zéro tandis que le second commence à un.

De ce fait, la première fois que la condition est évaluée, i vaut 0, il est incrémenté et le corps de la boucle est exécuté. Lors de la dernière exécution, i vaut 9, est incrémenté et la boucle s’exécute. Enfin, la condition est de nouveau évaluée, mais i vaut cette fois 10 donc la boucle s’arrête là.

III. for, le classique

for est la boucle classique telle qu’on la trouve dans l’ensemble des langages issus de la famille du C. Elle prend en paramètres trois expressions optionnelles, séparées par des points-virgules :

  • initialisation : valeur à initialiser avant le démarrage de la boucle ;
  • condition : condition qui détermine si la boucle continue ou s’arrête, elle est évaluée avant chaque tour de boucle ;
  • expression finale : expression évaluée après chaque tour de boucle.
 
Sélectionnez
const test = 5;

for (let i = 0; i < test; i++) {
    console.log(i); // 0, 1, 2, 3, 4
}

Étant donné que chacun des arguments est optionnel, on peut par exemple décider de n’utiliser que la condition, ce sera alors sur une variable définie en amont ; ou alors ne pas utiliser l’expression… Bref, tout est possible.

Il suffit pour cela d’utiliser l’instruction vide (empty statement). Cette dernière permet simplement de remplir la case dans laquelle JavaScript attend une réponse. Par exemple, dans le cas où l’on voudrait ignorer le paramètre d’initialisation.

 
Sélectionnez
const test = 5;
let i = 0;

for (; i < test; i++) {
    console.log(i); // 0, 1, 2, 3, 4
}

Le résultat est exactement le même que précédemment, même si l’exemple est tout à fait idiot, c’est possible. Par ailleurs, comme chacun de ces arguments doit être une expression – c’est-à-dire un élément qui retourne une valeur – on peut en tirer parti pour mettre en place un contrôle plus riche.

Par exemple, vous lirez (ou avez lu) qu’il est conseillé pour des raisons de performances de précalculer la longueur d’un array dans le paramètre d’initialisation plutôt que de le recalculer à chaque tour de boucle.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
const test = [1, 2, 3, 4, 5];

// ainsi, il vaut mieux remplacer cela
for (let i = 0; i < test.length; i++) {
    console.log(i); // 0, 1, 2, 3, 4
}

// par cela
for (let i = 0, length = test.length; i < length; i++) {
    console.log(i); // 0, 1, 2, 3, 4
}

Au-delà de l’exemple, cette optimisation n’est plus utile dans la plupart des cas. En effet, presque tous les moteurs JavaScript possèdent un compilateur JIT (just in time ou compilation à la volée), lequel sait optimiser ce genre de cas tout seul.

IV. do…while, la tête à l’envers

Celle-ci est la petite sœur de la boucle while. Son fonctionnement est sensiblement le même, mais l’évaluation de la condition est inversée. La boucle s’exécute puis évalue la condition.

Si cette dernière retourne true alors la boucle continue, sinon elle s’arrête. Si on transpose les exemples du while, le premier exemple donne le même résultat, mais dans le second, la boucle s’exécutera une fois de plus.

 
Sélectionnez
const countUntil = 10;
let i = 0;

do {
    console.log(i++); // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
} while (i <= countUntil);

Ici, i est incrémenté dans le corps de la boucle, cela ne change rien au résultat.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
const countUntil = 10;
let i = 0;

do {
    console.log(i); // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
} while (i++ < countUntil);

En revanche, lorsque l’incrément se fait directement dans la condition, on part de zéro et on compte jusqu’à dix, alors même que la condition est un inférieur exclusif.

En effet, la boucle s’exécute avant d’évaluer la condition. Ainsi, lors du premier tour, i est à 0, la boucle poursuit jusqu’à ce que i soit à 10, alors la condition est de nouveau évaluée, elle est toujours à, true car i peut-être inférieur ou égal à 10, donc i est incrémenté, passe à 11 et une nouvelle exécution de boucle est validée. i valant maintenant 11, la prochaine condition stoppe la boucle.

V. for..in et for..of pour les objets

La boucle for..in permet de looper sur les propriétés énumérables et non Symbol d’un objet.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
const person = {
    firstname: 'Mickey',
    lastname: 'Mouse',
    nickname: 'Mick'
};

for (const propt in person) {
    console.log(propt); // "firstname", "lastname", "nickname"
    console.log(person[propt]); // "Mickey", "Mouse", "Mick"
}

Sa cousine, la boucle for..of est plus récente, car il s’agit d’une addition au langage à partir de l’ES2015. Cette dernière loop sur les valeurs d’un itérable (objet, tableau, Set, Map, NodeList…).

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
const person = {
    firstname: 'Mickey',
    lastname: 'Mouse',
    nickname: 'Mick'
};

for (const val of person) {
    console.log(val); // "Mickey", "Mouse", "Mick"
}

Il est même possible d’itérer sur un String, cela se fera sur chacune des lettres du mot. Cette structure peut aussi s’avérer très utile pour itérer sur des TypedArray.

Bien que ces deux instructions semblent très similaires, des différences d’envergure existent :

  • for..in liste les clefs tandis que for..of liste les valeurs ;
  • for..in et for..of n’itèrent pas sur les mêmes choses. Tandis que for..in boucle sur toutes les propriétés énumérables autres que Symbol, for..of parcourt l’ensemble des données contenues dans l’objet itérable.

Cette seconde différence mène à des résultats parfois non prévus par le développeur.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
const testLoop = [1, 2, 3];
testLoop.test = 'hohohooo';

// On loop sur les itérables du tableau
for (const val of testLoop) {
    console.log(val); // 1, 2, 3
}

// On loop sur toutes les propriétés énumérables non Symbol
for (const propt in testLoop) {
    console.log(propt); // "0", "1", "2", "test"
}

Autre exemple qui mène souvent à des résultats imprévisibles : lors de l’usage de bibliothèques augmentant certains types natifs et objets.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
// Méthode custom sur le type Array
Array.prototype.customArrMethod = function() {};
Object.prototype.customObjMethod = function() {};

const testLoop = [1, 2, 3];

for (const val of testLoop) {
    console.log(val); // 1, 2, 3
}

for (const propt in testLoop) {
    console.log(propt); // "0", "1", "2", "customArrMethod", "customObjMethod"
}

customObjMethod est également listé, car un Array est aussi un Object. customObjMethod fait donc partie du prototype de testLoop.

On comprend bien que si l’on utilise for..in pour récupérer les valeurs en faisant testLoop[propt] sans prendre ses précautions, on peut aboutir à quelques bogues inattendus.

VI. .forEach, la méthode fonctionnelle

Contrairement aux instructions de boucles vues jusqu’ici, .forEach est une méthode définie sur certains types natifs et objets. Cette méthode est disponible par défaut sur nombre d’objets natifs : Array, Set, Map, NodeList et DOMTokenList. Il est tout à fait possible de l’ajouter à vos propres objets, il suffit de manuellement l’implémenter.

Nous prendrons l’exemple du tableau, car il s’agit du type le plus couramment utilisé avec le .forEach. Cette méthode exécute une fonction sur chacun des éléments de son itérable. Trois arguments sont passés à la fonction callback :

  • la valeur de l’élément courant ;
  • l’index de l’élément courant ;
  • l’objet depuis lequel est appelé .forEach.
 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
const testLoop = [1, 2, 3];

testLoop.forEach((el, i, arr) => {
    console.log(el); // 1, 2, 3
    console.log(i); // 0, 1, 2
    consol.log(arr); // [ 1, 2, 3 ], [ 1, 2, 3 ], [ 1, 2, 3 ]
});

Optionnellement, il est également possible de spécifier le this à utiliser dans le callback :

 
Sélectionnez
1.
array.forEach((el) => {}, thisArg);

Pour un bon nombre de cas d’usage, cette méthode est souvent plus commode que les instructions vues précédemment. En revanche, comme nous le verrons par la suite, certains de ces comportements diffèrent des autres instructions de boucle.

VII. Instructions de contrôle

Il existe deux instructions spécifiques permettant de contrôler le comportement des boucles :

  • break pour l’interrompre ;
  • continue pour passer directement à l’itération suivante.

Ces deux instructions permettent d’éviter des calculs inutiles lorsqu’il n’est pas nécessaire de continuer l’exécution d’un tour de boucle ou qu’il n’est pas nécessaire de poursuivre les itérations.

Dans l’exemple suivant, nous cherchons un nombre dans une liste. Une fois que nous avons trouvé ce nombre, il n’est pas utile de poursuivre la boucle, ce sont des cycles de processeur perdus (donc du temps d’exécution).

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
let index = 0;
const needle = 5;
const haystack = [1, 2, 3, 4, 5, 6, 7, 8, 9];

for (let i = 0; i < haystack.length; i++) {
    if (needle === haystack[i]) {
        index = i;
        break;
    }
}

Dans le cas présent, nous n’avons que peu de valeurs et nous n’effectuons aucun calcul lourd, la différence est donc imperceptible, mais pour un grand set de données ou une exécution complexe dans la boucle, la différence peut être énorme !

Dans le même ordre d’idée, il arrive parfois qu’il ne soit pas pertinent de poursuivre l’exécution d’un tour de la boucle. Dans ce cas, continue permet de passer directement au tour suivant.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
// méthode naïve de calcul de la suite de Fibonacci
function fibonacci(n) {
   if (n < 1) return 0;
   if (n <= 2) return 1;
   else return fibonacci(n - 1) + fibonacci(n - 2);
}

// on décide de trouver le nombre de Fibonacci pour tous les nombres pairs entre x et y
// (x et y non inclus)
const x = 10;
const y = 30;

for (let i = x; i < y; i++) {
    // si c'est impair, on passe directement au tour suivant
    if (i % 2 !== 0) continue;
    console.log(fibonacci(i));
}

Cet exemple est quelque peu tiré par les cheveux. On peut en effet dans ce cas utiliser directement un if pour n’appeler la fonction que si la valeur nous convient. Mais lorsque le corps de la boucle comporte beaucoup de code, cela nous évite des indentations nuisibles à la lisibilité.

VIII. Label de bloc

En plus des deux instructions que nous venons de voir, il est possible d’utiliser l’instruction de bloc avec un label. Assez peu connue des développeurs, cette instruction permet d’attribuer un nom à un bloc de code.

Break termine par défaut la boucle immédiate, mais si plusieurs boucles sont imbriquées, le break ne met pas fin à la boucle parente. En donnant un label à la boucle parente, c’est possible.

Dans l’exemple suivant, on cherche à nouveau à identifier une valeur précise dans un tableau, mais ce dernier est cette fois imbriqué. Il nous faut donc trouver la position dans les tableaux parent et enfant.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
let indexParent = 0;
let indexChild = 0;
const needle = 5;
const haystack = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];

outer: for (let i = 0; i < haystack.length; i++) {
    for (let k = 0; k < haystack[i].length; k++) {
        if (needle === haystack[i][k]) {
            indexParent = i;
            indexChild = k;
            break outer;
        }
    }
}

Sans le label, quand bien même la valeur serait trouvée dans le premier tableau, nous devrions itérer sur toutes les valeurs du parent. Le label nous permet ici d’indiquer à break que nous voulons stopper une boucle de plus haut niveau (en plus de la boucle courante).

IX. return, the end

Le return met fin à la fonction courante (et retourne optionnellement une valeur). À ce titre, il peut être utilisé pour mettre fin à une ou plusieurs boucles.

Si la ou les boucles sont dans une fonction, la fonction s’arrête alors à ce stade, si nous sommes dans un contexte global ou dans un module, aucun autre code ne sera exécuté.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
const needle = 5;
const haystack = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];

function findNeedle(needle, haystack) {
    for (let i = 0; i < haystack.length; i++) {
        for (let k = 0; k < haystack[i].length; k++) {
            if (needle === haystack[i][k]) {
                return { indexParent: i, indexChild: k };
            }
        }
    }
}

findNeedle(needle, haystack);

Dans le cas du .forEach, étant donné qu’une nouvelle fonction est lancée à chaque itération, le return ne mettra fin qu’à l’itération courante, à la manière du continue pour les autres structures de boucles.

Il n’est tout simplement pas possible d’arrêter un .forEach.

X. Synchrone et asynchrone

C’est ici que commencent les choses sérieuses ! Comme vous le savez, le JavaScript est un langage asynchrone. Cela veut dire que le code ne s’exécute par forcement de manière linéaire de haut en bas.

En l’absence totale de code asynchrone, quelle que soit l’option de boucle choisie, tout se passe sans surprise, le code est exécuté linéairement.

Prenons pour exemple le calcul d’une suite de Fibonacci des nombres entre 0 et 40 (plus que 40 risque vite de faire planter votre navigateur).

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
// on utilise Fibonacci pour avoir une fonction qui prend un peu de temps
function fibonacci(n) {
   if (n < 1) return 0;
   if (n <= 2) return 1;
   else return fibonacci(n - 1) + fibonacci(n - 2);
}

// on démarre le chrono
const timeStart = Date.now();

for (let i = 0; i < 40; i++) {
    // vous pouvez afficher les valeurs retournées si vous le souhaitez
    // console.log(fibonacci(i));
    fibonacci(i)
}

// temps total d'exécution,
// si le code était asynchrone, on serait proche de zéro
console.log((Date.now() - timeStart) / 1000);

Le résultat est le même avec toutes les autres boucles ainsi que le .forEach.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
function fibonacci(n) {
   if (n < 1) return 0;
   if (n <= 2) return 1;
   else return fibonacci(n - 1) + fibonacci(n - 2);
}

const timeStart = Date.now();

Array.from(Array(40).keys()).forEach((i) => {
    fibonacci(i);
});

console.log((Date.now() - timeStart) / 1000);

Il y a plusieurs manières de gérer du code asynchrone, les plus utilisées sont les callbacks, les Promises et les structures async/await. L’exécution du code asynchrone est alors immédiatement lancée et le moteur JavaScript passe à la suite du code, sans attendre le résultat de l’opération.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
function wait() {
    return new Promise((resolve) => {
        setTimeout(resolve, 1000);
    });
}

for (let num of [1, 2, 3]) {
    wait().then(() => console.log('wait is over'));
    console.log(num);
}

console.log('loop over');

// 1
// 2
// 3
// "loop over"
// "wait is over"
// "wait is over"
// "wait is over"

On se rend compte ici que la boucle est intégralement exécutée avant même que la première Promise soit résolue. Ce comportement est tout à fait normal, car nous n’avons pas indiqué au moteur JS d’attendre le retour du code asynchrone afin de poursuivre son exécution. Le code s’exécute ici exactement de la même manière qu’il le serait en dehors d’une boucle.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
function wait() {
    return new Promise((resolve) => {
        setTimeout(resolve, 1000);
    });
}

async function test() {
    for (let num of [1, 2, 3]) {
        await wait().then(() => console.log('wait is over'));
        console.log(num);
    }

    console.log('loop over');
}

test();

// "wait is over"
// 1
// "wait is over"
// 2
// "wait is over"
// 3
// "loop over"

Cette fois-ci, la temporalité du code est bien respectée. L’instruction await fait que la boucle attend la résolution de la promesse lors de chaque itération. Seulement une fois la promesse résolue, la boucle reprend son cours. Ce comportement se vérifie pour l’ensemble des instructions de boucle.

En revanche, dans le cas de la méthode .forEach, la fonction callback est appelée avec l’ensemble des valeurs sans considération pour la valeur de retour du callback, et donc sans attendre la résolution de la promesse.

Voici une implémentation simplifiée de la méthode .forEach :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
Array.prototype.forEach = function (callback) {
    // ici, this fait référence à l'objet représenté,
    // donc au tableau considéré
    for (let index = 0; index < this.length; index++) {
        // le callback est appelé, ignorant les comportements asynchrones
        callback(this[index], index, this);
    }
};

De par sa nature même, .forEach est conçu pour ses effets de bord et non pour sa valeur de retour. Cette dernière ne peut d’ailleurs pas être récupérée (array.forEach() retourne toujours undefined).

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
function wait() {
    return new Promise((resolve) => {
        setTimeout(resolve, 1000);
    });
}

function test() {
    [1, 2, 3].forEach(async (num) => {
        console.log('before await');
        await wait().then(() => console.log('wait is over'));
        console.log(num);
    });

    console.log('loop over');
}

test();

// "before await"
// "before await"
// "before await"
// "loop over"
// "wait is over"
// 1
// "wait is over"
// 2
// "wait is over"
// 3

Le await est bien respecté au sein de la fonction callback. Néanmoins, la boucle est intégralement exécutée avant même la résolution de la première promesse.

Il est toutefois possible de lancer l’ensemble des promesses et d’attendre qu’elles soient ensuite toutes résolues afin d’exécuter la suite d’un programme.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
// tous les traitements sont lancés en parallèle et placés dans un array
const promises = [];
array.forEach(el => promises.push(asyncFunction(el)));

// lorsque l'ensemble des traitements sont terminés,
// on peut déclencher la suite
Promise.all(promises)
.then(() => {});

Dans ce dernier cas, on préférera parfois le .map au .forEach, car il permet de directement récupérer les valeurs.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
const promises = array.map(el => asyncFunction(el));

Promise.all(promises)
.then(() => {});

XI. Conclusion

Aucune des manières de faire n’est meilleure qu’une autre. Les quatre instructions de boucle sont fonctionnellement identiques, elles ne diffèrent que par les instructions qu’elles acceptent et le moment auquel elles évaluent la condition.

.forEach diverge de par sa nature et son comportement avec l’asynchrone. Il sera parfois bénéfique de pouvoir lancer l’ensemble des promesses sans en attendre le résultat. On tire ainsi parti de la puissance de la nature asynchrone du JavaScript pour paralléliser les tâches qui requièrent de l’I/O.

Dans d’autres circonstances, on voudra attendre la résolution d’un traitement précédent afin de déclencher le suivant, cela peut être le cas pour des tâches consommatrices en CPU, RAM ou réseau et afin de ne pas surcharger la machine.

La richesse et la souplesse du langage nous offrent de nombreuses possibilités pour travailler avec les boucles et les traitements asynchrones. Il ne tient qu’à nous, développeurs, de tirer profit de cette souplesse pour écrire des programmes puissants et performants.

XII. Note de la rédaction de Developpez.com

Nous tenons à remercier Quentin Busuttil qui nous a aimablement autorisés à publier son tutoriel : Boucles JavaScript : maîtrisez-les toutes. Nous remercions également Malick pour la mise au gabarit et ClaudeLeloup et -FloT- pour la relecture orthographique.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Licence Creative Commons
Le contenu de cet article est rédigé par Quentin Busuttil et est mis à disposition selon les termes de la Licence Creative Commons Attribution - Pas d'Utilisation Commerciale - Pas de Modification 3.0 non transposé.
Les logos Developpez.com, en-tête, pied de page, css, et look & feel de l'article sont Copyright © 2019 Developpez.com.