Activité Programmation en JavaScript : fonctions asynchrones et fonctions callback

Situation

Vous êtes au début d’un projet de développement d’une application web. Vous envisagez d’utiliser JavaScript pour le développement de la partie client et de la partie serveur. Vous vous êtes déjà familiarisé avec le langage JavaScript et vous savez que dans ce langage, les fonctions sont des entités de première classe. Vous poursuivez vos investigations en vous penchant sur les notions de file d’attente d’événement (event queue) et de boucle d’événements (event queue) qui sont à la base de la programmation asynchrone mise en oeuvre dans les interfaces utilisateur graphiques, les opérations d’entrées/sorties asynchrones et la communication avec des worker threads.

Consigne

Réaliser les labs décrits ci-après et consigner le résultat de votre travail dans un document. Lisez attentivement tout le texte. Le but est d’apprendre JavaScript en exécutant du code, pas d’exécuter du code bêtement. Le travail est individuel, car il est important que tous les membres du groupe de projet soient capables de programmer la solution.

Un rapport qui ne contient que les réponses aux questions explicitement posées n’est pas suffisant. Le résultat de ce travail fait partie de la livraison du sprint 0.

Objectifs

À la fin de ce travail, vous devez :

  1. Connaître les notions d’opération asynchrone et d’événement.
  2. Connaître la notion de gestionnaire d’événement.
  3. Connaître la notion de fonction asynchrone.
  4. Être capable de distinguer fonction asynchrone et fonction callback
  5. Être capable d’expliquer ce qu’est le DOM (document object model) d’un document HTML et la manière dont le script du document y a accès.
  6. Connaître la manière d’attacher un gestionnaire d’événement à un élément HTML

Résultat attendu

  • Un bref rapport qui décrit votre travail et votre démarche.

Ressources

Logiciel :

  • Un éditeur de texte (VS Code).
  • Le gestionnaire de dépôt git.
  • La plateforme Node.js (node, npm, npx, etc.)
  • Un shell (bash, powershell, cmd).
  • Un navigateur web (Chrome, Firefox, IE).

Documents :

Démarrage

Mise en place de l’environnement

Cette activité fait suite à l’activité À la découverte de JavaScript. Assurez-vous d’avoir effectué la mise en place de votre environnement conformément au point 6.

Gestionnaire d’événement (lab4)

Rendez-vous dans votre répertoire de travail et lancer les commandes ci-après pour récupérer le projet lab4, installer les dépendances et ouvrir le projet dans Visual Studio Code.

1
2
3
4
git clone https://gitlab.epai-ict.ch/m120/lab4.git
cd lab4
npm install
code .

En installant les dépendances du projet avec la commande npm install, nous avons installé un serveur HTTP de développement capable d’effectuer un live reload, c’est-à-dire de recharger la page lorsqu’une modification est détectée dans le code de votre projet. Les dépendances du projet sont listées dans le fichier package.json. Le serveur HTTP de développement n’est utile que pour le développement, elle se trouve donc dans la section dépendances de développement (devDependencies).

Dans Visual Studio Code, vous pouvez lancer l’exécution du serveur HTTP de développement en exécutant la commande « Run Build Task » (voir Key Bindings for Visual Studio Code). Ouvrez les outils de développement du navigateu et organisez vos fenêtres de manière à voir simultanément votre éditeur et votre document HTML.

Essayez de modifier le fichier HTML pour vérifier que le live reload fonctionne correctement. La page devrait se réactualiser lorsque vous sauvegardez la modification.

Gestionnaire d’événements

Sur la page HTML, vous pouvez voir un bouton sur lequel vous pouvez cliquer. L’aspect visuel (look) vous permet de savoir qu’il s’agit d’un bouton, et le comportement de ce bouton vous permet de percevoir (feel) le fait que vous l’avez bien actionné.

Lorsque le chargement du document HTML est terminé et tous les scripts exécutés, la fenêtre du navigateur entre dans une boucle, appelée boucle d’événement (event loop), dans laquelle il attend que des événements (event) soient placés par le système dans la file d’événements (event queue). Lorsqu’un événement est placé dans la queue, la fenêtre du navigateur récupère les gestionnaires d’événements (event handlers) liés à la source de l’événement (event source) et appelle successivement chacun de ces gestionnaires d’événements.

Lorsque l’on actionne le bouton avec la souris, le système place un événement dans la file d’événements, mais puisqu’aucun gestionnaire d’événement n’est attaché à ce bouton, il ne se passe rien. Pour donner un effet à notre bouton, nous devons faire deux choses distinctes :

  1. Programmer l’effet du bouton dans un gestionnaire d’événement.
  2. Attacher la fonction au bouton.

En JavaScript un gestionnaire d’événement peut être un objet de type function ou n’importe quel objet qui implémente une méthode handleEvent(). Nous optons pour la variante la plus classique, à savoir un objet de type function. Copiez le code ci-après dans le fichier main.js.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
console.log('Début de l’exécution de l’élément script.')

/**
 * Gestionnaire d’événement du bouton
 *
 * @param {Event} event
 */
const myClickHandler = function (event) {
  // TODO : effectuer quelque chose d’utile
  console.log('Un click est survenu : ', event)
}

// Récupère l’élément HTML dont l’attribut id est "my-button"
const myButton = document.querySelector('#my-button')

// Attache le gestionnaire d’événement au bouton. Le premier
// argument ("click") indique que le gestionnaire doit être
// appelé lorsque le bouton est cliqué.
myButton.addEventListener('click', myClickHandler)

console.log('Un gestionnaire d’événement a été attaché au bouton "my-button".')
console.log('Fin de l’exécution de l’élément script.')

Lorsque vous enregistrez la modification du fichier main.js, la page est automatiquement rechargée et si la console des outils de développement du navigateur est ouverte, vous pouvez voir apparaître le message : Un gestionnaire d’événement a été attaché au bouton “my-button”.

Lorsque vous cliquez sur le bouton, un nouveau message apparaît à chaque clic. Ce message est affiché par le gestionnaire d’événement et contient une représentation de l’objet “event” qu’il reçoit en paramètre (voir figure 1).

Fig. 1 – Structure de données d’un événement
Fig. 1 – Structure de données d’un événement

On peut également écrire un gestionnaire d’événement avec une expression lambda comme nous l’avions fait pour la méthode sort.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
console.log('Début de l’exécution de l’élément script.')

// Récupère l’élément HTML dont l’attribut id est "my-button"
const myButton = document.querySelector('#my-button')

// Attache le gestionnaire d’événement au bouton. Le premier
// argument ("click") indique que le gestionnaire doit être
// appelé lorsque le bouton est cliqué.
myButton.addEventListener('click', function (event) {
  // TODO : effectuer quelque chose d’utile
  console.log('Un click est survenu : ', event)
})

console.log('Un gestionnaire d’événement a été attaché au bouton "my-button".')
console.log('Fin de l’exécution de l’élément script.')

Pour introduire la suite, nous pouvons réécrire ce petit programme en encapsulant les détails dans une fonction. Nous appellerons cette fonction listenToMyButtonClick. Le paramètre de cette fonction est la fonction qui servira de gestionnaire d’événement.

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
29
30
31
32
33
34
35
console.log('Début de l’exécution de l’élément script.')

/**
 * Cette procédure ajoute la fonction callback passée
 * en paramètre aux gestionnaires d’événement du bouton
 * et retourne immédiatement après.
 *
 * @param {Function} callback
 */
const listenToMyButtonClick = function (callback) {
  console.log('Début de la procédure listenToMyButtonClick.')

  // Récupère l’élément HTML dont l’attribut id est "my-button"
  const myButton = document.querySelector('#my-button')

  // Attache le gestionnaire d’événement au bouton. Le premier
  // argument ("click") indique que le gestionnaire doit être
  // appelé lorsque le bouton est cliqué.
  myButton.addEventListener('click', callback)

  console.log('Fin de la procédure listenToMyButtonClick.')
}

// Écoute les événements click sur le bouton.
listenToMyButtonClick(function (event) {
  console.log('Début du gestionnaire d’événement du bouton.')

  // TODO : effectuer quelque chose d’utile
  console.log('Un click est survenu : ', event)

  console.log('Fin du gestionnaire d’événement du bouton.')
})

console.log('Un gestionnaire d’événement a été attaché au bouton "my-button".')
console.log('Fin de l’exécution de l’élément script.')

Comme la fonction de comparaison que nous avions passée la méthode sort d’un tableau et qui était appelée en retour par la méthode lors de l’exécution de l’algorithme de tri, on passe à la fonction listenToMyButtonClick une expression lambda qui appelée en retour par la boucle d’événement lors du traitement d’un événement “click” dont la source est le bouton. Dans les deux cas, on parle de fonctions callback (fonctions de rappel), c’est-à-dire une fonction appelée en retour.

Il y a toutefois une différence entre ces deux fonctions callback. La fonction passée à la méthode sort permet à cette méthode de trier des éléments de différents types en délégant la comparaison de ces éléments. Mais en dehors de cela, la méthode sort est tout à fait classique. On parle d’une méthode bloquante ou synchrone, la fonction ne retourne que lorsque le travail est terminé.

Dans le cas de la fonction listenToMyButtonClick, on demande au système d’écouter les événements click du bouton et de nous informer lorsque cela arrive en appelant la fonction callback, mais la fonction listenToMyButtonClick n’attend pas que des événements arrivent. On parle dans ce cas de fonctions non bloquantes ou asynchrones, c’est-à-dire une fonction qui lance une opération asynchrone et qui retourne immédiatement après sans attendre le résultat. Le résultat est communiqué ultérieurement par un appel à la fonction callback.

Il est important de distinguer la fonction asynchrone et la fonction callback que l’on passe à une fonction asynchrone pour recevoir un résultat.

Opération asynchrone et événement

Un événement tel que la fin d’un timer ou une action de l’utilisateur sur le clavier ou la souris peut survenir à n’importe quel instant et pas seulement lorsque le programme est prêt à les recevoir. La survenue des événements et le traitement des événements par le programme ne se déroulent pas au même rythme.

Pour éviter qu’un événement ne passe inaperçu, l’état des dispositifs d’entrée (souris, clavier, etc.) doit être surveillé en permanence. Comme il s’agit de ressources partagées, cette tâche est généralement dévolue au système d’exploitation. Lorsque le thread chargé de la surveillance détecte une action de l’utilisateur, il doit d’abord déterminer le processus auquel cette action est destinée en utilisant, par exemple, des informations telles que la position du pointeur ou la fenêtre active. Il doit ensuite déléguer le traitement de cet événement au processus identifié. C’est là qu’entre en jeu la file d’attente d’événements (event queue).

En effet, comme un événement peut survenir n’importe quand, il est possible qu’au moment où il survient, le thread chargé de son traitement soit occupé. Un thread chargé de traiter des événements est donc équipé d’une file d’attente. Le système y place les événements qui lui sont destinés au fur et à mesure qu’ils surviennent. De son côté, la boucle de message (message loop) du thread traite les événements l’un après l’autre, dans l’ordre d’arrivée. On dit que le traitement des événements est asynchrone.

Une file d’attente d’événements est un moyen de communication asynchrone, c’est-à-dire qu’elle permet à deux threads de communiquer sans avoir à se synchroniser, chacun travaillant à son rythme. Le plus souvent, une file d’attente d’événements est réalisée de telle manière qu’elle permet à plusieurs threads de placer des événements, mais à un seul thread de les traiter. C’est pourquoi les interfaces utilisateur graphiques (GUI) n’ont généralement qu’un thread pour le traitement des événements et l’affichage. Il s’en suit que le temps de traitement des événements doit être aussi court que possible pour éviter de figer l’affichage. Mais pourquoi s’imposer une telle contrainte ?

Single threaded GUI

On peut en effet se demander pourquoi se limiter à un seul thread pour le traitement des événements. Puisque nos ordinateurs peuvent effectuer plusieurs tâches simultanément, pourquoi ne pas lancer un thread pour traiter un événement aussitôt qu’il survient plutôt que de différer son traitement en le plaçant dans une queue ? Ou alors, pourquoi ne pas permettre à plusieurs threads de traiter les événements de la queue de manière à ne pas bloquer le système si un traitement prend plus de temps ?

Ces questions, d’autres se les sont posées et il y a eu différentes tentatives pour réaliser des interfaces utilisateur graphiques multithread (multithreaded GUI). Java AWT (Abstract Windows Toolkit) est l’une d’entre elles. Toutefois, toutes ces tentatives ont été des échecs et dans le cas de Java, les successeurs de AWT, Swing et JavaFX sont tous deux single threaded.

La raison de ces échecs est que la réalisation de programme multithread est vraiment très difficile et nous expose à des problèmes tels que les race conditions ou les deadlocks. À moins d’avoir reçu une formation spécifique dans la programmation concurrente, il est fortement déconseillé d’utiliser explicitement des threads et dans tous les cas, il est recommandé de s’en passer autant que possible. Il est donc aujourd’hui communément admis qu’une interface utilisateur graphique n’a qu’un seul thread (single threaded).

Toutefois, une interface utilisateur qui se fige est généralement mal perçue par l’utilisateur, il est donc important que le temps de traitement soit aussi court que possible. Mais alors, que faire lorsqu’un événement déclenche une procédure qui prend beaucoup de temps ? Deux cas de figure peuvent se présenter :

  • CPU-bound : la procédure prend beaucoup de temps parce qu’elle utilise le processeur pour effectuer un calcul compliqué.
  • I/O-bound : la procédure prend beaucoup de temps parce qu’elle doit attendre la réponse d’un dispositif d’E/S lent.

Dans le premier cas (CPU-bound), il n’y a pas d’autre solution que d’utiliser un thread. Toutefois, il est recommandé d’utiliser pour cela une bibliothèque qui prend en charge la gestion de worker threads et la communication entre ces worker threads et le thread du GUI en utilisant leur file d’attente d’événements respective. En JavaScript, ces fonctionnalités sont offertes par l’API Web Workers.

Dans le second cas, la solution consiste à utiliser des API qui permettent d’accéder à la mémoire secondaire (disques durs, SSD, etc.) ou au réseau de manière asynchrone. Cette solution est celle qui est traditionnellement mise en oeuvre pour les dispositifs d’entrée (voir plus haut), mais elle peut tout aussi bien être mise en oeuvre pour effectuer des requêtes HTTP ou l’accès au système de fichier ou à une base de données.

Utiliser des Worker threads (lab5)

Pour illustrer l’utilisation d’un worker thread, nous allons réaliser une procédure qui effectue un calcul simple, mais ridiculement long. Rendez-vous dans votre répertoire de travail et lancer les commandes ci-après pour récupérer le projet lab5, installer les dépendances et ouvrir le projet dans Visual Studio Code.

1
2
3
4
git clone https://gitlab.epai-ict.ch/m120/lab5.git
cd lab5
npm install
code .

Appel d’un procédure bloquante (attendre la fin du calcul avant de poursuivre)

Dans le fichier main.js de votre projet lab5, ajoutez la fonction suivante :

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
console.log('Début de l’exécution de l’élément script.')

/**
 * Cette procédure ajoute la fonction callback passée
 * en paramètre aux gestionnaires d’événement du bouton
 * et retourne immédiatement après.
 *
 * @param {Function} callback
 */
const listenToMyButtonClick = function (callback) {
  console.log('Début de la procédure listenToMyButtonClick.')

  // Récupère l’élément HTML dont l’attribut id est "my-button"
  const myButton = document.querySelector('#my-button')

  // Attache le gestionnaire d’événement au bouton. Le premier
  // argument ("click") indique que le gestionnaire doit être
  // appelé lorsque le bouton est cliqué.
  myButton.addEventListener('click', callback)

  console.log('Fin de la procédure listenToMyButtonClick.')
}

/**
 * Cette procédure effectue un calcul simple
 * mais ridiculement long (plusieurs secondes).
 */
const veryLongProcedure = function () {
  console.log('Début du calcul')
  let count = 0
  for (let i = 0; i < 5000000000; i += 1) {
    count = count + i
  }
  console.log('Fin du calcul')
}

// Écoute les événements click sur le bouton.
listenToMyButtonClick(function (event) {
  console.log('Début du gestionnaire d’événement du bouton.')

  // TODO : effectuer quelque chose d’utile
  console.log('Un click est survenu : ', event)

  console.log('Fin du gestionnaire d’événement du bouton.')
})

console.log('Un gestionnaire d’événement a été attaché au bouton "my-button".')
console.log('Fin de l’exécution de l’élément script.')

L’ajout de code n’a pas d’effet sur le temps d’exécution. En effet, l’instruction crée une nouvelle procédure, mais elle n’est appelée nulle part. Replacez l’appel de la procédure listenToMyButtonClick par le code suivant :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Écoute les événements click sur le bouton.
listenToMyButtonClick(function (event) {
  console.log('Début du gestionnaire d’événement du bouton.')

  // Récupère la source de l’événement (le bouton)
  const myButton = event.target
  // Désactive le bouton (le bouton apparaît grisé)
  myButton.disabled = true

  // Affiche le message "Work in progress..."
  // dans le label prévu à cet effet.
  const myLabel = document.querySelector('#status-label')
  myLabel.textContent = 'Work in progress...'

  // Appelle la procédure
  veryLongProcedure()

  // Supprime le message "Work in progress..." et
  // réactive le bouton.
  myLabel.textContent = ''
  myButton.disabled = false

  console.log('Fin du gestionnaire d’événement du bouton.')
})

Ce code devrait effectuer plusieurs actions :

  1. Désactiver le bouton.
  2. Afficher le message “Work in progress…” dans l’élément <div> qui se trouve sous le bouton.
  3. Lancer l’exécution de la procédure et attendre le résultat.
  4. Supprime le message et réactive le bouton.

Toutefois, si vous exécutez le code en cliquant sur le bouton, le résultat n’est pas celui que vous attendez. En effet, le gestionnaire d’événement est exécuté dans le même thread que l’affichage, c’est pourquoi l’affichage n’est pas réactualisé durant son exécution. Le message n’apparaît pas et le bouton reste figé jusqu’à la fin du calcul. Pour vous en convaincre, essayez de redimensionner la fenêtre durant l’exécution de la procédure.

Utilisation d’un worker thread (faire autre chose en attendant le résultat du calcul)

Pour résoudre ce problème, on peut utiliser l’API Web Workers pour exécuter cette procédure dans un autre thread. Pour cela, créez un fichier task.js dans le répertoire js et copiez le code suivant :

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
// task.js

/**
 * Cette procédure effectue un calcul simple
 * mais ridiculement long (plusieurs secondes).
 */
const veryLongProcedure = function () {
  console.log('Début du calcul')
  let count = 0
  for (let i = 0; i < 5000000000; i += 1) {
    count = count + i
  }
  console.log('Fin du calcul')
}

try {
  // Appelle la procédure (procédure bloquante)
  veryLongProcedure()

  // place un message dans la file de messages
  // (file d’événements) pour indiquer que le
  // calcul est terminé.
  postMessage('done')
} catch (err) {
  // Si une exception est survenue, place un
  // message avec une description de l’erreur.
  postMessage(err.toString())
}

Modifier le fichier main.js conformément au code ci-après. N’écrasez pas votre code, effectuez les modifications nécessaires et utilisez la commande « Toggle Line Comment » de votre éditeur pour commenter le code devenu inutile.

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
console.log('Début de l’exécution de l’élément script.')

/**
 * Cette procédure ajoute la fonction callback passée
 * en paramètre aux gestionnaires d’événement du bouton
 * et retourne immédiatement après.
 *
 * @param {Function} callback
 */
const listenToMyButtonClick = function (callback) {
  console.log('Début de la procédure listenToMyButtonClick.')

  // Récupère l’élément HTML dont l’attribut id est "my-button"
  const myButton = document.querySelector('#my-button')

  // Attache le gestionnaire d’événement au bouton. Le premier
  // argument ("click") indique que le gestionnaire doit être
  // appelé lorsque le bouton est cliqué.
  myButton.addEventListener('click', callback)

  console.log('Fin de la procédure listenToMyButtonClick.')
}

/**
 * Cette procédure effectue un calcul simple, mais ridiculement
 * long (plusieurs secondes) de manière asynchrone en utilisant
 * un worker thread.
 *
 * @param {Function} success fonction callback à appeler en cas de succès.
 * @param {Function} error fonction callback à appeler en cas d’erreur.
 */
const veryLongProcedure = function (success, error) {
  console.log('Début de la procédure veryLongProcedure.')

  const myWorker = new Worker('js/task.js')

  // Attache un gestionnaire d’événement pour traiter
  // les messages en provenance du worker thread.
  myWorker.onmessage = function (event) {
    const result = event.data

    if (result === 'done') {
      success()
    } else {
      // Si le message du worker est différent de "done",
      // test si a fonction callback error a une valeur et
      // appelle cette fonction le cas échéant.
      if (typeof error === 'function') {
        error(result)
      }
    }
  }
  console.log('Fin de la procédure veryLongProcedure.')
}

// Écoute les événements click sur le bouton.
listenToMyButtonClick(function (event) {
  console.log('Début du gestionnaire d’événement du bouton.')

  // Désactive le bouton
  const myButton = event.target
  myButton.disabled = true

  // Affiche le message "Work in progress..."
  // dans le label prévu à cet effet.
  const myLabel = document.querySelector('#status-label')
  myLabel.textContent = 'Work in progress ...'

  // Applel la fonction veryLongProcedure en passant
  // les deux fonctions callback attendues
  // (fonction non bloquante)
  veryLongProcedure(
    function () {
      console.log('Début de la fonction callback success.')

      console.log('Calcul terminé!')

      // Supprime le message "Work in progress..." et
      // réactive le bouton.
      myLabel.textContent = ''
      myButton.disabled = false

      console.log('Fin de la fonction callback success.')
    },
    function (err) {
      console.log('Début de la fonction callback error.')

      console.log('Une erreur est survenue : ', err)

      // Supprime le message "Work in progress..." et
      // réactive le bouton.
      myLabel.textContent = ''
      myButton.disabled = false

      console.log('Fin de la fonction callback error.')
    })

  console.log('Fin du gestionnaire d’événement du bouton.')
})
console.log('Fin de l’exécution de l’élément script.')

Si vous exécutez la procédure asynchrone en cliquant sur le bouton, vous pouvez constater que cette fois, l’affichage n’est pas figé et que le message “Work in progress…” est correctement affiché. Observez la trace de la console pour vous aider à comprendre à quel moment les différentes parties du code sont exécutées.

Utiliser des E/S asynchrones (lab6)

Pour illustrer l’utilisation d’entrées/sorties asynchrone, nous allons utiliser une requête HTTP qui prend plusieurs secondes. L’URL de la ressource que nous allons utiliser est http://www.epai-ict.ch/m120/api/books. Cette API, réalisée pour les besoins de ce lab, renvoie une liste de livres et a besoin de 10 s pour s’exécuter. Rendez-vous dans votre répertoire de travail et lancer les commandes ci-après pour récupérer le projet lab5, installer les dépendances et ouvrir le projet dans Visual Studio Code.

1
2
3
4
git clone https://gitlab.epai-ict.ch/m120/lab6.git
cd lab6
npm install
code .

Requête synchrone (attendre la réponse avant de poursuivre)

Dans le fichier main.js de votre projet lab4, ajoutez la fonction ci-après. Vous avez sans doute remarqué que l’URL utilisée pour la requête est une URL relative et correspond donc à http://localhost:<port>/m120/api/books. En effet, l’objet XMLHttpRequest ne fonctionne que si l’URL de la requête a la même origine que l’URL du document HTML. C’est pourquoi nous avons donc utilisé la fonctionnalité de proxy du serveur de développement. Grâce à cela, le serveur traite les requêtes pour l’URL /m120/api/books en envoyant une requête vers l’URL http://www.epai-ict.ch/m120/api/books et en renvoyant la réponse reçue. Le serveur de développement fonctionne comme un proxy inversé (reverse proxy).

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
console.log('Début de l’exécution de l’élément script.')

/**
 * Cette procédure ajoute la fonction callback passée
 * en paramètre aux gestionnaires d’événement du bouton
 * et retourne immédiatement après.
 *
 * @param {Function} callback
 */
const listenToMyButtonClick = function (callback) {
  console.log('Début de la procédure listenToMyButtonClick.')

  // Récupère l’élément HTML dont l’attribut id est "my-button"
  const myButton = document.querySelector('#my-button')

  // Attache le gestionnaire d’événement au bouton. Le premier
  // argument ("click") indique que le gestionnaire doit être
  // appelé lorsque le bouton est cliqué.
  myButton.addEventListener('click', callback)

  console.log('Fin de la procédure listenToMyButtonClick.')
}

/**
 * Cette fonction effectue une requête HTTP synchrone pour
 * obtenir une liste de livre qu’elle renvoie à l’appelant.
 */
const getBooks = function () {
  console.log('Début de la procédure getBooks.')

  // Crée une requête HTTP.
  const req = new XMLHttpRequest()

  // Définit la méthode et l’URL.
  req.open('GET', '/m120/api/books', false)

  // Envoie la requête de manière synchrone, la méthode
  // send ne retourne que lorsque la réponse est arrivée.
  req.send(null)

  console.log('Fin de la procédure getBooks.')

  if (req.status === 200) {
    return JSON.parse(req.responseText)
  } else {
    throw new Error('Status de la réponse : ' + req.status + ' ' + req.statusText)
  }
}

// Écoute les événements click du bouton (fonction non bloquante)
listenToMyButtonClick(function (event) {
  console.log('Début du gestionnaire d’événement du bouton.')

  // Désactive le bouton
  const myButton = event.target
  myButton.disabled = true

  // Affiche le message "Request in progress..."
  // dans le label prévu à cet effet.
  const myLabel = document.querySelector('#status-label')
  myLabel.textContent = 'Request in progress...'

  // Appelle la fonction getBooks (fonction boquante)
  const books = getBooks()

  console.log('Le résultat est : %s', books)

  // Supprime le message "Request in progress..." et
  // réactive le bouton.
  myLabel.textContent = ''
  myButton.disabled = false

  console.log('Fin du gestionnaire d’événement du bouton.')
})

console.log('Fin de l’exécution de l’élément script.')

Lancez la commande « Run Build Task » pour ouvrir le document HTML dans un navigateur et actionnez le bouton. Comme vous pouvez le constater, là encore le rafraîchissement de l’affichage de la page est bloqué durant le temps de la requête. En outre, la console du navigateur contient un message qui nous informe qu’on ne devrait pas effectuer de requêtes synchrones.

Requête asynchrone (faire autre chose en attendant la réponse)

L’objet XMLHttpRequest peut également être utilisé pour effectuer des requêtes asynchrones, de telle sorte que notre programme n’a pas à attendre que la réponse avant de poursuivre son exécution. Au lieu de cela, on enregistre un gestionnaire pour l’événement readystatechange qui sera appelé à chaque changement d’état de la requête. On sait que la réponse est arrivée si l’état de la requête (requestState) vaut 4.

Modifiez le gestionnaire d’événement du bouton conformément au code suivant :

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
console.log('Début de l’exécution de l’élément script.')

/**
 * Cette procédure ajoute la fonction callback passée
 * en paramètre aux gestionnaires d’événement du bouton
 * et retourne immédiatement après.
 *
 * @param {Function} callback
 */
const listenToMyButtonClick = function (callback) {
  console.log('Début de la procédure listenToMyButtonClick.')

  // Récupère l’élément HTML dont l’attribut id est "my-button"
  const myButton = document.querySelector('#my-button')

  // Attache le gestionnaire d’événement au bouton. Le premier
  // argument ("click") indique que le gestionnaire doit être
  // appelé lorsque le bouton est cliqué.
  myButton.addEventListener('click', callback)

  console.log('Fin de la procédure listenToMyButtonClick.')
}

/**
 * Cette fonction effectue une requête HTTP asynchrone pour
 * obtenir une liste de livre qu’elle renvoie à l’appelant à
 * en appelant la fonction callback success passée en paramètre.
 *
 * @param {Function} success fonction callback à appeler en cas de succès.
 * @param {Function} error fonction callback à appeler en cas d’erreur.
 */
const getBooks = function (success, error) {
  console.log('Début de la procédure getBooks.')

  // Crée une requête HTTP.
  const req = new XMLHttpRequest()

  // Définit la méthode et l’URL.
  req.open('GET', '/m120/api/books')

  // Attache un gestionnaire pour l’événement
  // readystatechange de la requête.
  req.onreadystatechange = function (event) {
    // Récupère la source de l’événement
    // (la requête HTTP)
    const source = event.target

    // Test si la réponse est arrivée.
    if (source.readyState === 4) {
      console.log('Réponse reçue !')

      if (source.status === 200) {
        // Récupère le résultat
        const result = JSON.parse(req.responseText)
        // Appelle la fonction callback success
        success(result)
      } else {
        // Si le code de status est différent de 200, test
        // si la fonction callback error a une valeur et
        // appelle cette fonction le cas échéant.
        if (typeof error === 'function') {
          error('Status de la réponse : ' + req.status + ' ' + req.statusText)
        }
      }
    }
  }
  console.log('Envoie de la requête...')

  // Envoie la requête de manière asynchrone, la méthode
  // send retourne sans attendre la réponse.
  req.send(null)

  console.log('Fin de la procédure getBooks.')
}

// Écoute les événements click du bouton (fonction non bloquante)
listenToMyButtonClick(function (event) {
  console.log('Début du gestionnaire d’événement du bouton.')

  // Désactive le bouton
  const myButton = event.target
  myButton.disabled = true

  // Affiche le message "Request in progress..."
  // dans le label prévu à cet effet.
  const myLabel = document.querySelector('#status-label')
  myLabel.textContent = 'Request in progress...'

  // Appelle la fonction getBooks (fonction non bloquante)
  getBooks(function (books) {
    console.log('Début de la fonction callback success.')

    console.log('Le résultat est : %s', books)

    // Supprime le message "Request in progress..." et
    // réactive le bouton.
    myLabel.textContent = ''
    myButton.disabled = false

    console.log('Fin de la fonction callback success.')
  })

  console.log('Fin du gestionnaire d’événement du bouton.')
})

console.log('Fin de l’exécution de l’élément script.')

Cliquez maintenant sur le bouton et redimensionnez la fenêtre durant le traitement de la requête. Vous pouvez constater que l’affichage est correctement réactualisé. Ce même principe peut être appliqué aux requêtes de base de données ou aux accès à la mémoire de masse.

Modifiez le code pour appeler trois fois de suite (en séquence, pas simultanément) la fonction getBooks avant de réactiver les boutons et de supprimer le message “Request in progress…”.

Conclusion

Durant ce travail, vous avez pu expérimenter l’utilisation d’opérations asynchrones, c’est-à-dire des opérations qui s’exécutent dans un autre thread et qui notifient le thread appelant en utilisant une file d’attente d’événements parfois appelée file d’attente de messages. Une fonction asynchrone est une fonction qui lance une opération asynchrone (p. ex. XMLHttpRequest). Pour traiter les événements (messages), le thread appelant implémente une boucle d’événement. Le plus souvent, cette boucle est implémentée par la plateforme utilisée pour exécuter le programme. En JavaScript, ce peut être soit un navigateur, soit la plateforme Node.js.

Comme vous avez pu le constater, l’utilisation de fonction callback rend le code plus difficile à lire. Un enchaînement d’appels de fonctions asynchrones ne ressemble pas à une séquence (callback hell et la propagation des erreurs est particulièrement difficile à réaliser. Les promesses (promise), qui feront l’objet d’une prochaine activité, permettent de résoudre ces problèmes de manière élégante. En outre, de plus en plus de langages de programmation supportent la programmation asynchrone avec les mots-clés async et await.