Écrire des tâches asynchrones en JavaScript moderne

Publié: 2022-03-10
Résumé rapide ↬ Dans cet article, nous allons explorer l'évolution de JavaScript autour de l'exécution asynchrone dans le passé et comment cela a changé la façon dont nous écrivons et lisons du code. Nous commencerons par les débuts du développement Web et irons jusqu'aux exemples de modèles asynchrones modernes.

JavaScript a deux caractéristiques principales en tant que langage de programmation, toutes deux importantes pour comprendre comment notre code fonctionnera. Premièrement, sa nature synchrone , ce qui signifie que le code s'exécutera ligne après ligne, presque comme vous le lisez, et deuxièmement, il s'agit d'un thread unique, une seule commande est exécutée à tout moment.

Au fur et à mesure que le langage évoluait, de nouveaux artefacts sont apparus dans la scène pour permettre une exécution asynchrone ; les développeurs ont essayé différentes approches tout en résolvant des algorithmes et des flux de données plus complexes, ce qui a conduit à l'émergence de nouvelles interfaces et de nouveaux modèles autour d'eux.

Exécution synchrone et modèle d'observateur

Comme mentionné dans l'introduction, JavaScript exécute le code que vous écrivez ligne par ligne, la plupart du temps. Même dans ses premières années, le langage avait des exceptions à cette règle, bien qu'elles soient peu nombreuses et que vous les connaissiez peut-être déjà : requêtes HTTP, événements DOM et intervalles de temps.

 const button = document.querySelector('button'); // observe for user interaction button.addEventListener('click', function(e) { console.log('user click just happened!'); })

Si nous ajoutons un écouteur d'événement, par exemple le clic d'un élément et que l'utilisateur déclenche cette interaction, le moteur JavaScript mettra en file d'attente une tâche pour le rappel de l'écouteur d'événement mais continuera à exécuter ce qui est présent dans sa pile actuelle. Après avoir terminé avec les appels présents, il exécutera maintenant le rappel de l'auditeur.

Ce comportement est similaire à ce qui se passe avec les requêtes réseau et les temporisateurs, qui ont été les premiers artefacts à accéder à l'exécution asynchrone pour les développeurs Web.

Bien qu'il s'agisse d'exceptions à l'exécution synchrone courante en JavaScript, il est essentiel de comprendre que le langage est toujours monothread et bien qu'il puisse mettre des tâches en file d'attente, les exécuter de manière asynchrone, puis revenir au thread principal, il ne peut exécuter qu'un seul morceau de code. à la fois.

Plus après saut! Continuez à lire ci-dessous ↓

Par exemple, examinons une requête réseau.

 var request = new XMLHttpRequest(); request.open('GET', '//some.api.at/server', true); // observe for server response request.onreadystatechange = function() { if (request.readyState === 4 && request.status === 200) { console.log(request.responseText); } } request.send();

Lorsque le serveur revient, une tâche pour la méthode affectée à onreadystatechange est mise en file d'attente (l'exécution du code se poursuit dans le thread principal).

Remarque : Expliquer comment les moteurs JavaScript mettent les tâches en file d'attente et gèrent les threads d'exécution est un sujet complexe à couvrir et mérite probablement un article à part entière. Pourtant, je recommande de regarder "What The Heck Is The Event Loop Anyway?" par Phillip Roberts pour vous aider à mieux comprendre.

Dans chaque cas mentionné, nous répondons à un événement extérieur. Un certain intervalle de temps atteint, une action de l'utilisateur ou une réponse du serveur. Nous n'étions pas en mesure de créer une tâche asynchrone en soi, nous avons toujours observé des événements hors de notre portée.

C'est pourquoi le code formé de cette manière est appelé Observer Pattern , qui est mieux représenté par l'interface addEventListener dans ce cas. Bientôt, les bibliothèques d'émetteurs d'événements ou les frameworks exposant ce modèle ont prospéré.

Node.js et émetteurs d'événements

Un bon exemple est Node.js, dont la page se décrit comme "un environnement d'exécution JavaScript asynchrone piloté par les événements", de sorte que les émetteurs d'événements et les rappels étaient des citoyens de première classe. Il avait même un constructeur EventEmitter déjà implémenté.

 const EventEmitter = require('events'); const emitter = new EventEmitter(); // respond to events emitter.on('greeting', (message) => console.log(message)); // send events emitter.emit('greeting', 'Hi there!');

Ce n'était pas seulement l'approche à suivre pour l'exécution asynchrone, mais un modèle et une convention de base de son écosystème. Node.js a ouvert une nouvelle ère d'écriture de JavaScript dans un environnement différent, même en dehors du Web. En conséquence, d'autres situations asynchrones étaient possibles, comme la création de nouveaux répertoires ou l'écriture de fichiers.

 const { mkdir, writeFile } = require('fs'); const styles = 'body { background: #ffdead; }'; mkdir('./assets/', (error) => { if (!error) { writeFile('assets/main.css', styles, 'utf-8', (error) => { if (!error) console.log('stylesheet created'); }) } })

Vous remarquerez peut-être que les rappels reçoivent une error en tant que premier argument, si une donnée de réponse est attendue, elle passe en second argument. Cela s'appelait Error-first Callback Pattern , qui est devenu une convention que les auteurs et les contributeurs ont adoptée pour leurs propres packages et bibliothèques.

Les promesses et la chaîne de rappel sans fin

Alors que le développement Web faisait face à des problèmes plus complexes à résoudre, le besoin de meilleurs artefacts asynchrones est apparu. Si nous regardons le dernier extrait de code, nous pouvons voir un chaînage de rappel répété qui ne s'adapte pas bien à mesure que le nombre de tâches augmente.

Par exemple, ajoutons seulement deux étapes supplémentaires, la lecture des fichiers et le prétraitement des styles.

 const { mkdir, writeFile, readFile } = require('fs'); const less = require('less') readFile('./main.less', 'utf-8', (error, data) => { if (error) throw error less.render(data, (lessError, output) => { if (lessError) throw lessError mkdir('./assets/', (dirError) => { if (dirError) throw dirError writeFile('assets/main.css', output.css, 'utf-8', (writeError) => { if (writeError) throw writeError console.log('stylesheet created'); }) }) }) })

Nous pouvons voir comment, à mesure que le programme que nous écrivons devient plus complexe, le code devient plus difficile à suivre pour l'œil humain en raison du chaînage de rappels multiples et de la gestion répétée des erreurs.

Promesses, emballages et modèles de chaîne

Promises n'ont pas reçu beaucoup d'attention lorsqu'elles ont été annoncées pour la première fois comme le nouvel ajout au langage JavaScript, elles ne sont pas un nouveau concept car d'autres langages avaient des implémentations similaires des décennies auparavant. La vérité est qu'ils se sont avérés avoir beaucoup changé la sémantique et la structure de la plupart des projets sur lesquels j'ai travaillé depuis son apparition.

Promises non seulement introduit une solution intégrée permettant aux développeurs d'écrire du code asynchrone, mais ont également ouvert une nouvelle étape dans le développement Web servant de base de construction pour les nouvelles fonctionnalités ultérieures de la spécification Web, telles que fetch .

La migration d'une méthode d'une approche de rappel vers une approche basée sur des promesses est devenue de plus en plus courante dans les projets (tels que les bibliothèques et les navigateurs), et même Node.js a commencé à migrer lentement vers eux.

Enveloppons, par exemple, la méthode readFile de Node :

 const { readFile } = require('fs'); const asyncReadFile = (path, options) => { return new Promise((resolve, reject) => { readFile(path, options, (error, data) => { if (error) reject(error); else resolve(data); }) }); }

Ici, nous obscurcissons le rappel en l'exécutant à l'intérieur d'un constructeur Promise, en appelant la resolve lorsque le résultat de la méthode réussit et en le reject lorsque l'objet d'erreur est défini.

Lorsqu'une méthode renvoie un objet Promise , nous pouvons suivre sa résolution réussie en passant une fonction à then , son argument est la valeur à laquelle la promesse a été résolue, dans ce cas, data .

Si une erreur a été renvoyée pendant la méthode, la fonction catch sera appelée, si elle est présente.

Remarque : Si vous avez besoin de comprendre plus en profondeur le fonctionnement des promesses, je vous recommande l'article "JavaScript Promises : An Introduction" de Jake Archibald qu'il a écrit sur le blog de développement Web de Google.

Nous pouvons maintenant utiliser ces nouvelles méthodes et éviter les chaînes de rappel.

 asyncRead('./main.less', 'utf-8') .then(data => console.log('file content', data)) .catch(error => console.error('something went wrong', error))

Le fait de disposer d'un moyen natif de créer des tâches asynchrones et d'une interface claire pour suivre ses éventuels résultats a permis à l'industrie de sortir du modèle d'observateur. Ceux basés sur des promesses semblaient résoudre le code illisible et sujet aux erreurs.

Comme une meilleure mise en évidence de la syntaxe ou des messages d'erreur plus clairs aident lors du codage, un code plus facile à raisonner devient plus prévisible pour le développeur qui le lit, avec une meilleure image du chemin d'exécution, plus il est facile d'attraper un éventuel piège.

L'adoption des Promises était si globale dans la communauté que Node.js a rapidement publié des versions intégrées de ses méthodes d'E/S pour renvoyer des objets Promise, comme les importer des opérations de fichiers à partir de fs.promises .

Il a même fourni un promisify pour encapsuler toute fonction qui suivait le modèle de rappel d'erreur en premier et le transformer en un modèle basé sur la promesse.

Mais les promesses aident-elles dans tous les cas ?

Réimaginons notre tâche de prétraitement de style écrite avec Promises.

 const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') readFile('./main.less', 'utf-8') .then(less.render) .then(result => mkdir('./assets') .then(() => writeFile('assets/main.css', result.css, 'utf-8')) ) .catch(error => console.error(error))

Il y a une nette réduction de la redondance dans le code, en particulier autour de la gestion des erreurs car nous nous appuyons maintenant sur catch , mais Promises n'a pas réussi à fournir une indentation de code claire qui se rapporte directement à la concaténation des actions.

Ceci est en fait réalisé sur la première instruction then après l'appel de readFile . Ce qui se passe après ces lignes est la nécessité de créer une nouvelle portée où nous pouvons d'abord créer le répertoire, pour ensuite écrire le résultat dans un fichier. Cela provoque une rupture dans le rythme d'indentation, ne facilitant pas la détermination de la séquence d'instructions au premier coup d'œil.

Une façon de résoudre ce problème consiste à pré-préparer une méthode personnalisée qui gère cela et permet la concaténation correcte de la méthode, mais nous introduireions une complexité supplémentaire dans un code qui semble déjà avoir ce dont il a besoin pour accomplir la tâche nous voulons.

Remarque : Tenez compte du fait qu'il s'agit d'un programme d'exemple, et nous contrôlons certaines des méthodes et elles suivent toutes une convention de l'industrie, mais ce n'est pas toujours le cas. Avec des concaténations plus complexes ou l'introduction d'une bibliothèque avec une forme différente, notre style de code peut facilement casser.

Heureusement, la communauté JavaScript a appris à nouveau des syntaxes d'autres langages et a ajouté une notation qui aide beaucoup dans ces cas où la concaténation de tâches asynchrones n'est pas aussi agréable ou simple à lire que le code synchrone.

Asynchrone et attendre

Une Promise est définie comme une valeur non résolue au moment de l'exécution, et la création d'une instance d'une Promise est un appel explicite de cet artefact.

 const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') readFile('./main.less', 'utf-8') .then(less.render) .then(result => mkdir('./assets') .then(() => { writeFile('assets/main.css', result.css, 'utf-8') })) .catch(error => console.error(error))

Dans une méthode asynchrone, nous pouvons utiliser le mot réservé await pour déterminer la résolution d'une Promise avant de poursuivre son exécution.

Revoyons ou codez un extrait en utilisant cette syntaxe.

 const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') async function processLess() { const content = await readFile('./main.less', 'utf-8') const result = await less.render(content) await mkdir('./assets') await writeFile('assets/main.css', result.css, 'utf-8') } processLess()

Remarque : Notez que nous devions déplacer tout notre code vers une méthode car nous ne pouvons pas utiliser l' await en dehors de la portée d'une fonction asynchrone aujourd'hui.

Chaque fois qu'une méthode asynchrone trouve une instruction await , elle arrête de s'exécuter jusqu'à ce que la valeur ou la promesse en cours soit résolue.

Il y a une conséquence claire de l'utilisation de la notation async/wait, malgré son exécution asynchrone, le code semble être synchronous , ce que les développeurs sont plus habitués à voir et à raisonner.

Qu'en est-il de la gestion des erreurs ? Pour cela, nous utilisons des déclarations présentes depuis longtemps dans le langage, try and catch .

 const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less'); async function processLess() { try { const content = await readFile('./main.less', 'utf-8') const result = await less.render(content) await mkdir('./assets') await writeFile('assets/main.css', result.css, 'utf-8') } catch(e) { console.error(e) } } processLess()

Nous sommes assurés que toute erreur générée dans le processus sera gérée par le code à l'intérieur de l'instruction catch . Nous avons un endroit central qui s'occupe de la gestion des erreurs, mais maintenant nous avons un code plus facile à lire et à suivre.

Avoir des actions conséquentes qui ont renvoyé une valeur n'a pas besoin d'être stocké dans des variables comme mkdir qui ne cassent pas le rythme du code ; il n'est pas non plus nécessaire de créer une nouvelle étendue pour accéder à la valeur de result dans une étape ultérieure.

Il est prudent de dire que les promesses étaient un artefact fondamental introduit dans le langage, nécessaire pour activer la notation asynchrone/attente en JavaScript, que vous pouvez utiliser à la fois sur les navigateurs modernes et les dernières versions de Node.js.

Note : Récemment dans JSConf, Ryan Dahl, créateur et premier contributeur de Node, a regretté de ne pas s'en tenir à Promises lors de son développement initial, principalement parce que l'objectif de Node était de créer des serveurs pilotés par les événements et une gestion de fichiers pour lesquels le modèle Observer servait mieux.

Conclusion

L'introduction de Promises dans le monde du développement Web a changé la façon dont nous mettons les actions en file d'attente dans notre code et a changé notre façon de raisonner sur l'exécution de notre code et la façon dont nous créons des bibliothèques et des packages.

Mais s'éloigner des chaînes de rappel est plus difficile à résoudre, je pense que devoir passer une méthode pour then ne nous a pas aidés à nous éloigner du train de pensée après des années d'accoutumance au modèle d'observateur et aux approches adoptées par les principaux fournisseurs dans la communauté comme Node.js.

Comme le dit Nolan Lawson dans son excellent article sur les mauvaises utilisations dans les concaténations Promise, les vieilles habitudes de rappel ont la vie dure ! Il explique plus tard comment échapper à certains de ces pièges.

Je pense que les promesses étaient nécessaires comme étape intermédiaire pour permettre une manière naturelle de générer des tâches asynchrones, mais ne nous ont pas beaucoup aidés à avancer sur de meilleurs modèles de code, parfois vous avez en fait besoin d'une syntaxe de langage plus adaptable et améliorée.

Alors que nous essayons de résoudre des énigmes plus complexes en utilisant JavaScript, nous voyons le besoin d'un langage plus mature et nous expérimentons des architectures et des modèles que nous n'avions pas l'habitude de voir sur le Web auparavant.

"

Nous ne savons toujours pas à quoi ressemblera la spécification ECMAScript dans les années, car nous étendons toujours la gouvernance JavaScript en dehors du Web et essayons de résoudre des énigmes plus compliquées.

Il est difficile de dire maintenant ce dont nous aurons exactement besoin du langage pour que certains de ces puzzles se transforment en programmes plus simples, mais je suis satisfait de la façon dont le Web et JavaScript lui-même font bouger les choses, essayant de s'adapter aux défis et aux nouveaux environnements. J'ai l'impression qu'actuellement, JavaScript est un endroit convivial plus asynchrone que lorsque j'ai commencé à écrire du code dans un navigateur il y a plus de dix ans.

Lectures complémentaires

  • "Les promesses JavaScript : une introduction", Jake Archibald
  • "Promise Anti-Patterns", une documentation de la bibliothèque Bluebird
  • "Nous avons un problème avec les promesses", Nolan Lawson