Aujourd’hui, le dialogue client-serveur après que la page soit chargée est au centre du JavaScript moderne.
C’est même la raison principale de la poussée de l’asynchronicité partout, au coeur du langage : on a besoin de faire des requêtes, tout le temps, et de ne pas bloquer le navigateur/le script pendant que celles-ci se fassent.
Au départ, dans JavaScript, on ne pouvait pas faire de requête vers l’extérieur ! Ce bon vieux langage était cantonné à la modification de la page active, le style, l’affichage et la réaction aux évènements. Très rapidement (dès la fin des années 90), il est devenu nécessaire de pouvoir charger des données depuis le serveur après rendu de la page, pour proposer du nouveau contenu à l’utilisateur sans perdre l’état de la page en cours.
XMLHttpRequest : une API propulsée par Microsoft
En fin 1998 (la préhistoire, Windows 95 était le système le plus populaire et Apple venait de lancer l’iMac transparent), Microsoft lance… Internet Explorer 5 !
Ce navigateur, à l’époque en pleine première guerre des navigateurs, embarquait les fameux plugins ActiveX propriétaires (les vieux savent), nativement.
L’un d’entre eux était Microsoft.XMLHTTP
, précurseur d’XMLHttpRequest
. Disposant d’une API comparable, la standardisation ayant repris ce qu’avait fait Microsoft, il permettait de manière synchrone (bloquante, donc) ou asynchrone de faire des requêtes HTTP vers l’extérieur. La réponse pouvait être facilement décodable si de l’XML était transmis (XML étant le format en vogue à l’époque).
Cette API a été un poil étoffée au cours du temps, notamment avec le décodage du JSON proposé ensuite, mais est restée globalement inchangée. Comprenez donc que ce truc date un max.
Et soyons clair : quand un truc dans JavaScript date, en général, ça pue (comme Date
, qui date, vieille relique de Java 1.6).
Note de l’auteur : Le nom est une véritable énigme !
XML
est un acronyme écrit en caps, mais pasHttp
juste après. Inconcevable. Comment ce truc a t’il pu sortir comme ça ?
Un exemple ? Oui, un petit exemple fait pas de mal.
// D'abord, il faut instancier var xhr = new XMLHttpRequest(); // On créé la requête // method, href, is_async xhr.open('GET', 'https://google.fr', true); // Maintenant, on peut utiliser onload, mais c'est "récent" (IE10) xhr.onreadystatechange = function () { if (xhr.readyState === 4) { if (!xhr.status || xhr.status >= 400) { // Erreur ! console.log('Erreur:', xhr.status, '; Contenu:', xhr.responseText); } else { console.log('Code HTTP:', xhr.status, '; Contenu:', xhr.responseText); // Si la réponse est de l'XML, il est possible d'obtenir un objet // contenant l'arbre parsé avec xhr.responseXML } } // Si readyState n'est pas à 4, la requête n'est pas encore finie }; // xhr.send prend un paramètre optionnel qui est le corps de la requête HTTP. // Cela peut être une string, mais aussi un FormData ou un Blob :) xhr.send();
Pourquoi changer
Ça pue tant que ça ?
XMLHttpRequest
n’est pas du tout à jeter !
Ce n’est pas pour rien que 90% des librairies aidant à faire de l’AJAX l’utilisent (jQuery, Axios, entre autres). Bon, aussi parce qu’il est supporté partout.
Mais outre ça, il dispose en plus de fonctionnalités exclusives (presque World Premiere, mais en 1998) que n’a même pas ce dont on va parler aujourd’hui, fetch
.
Bah alors, qu’est-ce que tu racontes ? Un remplaçant qui supplante même pas ce qu’il remplace, c’est quand même dingue…
Oh vous savez, dans l’informatique, tout est possible.
Regardez les paramètres de Windows 8/10 : 8 ans qu’ils sont là, et 8 ans qu’il faut encore l’ancien panneau de configuration pour gérer la moitié de l’OS (mais on s’égare) !
Alors, pourquoi fetch ?
- C’est plus court
C’est un avantage comme un autre. Vous noterez que fetch
compte 5 caractères quand XMLHttpRequest
en compte 14.
En vrai, le code est en général plus court à écrire avec fetch
qu’avec l’ancien (et plus clair). Surtout si le reste de votre code-base est déjà écrit comme du JS moderne.
Même plus que ça, ça ressemble presque au système d’AJAX proposé par jQuery (en moins poussé, on reste sur un truc standard).
- C’est purement asynchrone
Pas de mode synchrone avec fetch
! Tout est, 100%, Promise
-based. Vous pouvez profiter de toute la puissance des promesses dont on parlé juste dans l’article précédent !
- C’est plus rapide
Vous allez le voir, effectuer des requêtes avec fetch
est généralement plus rapide. Ceci est dû au fait que la réponse est un Stream
JavaScript natif (et non pas un chaîne de caractères qui charge peu à peu).
- Ça marche partout
Non, je déconne. On est en JavaScript là.
Côté positif : ça marche dans les navigateurs (de moins de 4 ans) et dans deno
. Et un polyfill ultra-léger existe pour Node.js !
Disponibilité
Dans les grandes lignes, on peut dire maintenant que c’est disponible de manière safe partout.
Minimum :
- Chrome 42 (2015)
- Firefox 39 (2015)
- Safari 10.3 (2017)
- Edge 14 (2016)
Petit mot sur une feature plus récente dont on parlera plus tard, AbortController
.
Minimum :
- Chrome 66 (2018)
- Firefox 57 (2017)
- Safari 12.1 (2019)
- Edge 16 (2017)
Utilisons ça
Après tout ce blabla, c’est parti pour s’exercer sur fetch
!
fetch
est disponible sans rien faire dans votre navigateur. Si vous utilisez Node, installez node-fetch.
// -- Node.js SEULEMENT ! -- const fetch = require('node-fetch'); // Si ça vous amuse, rendez le global Object.defineProperty(global, 'fetch', { value: fetch });
Faire une requête
La syntaxe est la suivante : fetch(url, options?)
.
const response = fetch('/flower-list.json');
fetch
renvoie une Promise
contenant un objet Response. Utilisez async
/await
ou unwrappez le avec .then
.
Attention ! La promesse se résoud quand le serveur a commencé à répondre (entêtes reçues) et non pas quand la réponse est entièrement téléchargée.
response.then(r => { // r est un objet Response if (r.ok) { // Si le statut HTTP est ok (200 et similaires) console.log('Le serveur a répondu correctement. Téléchargement de la réponse...'); } else { console.log('Erreur, code HTTP : ' + r.status + '. Téléchargement de la réponse...'); } // Faire télécharger la réponse }); response.catch(err => { console.log('Impossible de contacter le serveur. Erreur:', err); });
Deux choses importantes.
-
Cet objet
Response
reçu vous permet d’extraire de la réponse le type d’objet voulu (json, blob ou text, par exemple). Comme je le disais, laResponse
est créée sans avoir reçu tout le contenu ! L’appel à.json()
,.text()
et similaires va renvoyer une nouvelle promesse qui se résoudra quand la réponse sera téléchargée et décodée. -
Vous remarquez le promesse n’échoue pas si le statut relève une erreur ! Elle sera rejetée si et seulement si le serveur ne répond pas du tout (erreur réseau, souvent). Il faut traitement manuellement le cas de l’erreur HTTP par code.
fetch
nous facilite les choses en mettant à dispositionresponse.ok
qui vauttrue
si le code correspond à une réussite.
Ceci dit, décodons notre réponse en reprenant le .then
du dessus.
const decoded_promise = response.then(r => { // r est un objet Response if (r.ok) { // Si le statut HTTP est ok (200 et similaires) console.log('Le serveur a répondu correctement. Téléchargement de la réponse...'); // Faire télécharger la réponse normale (on attend du JSON) return r.json(); } else { console.log('Erreur, code HTTP : ' + r.status + '. Téléchargement de la réponse...'); // Les deux résultats sont wrappés dans une promesse rejetée, pour // différencier erreur de résultat correct. // On ne sait pas comment le serveur a répondu, on check le content-type if (r.headers.get('Content-Type')?.includes('application/json')) { return Promise.reject(r.json()); } // Sûrement une page d'erreur HTML, restons en texte return Promise.reject(r.text()); } }); // On attend désormais la fin de la réponse decoded_promise.then(flower_list => { console.log('Voici la liste de fleurs:', flower_list); }); decoded_promise.catch(err => { console.log('Impossible de récupérer la liste de fleurs :('); console.log('Voici la réponse du serveur:', err); });
Bon. Bah c’est quand même bien long tout ça ? Un poil quand même.
Il est possible d’abréger un peu en hackant :
function jsonOrText(r) { if (r.headers.get('Content-Type')?.includes('application/json')) { return r.json(); } return r.text(); } function rejectOnError(response, wrapped_result) { return response.ok ? wrapped_result : Promise.reject(wrapped_result); } const result = await fetch('/flower_list.json') .then(r => rejectOnError(r, jsonOrText(r))); // result est le JSON décodé de résultat, sinon ça a levé une erreur
Des helpers aidant, c’est déjà un poil plus clair à lire. Dites vous que tout ce bordel serait aussi nécessaire (voire pire…) avec XMLHttpRequest
, mais restons clair que ça reste plus lourd qu’utiliser jQuery ou Axios.
Et encore, on a pas encore essayé d’ajouter des paramètres GET ou un corps à notre requête là.
Personnaliser sa requête
- Ajouter les cookies
fetch
n’envoie pas les cookies par défaut avec la requête si la requête est envoyée à un domaine différent du domaine actuel.
Il faut le préciser explicitement dans les options (indispensable si vous avez un cookie de session par exemple entre plusieurs sous-domaines).
fetch('https://myapi.domain.com/flower_list.json', { credentials: 'include' });
Vous pouvez aussi, au contraire, choisir d’ignorer les cookies avec credentials: 'omit'
.
- Gérer les redirections
Par défaut, fetch
ne suit pas les redirections. Vous pouvez néanmoins lui dire gentillement de le faire :
fetch('/flower_list.json', { redirect: 'follow' });
- Gérer les paramètres GET
Malheureusement, fetch
ne gère pas seul l’ajout de paramètres d’URL nativement.
Vous pouvez néanmoins utiliser un objet natif pour générer des query-string pour vous, URLSearchParams
.
const target = '/flower_list.json'; const params = new URLSearchParams({ count: 5, page: 2 }); // It just works :D const response = await fetch(target + '?' + params.toString());
- Gérer les requêtes comportant un corps
Attention, nouvelle règle ! Pour passer un corps à votre requête, vous allez devoir définir manuellement le bon Content-Type
et passer un paramètre body
dans les options de la requête.
- Si vous souhaitez un
application/x-www-form-urlencoded
(une query-string)
Comme pour les requêtes GET, utilisez URLSearchParams
. Définissez le header qui va bien avec.
const params = new URLSearchParams({ name: 'rose', size: '60cm' }); const response = await fetch('/flower_create.json', { method: 'POST', body: params, // Accepté ici headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, });
- Si vous souhaitez un
application/json
(encodé en JSON)
Encodez votre objet en chaîne de caractères et passez le en tant que corps.
const params = JSON.stringify({ name: 'rose', size: '60cm' }); const response = await fetch('/flower_create.json', { method: 'POST', body: params, // une string fait l'affaire comme corps headers: { 'Content-Type': 'application/json' }, });
- Si vous souhaitez un
multipart/form-data
(formulaire pseudo-binaire)
Ne fonctionne pas sur Node.js, nécessite le paquet form-data.
Créez un objet FormData
, et ajoutez y des propriétés (ou utilisez un formulaire comme source).
const params = new FormData(document.querySelector('form')); params.append('name', 'rose'); const response = await fetch('/flower_create.json', { method: 'POST', body: params, // ok également // Le Content-Type se définit tout seul ! Ne le mettez surtout pas });
- Si vous souhaitez passer des données binaires
Ne fonctionne pas sur Node.js.
Un objet Blob
peut être utilisé comme corps. Vous devez définir le Content-Type
approprié !
const data = JSON.stringify({ name: 'rose', size: '60cm' }); const params = new Blob([data]); const response = await fetch('/flower_create.json', { method: 'POST', body: params, headers: { 'Content-Type': 'application/json', 'Content-Transfer-Encoding': 'binary' }, });
- Et pour toute autre requête…
Merci de vous adresser à l’accueil.
Comme dit dans la partie JSON, n’importe quelle string
fait l’affaire en tant que corps.
const params = 'name:rose|size:60cm'; const response = await fetch('/flower_create.json', { method: 'POST', body: params, headers: { 'Content-Type': 'application/x-personal-query', }, });
Utilisation avancée
On en a terminé avec l’utilisation "standard", mais il peut arriver que des fonctions moins classiques de fetch
soient utiles. Voyons en certaines !
- Lire le corps en tant que stream
fetch
permet de consommer le corps de la réponse (une fois les headers obtenus) avec un système de stream (qui a le luxe d’être incompatible avec les itérateurs asynchrones, bravo l’ECMA).
On obtient un lecteur de stream avec .getReader()
depuis response.body
, et on le consomme avec .read()
qui renvoie un "résultat d’itérateur".
async function readFromStreamReader(reader) { let data = []; // Convertit reader en itérateur asynchrone valide const it = { [Symbol.asyncIterator]() { return this; }, next() { return reader.read(); }, }; for await (const part of it) { data = [...data, ...part]; } return new TextDecoder().decode(Uint8Array.from(data)); } // body n'est pas une promesse, c'est un stream. Il faut néanmoins // attendre que la Response soit obtenue let request = await fetch('/flower.jpg').then(r => r.body); const decoded_full_stream = await readFromStreamReader(request.getReader());
Bon là l’example est bidon, puisqu’on attend que le stream soit consommé pour utiliser la réponse… Mais si vous souhaitez consommer en direct ou simplement avoir de la connexion continue, c’est possible avec fetch
!
- Arrêter une requête lancée
Vous le savez peut-être, mais les promesses ne sont pas annulables. C’est un regret, mais c’est difficile de l’implémenter.
Vous vous doutez donc : Mais comment peut-on faire pour annuler une requête faite avec fetch
?
Accueillez AbortController
! Vous allez voir, c’est un peu verbeux, mais simple d’utilisation.
const controller = new AbortController(); document.querySelector('.cancel-btn').addEventListener('click', () => { // Demande l'annulation au controller controller.abort(); }); const response = fetch('/heavy_content.mp4', { signal: controller.signal, }); console.log('Fetching heavy media... Click on button to cancel.'); try { await response; console.log('Media is downloaded!'); } catch (e) { if (e?.name === 'AbortError') { console.log('Download aborted.'); } else { console.log('Error while fetching media:', e); } }
On est quand même loin du simple xhr.abort()
disponible avec XMLHttpRequest
.
Fonctions manquantes
Malheureusement, comme évoqué au début de l’article, plusieurs fonctions d’XMLHttpRequest
manquent encore à l’appel dans fetch
. Voici les plus importantes :
- Impossible de savoir le pourcentage de téléversement
Lorsque qu’une requête avec un corps s’envoie, il est impossible de savoir à quel point en est l’envoi. Lors de traitement de gros fichiers, cela peut être gênant.
Exemple avec XMLHttpRequest
:
const xhr = new XMLHttpRequest(); xhr.upload.addEventListener("progress", function (evt) { if (evt.lengthComputable) { const percent_complete = evt.loaded / evt.total; console.log('Téléversement complété à', percent_complete, 'pourcent.'); } }, false); xhr.open('POST', '/upload', true); xhr.send(new FormData(document.forms[0]));
- Impossible de savoir le pourcentage de téléchargement
Même chose que précédemment, mais pour le téléchargement. Avec XHR :
const xhr = new XMLHttpRequest(); xhr.addEventListener("progress", function (evt) { if (evt.lengthComputable) { const percent_complete = evt.loaded / evt.total; console.log('Téléchargement complété à', percent_complete, 'pourcent.'); } }, false); xhr.open('GET', '/file.mp4', true); xhr.send();
- Gérer nativement le timeout
XMLHttpRequest
gère nativement les timeout, alors que fetch
non.
const xhr = new XMLHttpRequest(); xhr.timeout = 2000; // Deux secondes xhr.ontimeout = function () { console.log('La requête a timeout.'); }; xhr.open('GET', '/file.mp4', true); xhr.send();
Avec fetch
, il faut tricher avec Promise.race()
…
const _fetch = fetch; window.fetch = function fetchWithTimeout(init, options) { let timeout = undefined; if (options?.timeout) { timeout = options.timeout; } // Permet d'arrêter la requête lancée const controller = new AbortController(); if (!options) { options = {}; } options.signal = controller.signal; const rq = _fetch(init, options); if (timeout && timeout !== Infinity) { // Créer une promesse qui échoue si l'autre promesse n'a pas fini // et que le timeout est terminé const timeout_promise = new Promise((resolve, reject) => { const timeout_id = setTimeout(() => { controller.abort(); reject(new Error('Timeout')); }, timeout); // Quand la requête est finie (rejetée ou non) // On annule le timeout rq.finally(() => { clearTimeout(timeout_id); resolve(); }); }); return Promise.race([rq, timeout_promise]); } return rq; } // On peut maintenant mettre un timeout fetch('/video.mp4', { timeout: 2000 });
C’est chiant, et surtout, pas très propre et optimisé. Personnellement, je ne recommande pas.
Le mot de la fin
Voilà, on a fait le tour de ce qui est intéressant concernant fetch
.
J’espère que c’était clair et que cet article vous a plu !
La prochaine fois, on parlera d’une énorme nouveauté, auparavant très controversée et maintenant largement adoptée, les class
!
À la prochaine 😀
Article précédent : L’asynchronicité • Article suivant : Les classes