Fetch est une API propre aux navigateurs qui permet de présenter des requêtes à un serveur HTTP par voie de programmation, autrement dit depuis du code JavaScript.
Fetch remplace XMLHttpRequest, dont elle est une version plus moderne. En particulier :
Téléchargement progressif avec fetch ()
- XMLHttpRequest est bien une API asynchrone, mais elle repose sur le recours à des callbacks. Or ECMAScript 2015 a introduit les promesses pour faciliter la programmation asynchrone en échappant au "callback hell", mais XMLHttpRequest n'en tient pas compte.
- Du fait de Same-Origin Policy (SOP), XMLHttpRequest ne permet pas de récupérer une ressource dont l'origine est différente de celle de la page. Or SOP a été tempérée en introduisant Cross-Origin Resource Sharing (CORS), mais XMLHttpRequest n'en tient pas compte.
Pour se familiariser avec Fetch, l'on se propose d'écrire une API qui permet de récupérer une série de ressources tout en étant tenu informé de la progression du téléchargement de chacune.
Le code JavaScript devra être aussi simple que possible, mais on n'échappera pas à la mobilisation de quelques concepts fondamentaux : les promesses, la récursivité, les closures, etc. Pas d'affolement, tout est expliqué en détail !
Noter que contrairement à ce que certains racontent, Fetch étant une API du navigateur, elle ne constitue donc pas un aspect de JavaScript. En conséquence, Fetch n'est pas spécifiée par ECMA, mais par WHATWG - la spécification se trouve d'ailleurs ici.
Cliquez ici pour récupérer tout le code présenté ci-après.
La configuration requise
Pour l'exemple, il s'agira de télécharger simultanément trois fichiers JPEG dont le poids de chacun ne dépasse guère 100 Ko.
Le problème est qu'une ressource servie par un serveur HTTP local est presque instantanément récupérée par
fetch ()
. Dans ce contexte, pour tester le fonctionnement de l'API, c'est-à-dire notamment pouvoir observer la progression d'un téléchargement, il convient de contraindre la bande passante.
Avec Apache, cela peut être fait très simplement à l'aide du module mod_ratelimit.so. Il suffit d'éditer httpd.conf pour activer le chargement du module en décommentant une ligne... :
LoadModule ratelimit_module modules/mod_ratelimit.so
...et de rajouter une directive spécifique au répertoire qui contient les trois JPEG pour limiter la bande passante, par exemple à 20 Ko par seconde :
Listen 81 <VirtualHost localhost:81> DocumentRoot "C:\www" <Directory "C:\www\files"> SetOutputFilter RATE_LIMIT SetEnv rate-limit 20 </Directory> </VirtualHost>
Ne pas oublier de redémarrer Apache pour que cette nouvelle configuration soit prise en compte.
L'interface graphique
S'agissant d'un test, nul besoin de fioritures. L'interface graphique est minimale :
- un bouton pour lancer les téléchargements simultanés ;
- une grille dont chaque ligne comprend le nom du fichier et une barre de progression assortie d'un pourcentage et de la quantité de données téléchargées / à télécharger ;
-
des
<img>
pour afficher les images une fois qu'elles sont toutes téléchargées.
Comme personne ne peut plus l'ignorer, les tableaux HTML ont vécu depuis l'introduction des grilles dans CSS, et c'est tant mieux. Le tableau des téléchargements est donc une composition à base de grilles, sur ce modèle :
<div id="_files" style="display:inline-grid;grid-template-columns:auto auto;gap:5px"></div> <!-- Répété pour chaque fichier --> <div>Fichier machin</div> <div style="display: inline-grid; grid-template-columns: auto auto"> <div style="background-color: red; text-align: center; width: 36px">9%</div> <div>10543 / 116555</div> </div> </div>
Pour que le code soit tout de même un peu flexible, la grille est générée dynamiquement pour s'adapter au nombre de fichiers dont le téléchargement est demandé. C'est le travail de la fonction
loadFiles ()
, seule fonction que l'utilisateur doit adapter en fonction de ses besoins :
function loadFiles (...files) { let tagFiles, tagProgress, tagName, tagBar, tagSize, file, tag, filesLoader; tagFiles = document.getElementById ("_files"), filesGrid = new Array (); tagFiles.innerHTML = ""; for (file of files) { tagName = document.createElement ("div"); tagName.innerHTML = file; tagFiles.appendChild (tagName); tagProgress = document.createElement ("div"); tagProgress.style.display = "inline-grid"; tagProgress.style.gridTemplateColumns = "auto auto"; tagFiles.append (tagProgress); tagBar = document.createElement ("div"); tagBar.style.backgroundColor = "red"; tagBar.style.textAlign = "center"; tagProgress.append (tagBar); tagSize = document.createElement ("div"); tagProgress.appendChild (tagSize); filesGrid.push ({ name: file, tags: { name: tagName, bar: tagBar, size: tagSize } }); } filesLoader = new FilesLoader (showProgress); filesLoader.load (...files); }
Cette fonction prend pour paramètres un nombre quelconque de chemins d'accès aux fichiers à télécharger.
-
au début, la syntaxe rest
...files
permet de regrouper tous ces paramètres dans un tableaufiles
; -
à la fin, la syntaxe spread permet de faire exactement l'inverse, c'est-à-dire éclater le tableau pour passer chacun de ses éléments via un paramètre particulier à
filesLoader.load ()
.
A vrai dire, l'on aurait pu se contenter de passer un tableau aux fonctions, mais c'était l'occasion de tester ces syntaxes, donc pourquoi se priver ? Aussi, cela permet à l'utilisateur de ne pas avoir à passer un tableau qui ne contient qu'un élément quand il n'a besoin de télécharger qu'un seul fichier : il ne transmet jamais que des chemins d'accès à
loadFiles ()
.
loadFiles ()
stocke la description de la grille dans une variable globale filesGrid
. C'est un tableau d'objets de ce type :
{ name: "", // Chemin d'accès au fichier tags: { name: null, // <div> contenant le chemin au fichier bar: null, // <div> représentant la barre de progression size: null // <div> contenant le nombre octets reçus / la taille du fichier } }
La grille est mise à jour en fonction de la progression des téléchargements. Il s'agit simplement de modifier la largeur du
<div>
qui représente la barre de progression, ainsi que le pourcentage d'octets téléchargés et la taille du fichier. Ce travail est assuré par la fonction showProgress ()
:
function showProgress (files) { let file, f, allFetched, tag, blob, url; allFetched = true; for (file of files) { for (f of filesGrid) { if (f.name === file.name) { f.tags.bar.style.width = `${Math.round (400 * file.fetched / file.size)}px`; f.tags.size.innerHTML = `${file.fetched} / ${file.size}`; f.tags.bar.innerHTML = `${Math.round (100 * file.fetched / file.size)}%`; break; } } if (!file.data) allFetched = false; } if (allFetched) { console.log ("Files loaded!"); for (file of files) { blob = new Blob ([file.data], { type: "application/octet-binary" }); url = URL.createObjectURL (blob); tag = document.createElement ("img"); tag.src = url; document.getElementById ("_gallery").appendChild (tag); } } }
Cette fonction prend un tableau
files
en entrée. Il s'agit d'un tableau d'objets qui décrivent l'état du téléchargement des fichiers :
{ name: "", // Chemin d'accès au fichier size: 0, // Taille du fichier fetched: 0, // Nombre d'octets du fichier déjà téléchargés data: null // Uint8Array contenant les données du fichier, une fois son téléchargement terminé }
showProgress ()
contient aussi un bout de code qui affiche les images une fois que leurs fichiers ont tous été téléchargés. Ici, la seule subtilité est l'utilisation d'un blob (Binary Large OBject) pour y copier les données d'un fichier afin de pouvoir les passer à un élément <img>
.
Pourquoi passer par un blob ? En particulier, pourquoi ne pas encoder les données en Base64 et les affecter à la source de l'élément, comme suit :
tag.src = `data:img/jpeg;base64,${bota (file.data)}`;
Le problème, c'est qu'il n'existe aucun moyen pour accéder directement à la représentation binaire des octets qu'un Uint8Array contient - cet objet ne contient aucune méthode ni propriété pour cela. Et quand bien même un Uint8Array repose sur un ArrayBuffer et qu'il est possible d'accéder à ce dernier via
Uint8Array.buffer
, cet ArrayBuffer ne donne pas plus directement accès aux données binaires. Le seul accès direct possible passe par un DataView, mais ce dernier ne permet d'accéder au binaire qu'élément par élément, comme par exemple via DataView.getUint8 ()
pour récupérer la représentation binaire d'un élément représenté comme un entier non signé sur 8 bits.
Dans ces conditions,
bota (file.data)
ne fait qu'encoder en Base64 le produit de l'appel à file.data.toString ()
:
let foo = new Uint8Array ([5, 145 , 161]); console.log (bota (foo)); // Affiche "NSwxNDUsMTYx"
Or "NSwxNDUsMTYx" correspond à 35 2C 31 34 35 2C 31 36 31, soit la chaîne "5,145,161", et non à 05 91 a1, représentation binaire des trois octets fournis.
Plutôt que de coder la génération de la représentation binaire des données du fichier, il est bien plus efficace de passer par un blob pour l'obtenir.
L'API
L'API se présente sous la forme d'un objet
FilesLoader
que l'utilisateur instancie en lui passant une référence sur la fonction qui sert à suivre la progression des téléchargements, c'est-à-dire la fonction showProgres ()
déjà présentée :
function FilesLoader (showProgress) { this.showProgress = showProgress; this.files = null; }
L'objet n'a autrement qu'une propriété,
.files
, un tableau d'objets qui décrivent l'état du téléchargement des fichiers, qui correspond au tableau passé à showProgress ()
dont il a été question plus tôt.
Le gros morceau est la méthode
.load ()
, que l'utilisateur appelle en lui passant la liste des chemins aux fichiers à télécharger :
FilesLoader.prototype.load = function (...files) { return (new Promise ((resolve, reject) => { this.files = new Array (); for (let fileName of files) { // let et non var pour la closure ! let file = { name: fileName, size: 0, fetched: 0, data: null }; this.files.push (file); request = { object: null, done: false }; fetch (fileName, {cache: "no-store" }).then (response => { let reader, chunks; file.size = Number.parseInt (response.headers.get ("Content-Length")); // Closure sur file reader = response.body.getReader (); chunks = new Array (); this.readChunk (file, reader, chunks).then (result => { // Closure sur this (fonction fléchée !) let offset, chunk; file.data = new Uint8Array (file.size); // Closure sur file offset = 0; for (chunk of chunks) { // Closure sur chunks file.data.set (chunk, offset); // Closure sur file offset += chunk.length; } this.showProgress (this.files); // Closure sur this (fonction fléchée !) }); }); } })); };
D'emblée, précisons qu'il n'est pas fait usage du sugar coating de l'asynchrone, c'est-à-dire de async, await et autre yield. Travailler directement avec des promesses a le mérite de la clarté, car s'il simplifie l'écriture, ce sugar coating complique singulièrement la lecture tant il rend la part entre le synchrone et l'asynchrone difficile à faire - par ailleurs, il limite les possibilités, même si ce n'est pas une contrainte en l'espèce.
Le schéma général vise à résoudre un problème des plus intéressants, celui de la boucle asynchrone à condition d'arrêt.
En effet, pour suivre la progression d'un téléchargement, il faut pouvoir accéder au flux des données qui constituent le corps de la réponse à la requête présentée au serveur HTTP pour récupérer le fichier.
Il se trouve qu'à la résolution de la promesse qu'elle retourne,
fetch ()
donne accès un objet Response, dont la propriété Response.body représente le corps de la réponse. C'est un objet ReadableStream qui représente un flux de données. Ce flux peut être lu via un lecteur, un objet ReadableStreamDefaultReader. Les données parviennent par morceaux, ou chunks, qu'il faut lire les uns après les autres en appelant ReadableStreamDefaultReader.read ().
Comme l'on s'en doute,
ReadableStreamDefaultReader.read ()
est une fonction asynchrone. De fait, elle retourne une promesse. A la résolution de cette dernière, le fulfiller reçoit un objet qui se présente ainsi :
-
{ done: false, value: chunk }
si un chunk a été lu ; -
{ done: true, value: null }
s'il n'y a plus de chunk à lire.
Pour résumer, car il est vrai que c'est un peu difficile à suivre, et qu'un dessin vaut mieux qu'un long discours :
Pour finir, il faut préciser que la promesse retournée par
fetch ()
est résolue aussitôt que le navigateur a reçu le code de réponse du serveur, sans attendre que le corps de la réponse soit parvenu en entier. C'est ce qui permet d'enchaîner sur une lecture par chunks du flux du corps de la réponse.
Ainsi, à la résolution de la promesse retournée par
fetch ()
, il faut donc appeler Response.getReader ().read ()
en boucle. Mais cette fonction est donc asynchrone, si bien qu'il faut attendre que la promesse qu'elle retourne soit résolue avant de reboucler. Par ailleurs, à la résolution, il faut décider de reboucler ou non en fonction du résultat transmis au fulfiller.
Ce problème peut être formulé de manière simplifiée. On simule un lecteur de flux par une fonction
readChar ()
asynchrone qui lit un caractère par seconde d'une chaîne "Hello"
, et retourne un objet sur le modèle de ReadableStreamDefaultReader.read ()
:
let sent = "Hello"; let index = 0; function readChar () { return (new Promise ((resolve, reject) => { setTimeout (() => { let result; if (index === sent.length) result = { done: true, value: null }; else { result = { done: false, value: sent[index] }; index ++; } console.log (`READCHAR: { done: ${result.done}, value: ${result.value} }`); resolve (result); }, 1000); })); }
Somme toute, pour lire tous les caractères les uns après les autres, il s'agit d'imbriquer des appels à
reader ()
:
let received = ""; readChar ().then (result => { if (!result.done) { received += result.value; readChar ().then (result => { if (!result.done) { received += result.value; readChar ().then (result => { // Ainsi de suite jusqu'au dernier caractère console.log (`RESULT: ${received}`); }); } }); } });
Ce qu'il est possible d'écrire de manière non plus hiérarchique, mais linéaire, en chaînant ainsi les promesses :
let received = ""; function readNextChar (result) { received += result.value; return (readChar ()); } readChar ().then (readNextChar).then (readNextChar).../* Ainsi de suite jusqu'au dernier caractère */...then (() => console.log (`RESULT: ${received}`));
Evidemment, il est hors de question de dérouler ainsi la boucle, ne serait-ce que parce que son nombre d'itérations est inconnu. La solution passe par la création d'une fonction
readString ()
qui s'appelle elle-même :
let received = ""; function readString () { return (new Promise ((resolve, reject) => { readChar ().then (result => { if (result.done) { resolve (); return; } received += result.value; readString ().then (resolve); }); })); } readString ().then (() => console.log (`RESULT: ${received}`)); console.log ("Preuve que l'appel précédent est asynchrone, ce message est affiché en premier");
Cette nouvelle fonction est bien bien asynchrone, puisqu'elle retourne une promesse - nommons-la PreadString. L'utilisateur qui appelle
readString ()
précise qu'une fonction anonyme doit être exécutée lorsque PreadString est résolue. Cette fonction, qui constitue donc le fulfiller de PreadString, se contente d'afficher dans la console le résultat de la concaténation des caractères reçus.
La fonction immédiatement exécutée par PreadString, son executor, appelle
readChar ()
, qui retourne une autre promesse - nommons-la PreadChar. Cette promesse sera résolue une fois qu'un caractère aura été lu ou qu'il n'en restera plus à lire. A ce moment, le fulfiller de PreadChar étudiera le résultat de cet appel :
-
si le résultat indique un nouveau caractère, le fulfiller l'ajoutera à la liste des caractères déjà lus, puis appelera de nouveau
readString ()
et associera à la nouvelle PreadString retournée un fulfiller qui n'est autre que le resolver de la PreadString courante ; -
si le résultat indique qu'il n'y a plus de caractère, le fulfiller se contentera d'appeler ce resolver et de rendre la main, mettant un terme à la série d'appels récursifs à
readString ()
.
Ainsi, à chaque caractère lu,
readString ()
est appelée, ce qui entraîne la création d'une nouvelle promesse PreadString. Lorsque le dernier caractère est lu, la dernière PreadString est résolue, ce qui entraîne la réoslution de l'avant-dernière PreadString, et ainsi de suite jusqu'à la résolution de la PreadString qui a été retournée à l'utilisateur lors de son appel initial à readString ()
.
Pour visualiser ce dépilement de résolutions, il suffit de modifier légèrement
readString ()
pour afficher une résolution quand elle survient :
function readString () { let n = index; return (new Promise ((resolve, reject) => { readChar ().then (result => { if (result.done) { resolve (); return; } received += result.value; readString ().then (() => { console.log (`${n} resolved`); // Closure sur n resolve (); }); }); })); }
Ce qui donne :
Sans que cela soit trop surprenant, le schéma auquel l'on parvient reproduit celui qui s'impose si l'on utilise non pas des promesses, mais des callbacks :
let index = 0; function readChar (callback) { setTimeout (() => { let result; if (index === sent.length) result = { done: true, value: null }; else { result = { done: false, value: sent[index] }; index ++; } console.log (`READ: { done: ${result.done}, value: ${result.value} }`); callback (result); }, 1000); } let received = ""; function readString (callback) { readChar (result => { if (result.done) { callback (); return; } received += result.value; readString (callback); }); } readString (() => console.log (`RESULT: ${received}`)); console.log ("Preuve que l'appel précédent est asynchrone, ce message est affiché en premier");
Un peu décevant non ? Peut-être, mais les promesses ne servent pas à introduire de nouveaux mécanismes d'asynchronisme, simplement à utiliser ceux qui existent autrement. L'apport des promesses en l'espèce peut ne pas sembler évident, mais c'est que l'on ne se contente pas de faire grand-chose dans cet exemple - on échappe donc à un "callback hell" qui n'est pas apparent.
Pour revenir au téléchargement de fichiers, ce schéma étant compris, il n'y a pas grand-chose à rajouter pour expliquer le fonctionnement de
FilesLoader.load ()
, sinon peut-être ces quelques points :
- le recours à des closures ;
- la gestion du cache ;
- l'assemblage des chunks.
Tout d'abord, les closures. Comme indiqué dans les commentaires du code de
FilesLoader.load ()
, il est fait usage plusieurs fois de closures, c'est-à-dire de références dans un bloc à une variable définie dans un bloc qui l'englobe.
Sur ce point, il faut souligner l'apport de
let
, qui n'est certainement pas utilisé par hasard dans la boucle for (let file of files)
Un exemple permet de comprendre. De seconde en seconde, l'on souhaite afficher successivement les chiffres de 0 à 9. Pour cela, l'on utilise
setTimeout ()
en lui passant une callback qui fait référence à un compteur i
via une closure :
for (var i = 0; i != 10; i ++) setTimeout (() => console.log (i), i * 1000);
Le résultat ? 10 est affiché dix fois ! Le problème est que la déclaration d'une variable avec
var
est "hoisted", c'est-à-dire remontée au début de la fonction, ou à défaut du programme principal - ce qui est le cas ici. C'est donc sur une seule et même variable i
que toutes les callbacks passées à setTimeout ()
récupèrent une closure. Or lorsque la première callback est exécutée, après une seconde, la boucle s'est terminée depuis longtemps, et i
vaut alors 10... pour toutes les callbacks.
A l'inverse, avec
let
... :
for (let i = 0; i != 10; i ++) setTimeout (() => console.log (i), i * 1000);
...les chiffres de 0 à 9 sont affichés seconde après seconde. Pourquoi ? Car
let
crée une variable locale au bloc du for
, et cela à chaque itération. C'est donc sur une variable i
toujours unique que la callback transmise à setTimeout ()
récupère une closure.
Pour terminer sur les closures, l'on ne s'étendra pas sur le fait qu'une fonction fléchée permet de créer une closure sur
this
, ce dont il est fait usage. Pour plus d'informations à ce sujet, se reporter ici.
Ensuite, la gestion du cache. Pour développer cette API et l'interface graphique, il a été nécessaire de procéder à des tests. Or, ces derniers auraient été rendus bien compliqués si lors d'un appel à
fetch ()
pour télécharger de nouveau les mêmes fichiers, le navigateur avait pu piocher lesdits fichiers dans son cache plutôt que de s'adresser au serveur : les téléchargements auraient été instantanés, si bien qu'il aurait été impossible d'en suivre la progression.
Fort heureusement,
fetch ()
peut prendre des options, dont cache: "no-store"
qui permet de demander au navigateur de ne pas cacher la ressource demandée. Cette option a donc été utilisée et figure encore dans le code de l'API. Elle vous sera utile si vous devez retravailler sinon l'API, du moins l'interface graphique. N'oubliez pas de supprimer cette option lors d'un passage en production !
Enfin, l'assemblage des chunks. Comme déjà mentionné, le lecteur de flux retourne un chunk sous la forme d'un objet Uint8Array. L'assemblage des chunks d'un fichier consiste simplement à recopier ces derniers dans un objet Uint8Array de la taille du fichier.
Cette opération est effectuée lorsque la promesse retournée par le premier appel à
FilesLoader.readChunk ()
est résolue, résolution qui intervient après que le dernier chunk a été téléchargé, c'est-à-dire non pas après l'appel au lecteur de flux qui retourne ce dernier chunk, mais après l'appel suivant à ce lecteur, qui retourne { done: true, value: null }
.
L'opération est suivie d'un dernier appel à
showProgress ()
. Pourquoi ? Après tout, comme cela vient d'être précisé, l'opération se déroule après que le dernier chunk a été téléchargé, si bien l'interface graphique doit déjà afficher une barre de progression à 100%.
En fait, c'est pour permettre à l'utilisateur de réagir à la finalisation du téléchargement en deux temps, sur le modèle de ce que permet le lecteur : un avant-dernier appel à
showProgress ()
sans mise à disposition des données du fichier quand leur dernier chunk a été téléchargé, et un dernier appel après avec mise à diposition de ces données. Qui peut le plus peut le moins : l'utilisateur pourra toujours confondre la gestion de ces deux derniers appels si cela lui chante.
Pour en terminer avec la présentation de l'API, il reste tout de même à mentionner la cheville ouvrière
FilesLader.readChunk ()
.
Comme son nom l'indique, cette fonction lit un chunk. C'est là que le flux du corps de la requête, donc le contenu du fichier téléchargé, est accédé via le lecteur :
FilesLoader.prototype.readChunk = function (file, reader, chunks) { return (new Promise ((resolve, reject) => { reader.read ().then (result => { if (result.done) { resolve (); return; } chunks.push (result.value); file.fetched += result.value.length; this.showProgress (this.files); this.readChunk (file, reader, chunks).then (resolve); }) })); };
Sans surprise,
FilesLoader.readChunk ()
appelle showProgress ()
chaque fois que la promesse retournée par le lecteur de flux est résolue, ce qui permet la mise à jour de la barre de progression dans l'interface graphique.
Le programme principal
Le programme principal, écrit par l'utilisateur de l'API qui vient d'être décrite, est vraiment réduit à la partie congrue :
let filesGrid = null; function run () { loadFiles ("./files/test0.jpg", "./files/test1.jpg", "./files/test2.jpg"); }
Cela n'appelle pas de commentaire.
Conclusion
Comme l'on vient de le voir, écrire une API pour permettre de suivre le téléchargement de fichiers est un exercice des plus intéressants, puisqu'il conduit à mobiliser pas mal de concepts fondamentaux de JavaScript.
Evidemment, il serait possible de pousser le bouchon encore plus loin, notamment en faisant de
FilesLoader.readChunk ()
une fonction interne à FilesLoader.load ()
et en jouant des closures pour éliminer les paramètres qui lui sont transmis. Cela pourrait se justifier par le fait que FilesLoader.readChunk ()
n'a pas vraiment de raison d'exister en dehors de FilesLoader.load ()
, mais cela compliquerait un code qui, pour aussi court qu'il soit, est déjà assez riche.
Enfin, il convient de signaler que le téléchargement de ressources assuré par
FilesLoader
ne peut concerner que des ressources dont l'origine est identique à celle du document dans lequel figure le code qui utilise FilesLoader
. Autrement dit, impossible de télécharger https://www.stashofcode.fr/foo.jpg depuis http://localhost:81. Pour le rendre possible, il faudrait utiliser une option de fetch ()
relative à CORS, ce qui impliquerait de gérer les requêtes "preflight", comme expliqué ici.