Intermédiaire

Une autre manière d’écrire du code asynchrone en Javascript

java-scriptCes dernières années, l’écriture de code asynchrone a été grandement facilitée par l’utilisation des promesses. Elles permettent de multiplier les appels asynchrones tout en écrivant du code lisible, maintenable et en résolvant le problème de Callback Hell.

Le langage Javascript s’est enrichi avec les spécifications Ecmascript 6 (ou ES2015), ES2016 et la dernière en date ES2017, dont certaines fonctionnalités peuvent déjà être utilisées. Nous verrons à travers ce billet que les nouveautés apportées au langage permettent d’écrire du code asynchrone autrement (d’une façon similaire à l’écriture de code synchrone). Au final, on ne fera pas plus de choses qu’avec les callbacks et les promesses, on écrira juste du code d’une façon différente que certains jugeront plus concis et plus lisible.

Les plus curieux d’entre vous pourront retrouver les exemples de code présentés dans cet article sur le github Soat

Les promesses

Quelque fois, les promesses ne règlent pas tous les problèmes

	childService.findOne(childId).then(function(child) {
		return childService.findFather(child);
	}).then(function(father) {
		// on ne peut pas accéder à la variable child
		return yearsBetween(father.birthDate, child.birthDate);
	});

Ici, nous ne pouvons pas accéder au résultat de la promesse précédente. Voici une solution qui fonctionnerait, toutefois on retombe sur le même problème que les callbacks imbriqués.

	childService.findOne(childId).then(function(child) {
		return childService.findFather(child).then(function(father) {
			return yearsBetween(father.birthDate, child.birthDate);
		});
	});

Avec les promesses, on enchaîne les appels à la fonction then. Le code est plus lisible qu’en utilisant des callbacks imbriqués dans d’autres callbacks. Cependant, Ecmascript 6 enrichit le langage javascript de telle sorte que nous pourrons gérer l’asynchronisme d’une manière différente.

Itérateur et Itérable

Avant d’attaquer l’écriture de code asynchrone, quelques fonctionnalités introduites par Ecmascript 6 doivent être assimilées, notamment les notions d’itérateur, d’itérable et de générateur.

Un itérateur est un objet disposant d’une fonction next permettant de parcourir un ensemble (fini ou infini) d’éléments. Cette fonction doit retourner un objet contenant les propriétés value (correspondant à l’élément courant) et done (indiquant s’il reste des éléments à parcourir).

Un itérateur parcourant les entiers de 0 à 10


  function makeIterator() {
		var elem = 0;
		
		var iterator = {
			next: function() {
				return elem <= 10 ?
					{ value: elem++, done:false } :
					{ done:true };
			}
		};

		return iterator;
	}

	var myIterator = makeIterator();

	console.log(myIterator.next().value); // 0
	console.log(myIterator.next().value); // 1
	console.log(myIterator.next().value); // 2
	...
	console.log(myIterator.next().value); // 10
	console.log(myIterator.next().done); // true

ES6 définit également la notion d’Itérable. Un itérable est un objet décrivant comment un ensemble d’éléments doit être parcouru. Cet objet exposera une propriété ayant pour clé Symbol.iterator et pour valeur une fonction retournant un itérateur. C’est l’équivalent d’une fabrique d’itérateurs.

La boucle for...of permet de parcourir un objet itérable.

var myIterable = {};
myIterable[Symbol.iterator] = makeIterator;

for(var val of myIterable) {
	console.log(val);
}

// Print 0 1 2 3 ... 10

A noter que certains types natifs comme Array, Map sont des itérables, on pourra donc utiliser for...of pour parcourir les éléments d’un tableau.

for (var elem of [3, 4, 7]) {
	console.log(elem);
}

// Print 3 4 7

Les générateurs

Un générateur est une fonction particulière permettant de créer des itérateurs. En utilisant un générateur, on pourra réécrire la fonction makeIterator plus simplement à l’aide des mots-clés function* et yield.

   function* makeIterator() {
		for(var elem = 0; elem <= 10; elem++) {
			yield elem;
		}
	}

	var myIterator = makeIterator();

	console.log(myIterator.next().value); // 0
	console.log(myIterator.next().value); // 1
	console.log(myIterator.next().value); // 2

La fonction doit être déclarée avec la syntaxe function*, ensuite on utilise le mot-clé yield pour définir la valeur qui sera retournée lors de l’appel à la fonction next.

L’objet retourné par cette fonction définit la propriété Symbol.iterator (comme pour les itérables), ce qui permet d’utiliser la boucle for...of comme ceci :

	for(elem of makeIterator()) {
		console.log(elem);
	}
	//Print 0 1 2 3 ... 10

Les générateurs offrent également la possibilité de transmettre un paramètre lors de l’appel à la fonction next. Sa valeur pourra être récupérée dans le corps de la fonction génératrice.


	function* gen() {
		var valFromNext = yield 0;
		console.log(`valFromNext = ${valFromNext}`);
	}
	
	var it = gen();
	
	console.log(it.next().value);
	// Print 0
	console.log(it.next(1).done);
	// Print valFromNext = 1
	// Print true

De nouvelles possibilités pour l’écriture de code asynchrone

– Les itérateurs, les générateurs, c’est bien joli, mais en quoi cela nous permet d’écrire du code asynchrone ?

L’idée est à la suivante, on se sert de yield pour lancer l’exécution d’une fonction asynchrone et dans le callback on appelle la fonction next du générateur pour poursuivre l’exécution du programme.

var fs = require('fs');
var iterator =  fileReader();

function readFile(path, encoding) {
	fs.readFile(path, encoding, function(err, data) {
		if(err) return iterator.throw(err);
		iterator.next(data);
	});
}

function* fileReader() {
	var file1 = yield readFile('file1.txt', 'utf-8');
	console.log(file1);
	var file2 = yield readFile('file2.txt', 'utf-8');
	console.log(file2);
}

// Début du parcours de l'itérateur
iterator.next();

Cette méthode implique de déclarer et d’initialiser le générateur dans le scope parent. Ce n’est pas optimal, le code précédent était juste là à titre d’exemple, “en vrai” on englobera notre générateur dans une fonction dont le rôle sera de parcourir ses éléments (un peu comme la boucle for...of).

var fs = require('fs');

function run(generator) {
  var iterator = generator(go); 

  function go(err, data) {
  	if(err) return iterator.throw(err);
  	iterator.next(data);
  }
	
  // Début du parcours de l'itérateur
  go();
}

function* fileReader(cb) {
   try {
		var file1 = yield fs.readFile(`file1.txt`, `utf-8`, cb);
		console.log(file1);
		var file2 = yield fs.readFile('file2.txt', 'utf-8', cb);
		console.log(file2);
	} catch (err) {
		// Gérer exception
	}
}

run(fileReader);

Ici la fonction génératrice prend en paramètre le callback et la méthode run permet de manipuler le générateur. On pourra réécrire cette fonction afin de gérer les promesses.


var fsp = require('fs-promise');

function run(generator) {
	var it = generator();

	function go(elem) {
		if(elem.done) return;

		elem.value.then(function success(data) {
			return go(it.next(data));
		}, function error(err) {
			return go(it.throw(err));
		});
	}

	go(it.next());
}

function* fileReader() {
   try {
		var file1 = yield fsp.readFile(`file1.txt`, `utf-8`);
		console.log(file1);
		var file2 = yield fsp.readFile(`file2.txt`, `utf-8`);
		console.log(file2);
	} catch (err) {
		// Gérer exception
	}
}

run(fileReader);

Dans la méthode go, on s’attend à recevoir un objet de type {done: false, value: promise} ou {done: true}. Lorsque la promesse est résolue, on effectue un appel à la fonction go en passant en paramètre le prochain élément de l’itérateur.

Co pour nous faciliter la tâche

– Ok, mais cela va vite devenir contraignant si on doit écrire tout ce code à chaque fois

Certaines librairies comme co ou bluebird implémentent le même mécanisme que la fonction précédente run. Elles nous évitent le travail fastidieux réalisé lors de l’étape précédente, ce qui facilite grandement l’utilisation des générateurs.

var co = require('co');
var fsp = require('fs-promise');

function* gen() {
    var file1 = yield fsp.readFile(`file1.txt`, `utf-8`);
	console.log(file1);
	var file2 = yield fsp.readFile(`file2.txt`, `utf-8`);
	console.log(file2);
}

// la fonction co retourne une promesse
co(gen)
.then(function() {
	console.log('success');
}, function(err) {
	console.log(err);
});

co gère les promesses, c’est pourquoi dans l’exemple précédent, j’ai utilisé la librairie fs-promise pour la lecture d’un fichier.

Nous ne sommes cependant pas obligés d’utiliser les promesses, co gère également un type de fonction particulier appelé Thunk. On pourra écrire yield myFunctionmyFunction prendra un seul paramètre en entrée correspondant au callback. Voici un exemple :

var co = require('co');
var fs = require('fs');

function makeReadFileThunk(path, encoding) {
	return function(callback) {
		fs.readFile(path, encoding, callback);
	};
}

function* gen() {
	var file1 = yield makeReadFileThunk(`file1.txt`, `utf-8`);
	console.log(file1);
	var file2 = yield makeReadFileThunk(`file2.txt`, `utf-8`);
	console.log(file2);
}

co(gen)
.then(function() {
    console.log('success');
}, function(err) {
    console.log(err);
});

Petit rappel sur Thunk

Je ne saurais expliquer l’origine de ce mot. Dans tous les cas, vous pouvez penser à Thunk comme à un wrapper dans lequel les paramètres de la fonction-cible seront sauvegardés.

Un thunk synchrone

	function mult(x, y) {
		return x * y;
	}
	
	function thunk() {
		return mult(2, 3);
	}
	
	thunk(); // return 6

En mode asynchrone la fonction thunk prendra un seul argument correspondant au callback.

Un thunk asynchrone

	function thunk(callback) {
		fs.readFile('file1.txt', 'utf-8', callback);
	}
	
	thunk(function(err, contentFile) {
		if(err) return console.log(err);
		console.log(contentFile);
	});

Là encore, il existe une librairie thunkify nous facilitant la tâche pour créer des fonctions Thunk.

var co = require('co');
var fs = require('fs');
var thunkify = require('thunkify');

var makeReadFileThunk = thunkify(fs.readFile);

function* gen() {
	try {
		var file1 = yield makeReadFileThunk(`file1.txt`, `utf-8`);
		console.log(file1);
		var file2 = yield makeReadFileThunk(`file2.txt`, `utf-8`);
		console.log(file2);
	} catch (err) {
		// Gérer exception
	}
}

co(gen);

La fonction makeReadFileThunk est équivalente au code suivant


	function makeReadFileThunk(file, encoding) {
		return function(callback) {
			fs.readFile(file, encoding, callback);
		}
	}

Dans l’exemple précédent, il faut bien avoir à l’esprit que l’appel asynchrone ne s’effectue pas lors de l’appel à l’instruction makeReadFileThunk('file1.txt', 'utf-8'); puisque cet appel retourne une fonction prenant en argument le callback. La lecture asynchrone du fichier débutera au moment où la librairie co exécutera la fonction retournée par makeReadFileThunk.

Vous aurez peut être remarqué que tout ceci fonctionnera uniquement si le callback est du type function callback(err, data), avec un premier argument pour l’erreur et un second pour la donnée retournée.

Et dans un navigateur ?

Jusqu’ici, j’utilisais co au sein de Node.js. On peut également utiliser cette librairie dans notre navigateur, voici un exemple d’utilisation avec l’api fetch.

<script src='https://cdnjs.cloudflare.com/ajax/libs/co/4.1.0/index.min.js'></script>
<script>
 co(function* () {
	var resp1 = yield fetch('test1.txt');
	if (resp1.status === 200) {
		console.log(yield resp1.text());
	}

	var resp2 = yield fetch('test2.txt');
	if (resp2.status === 200) {
		console.log(yield resp2.text());
	}
 });
</script>

Ce code pourra être exécuté dans un navigateur supportant les générateurs et l’api fetch. Cependant, ces fonctionnalités sont incompatibles avec de nombreux navigateurs. Nous pouvons utiliser Can I Use pour visualiser les navigateurs implémentant l’api fetch et kangax pour obtenir une table de compatibilité des fonctionnalités ES6. Par exemple, si j’essaye avec IE11, le code précédent ne fonctionnera pas.

Nous pouvons utiliser conjointement la librairie babel avec des pollyfills pour rendre ce code compatible Ecmascript 5,

Dans un premier temps, on utilise babel pour transformer la syntaxe de notre code (function* et yield ne sont pas compatibles Ecmascript 5)

babel myScript-es6.js -o myScript-es5.js --presets=es2015

Cette commande génère un fichier compatible Ecmascript 5, cependant si nous incluons seulement le script généré, nous obtiendrons une erreur car le code transformé par babel necéssite un pollyfill pour pouvoir s’exécuter correctement.

Pour rappel, babel fonctionne avec des plugins (un plugin décrit comment le code doit être transformé). Un preset permet de regrouper un ensemble de plugins, ici --presets=es2015 englobera tous les plugins permettant de transformer du code Ecmascript 6 (ES2015) en Ecmascript 5.

La transformation Ecmascript 5 n’est pas magique. En effet, la commande précédente permet uniquement de transformer la syntaxe du code, alors que babel définit en plus un ensemble de fonctions utiles pour pouvoir émuler les dernières fonctionnalités d’Ecmascript. On parle alors de polyfill.

Au final pour faire fonctionner notre code précédent dans un navigateur IE11, nous inclurons les polyfills babel et fetch, la librairie co ainsi que le fichier généré myScript-es5.js.

<head>
	<script src='https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/6.20.0/polyfill.js'></script>
	<script src='https://cdnjs.cloudflare.com/ajax/libs/fetch/2.0.1/fetch.min.js'></script>
	<script src='https://cdnjs.cloudflare.com/ajax/libs/co/4.1.0/index.min.js'></script>
	<script src='myScript-es5.js'></script>
</head>

Bien évidemment dans un vrai projet, nous n’utiliserons pas babel en ligne de commande, nous l’associerons plutôt à un outil de build comme webpack ou gulp.

Async Await

ES2017 met à disposition les mots-clés async et await, cela permet d’utiliser nativement le mécanisme précédent sans ajouter de librairie tierce comme co.

async function main() {
	var resp1 = await fetch('test1.txt');
	if (resp1.status === 200) {
		console.log(await resp1.text());
	}

	var resp2 = await fetch('test2.txt');
	if (resp2.status === 200) {
		console.log(await resp2.text());
	}
}

main()
.then(function() {
    console.log('success');
}, function(err) {
    console.log(err);
});

Ici, il suffit juste d’appeler la fonction main, nous n’avons pas besoin d’inclure de librairie supplémentaire pour parcourir le générateur.

À l’heure où ces lignes sont écrites, ES2017 n’est pas encore finalisé, cependant certaines propositions comme async await peuvent être utilisées grâce à babel.

Cette fois-ci, au lieu de saisir la configuration en ligne de commande, nous allons créer un fichier de configuration .babelrc

{
  'plugins': ['syntax-async-functions','transform-regenerator']
}

Les deux plugins précédents vont permettre de transformer la syntaxe async await en code Ecmascript 5.

babel main.js -o main-es5.js

Dans le fichier html, nous devrons inclure les polyfills babel et fetch ainsi que le fichier généré

<head>
	<script src='https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/6.20.0/polyfill.js'></script>
	<script src='https://cdnjs.cloudflare.com/ajax/libs/fetch/2.0.1/fetch.min.js'></script>
	<script src='main-es5.js'></script>
</head>

Conclusion

Les dernières fonctionnalités définies dans Ecmascript offrent une alternative pour gérer l’asynchronisme. La syntaxe change mais le résultat reste le même. Certains apprécieront le fait de pouvoir écrire un code asynchrone de la même manière qu’un code synchrone, d’autres au contraire privilégieront l’utilisation des callbacks et des promesses. Libre à chacun de l’utiliser ou non, dans tous les cas vous savez désormais quel mécanisme se cache derrière la syntaxe async await.

© SOAT
Toute reproduction interdite sans autorisation de la société SOAT

Nombre de vue : 1160

AJOUTER UN COMMENTAIRE