JavaScript, les bons éléments, façon 2020 : fetch, remplaçant de XMLHttpRequest

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 pas Http 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.

  1. 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, la Response 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.

  2. 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 à disposition response.ok qui vaut true 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.

  1. 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' },
});
  1. 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' },
});
  1. 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
});
  1. 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' 
  },
});
  1. 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 !

  1. 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 !

  1. 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

Laisser un commentaire