Téléchargement interactif de fichiers sur le serveur à l'aide de RxJS



Cela fait longtemps que j'ai écrit mon dernier article sur les bases de RxJS. Dans les commentaires, on m'a demandé de montrer des exemples plus complexes qui pourraient être utiles dans la pratique. J'ai donc décidé de diluer un peu la théorie et aujourd'hui nous allons parler de télécharger des fichiers.

Qu'est-ce qu'on fait?

  • Nous allons écrire une petite page où l'utilisateur peut sélectionner un fichier pour le télécharger sur le serveur
  • Ajoutez une barre de progression pour afficher la progression du téléchargement du fichier.
  • Ajoutez la possibilité d'annuler le téléchargement en cliquant sur le bouton Annuler

Pour comprendre cet article, vous aurez besoin des connaissances de base de RxJS. Que sont observables , les opérateurs , ainsi que les opérateurs HOO

Nous ne tirerons pas le chat par la queue et nous passerons immédiatement aux choses sérieuses!

Entraînement


Tout d'abord, nous avons besoin d'un serveur qui peut accepter les demandes de téléchargement de fichiers. N'importe quel serveur peut convenir à cela, j'utiliserai node.js en conjonction avec express et multer pour l'article :

const express = require("express");
const multer  = require("multer");

const app = express();
const upload = multer({ dest:"files" });

app.post("/upload", upload.single("file"), function (req, res) {
    const { file } = req;

    if (file) {
        res.send("File uploaded successfully");
    } else {
        res.error("Error");
    }

});

app.listen(3000);

Créez maintenant une page html sur laquelle nous placerons tous les éléments nécessaires:

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>File uploading</title>
</head>
<body>
    <label for="file">load file: <input id="file" type="file"></label>
    <button id="upload">Upload</button>
    <button id="cancel">Cancel</button>
    <div id="progress-bar" style="width: 0; height: 2rem; background-color: aquamarine;"></div>
    <script src="index.js" type="text/javascript"></script>
</body>
</html>

Maintenant, sur la page, nous avons 4 éléments avec lesquels l'utilisateur va interagir:

  • Saisie du fichier type pour que l'utilisateur puisse sélectionner le fichier à télécharger
  • Bouton de téléchargement, lorsque vous cliquez dessus, nous commencerons le téléchargement
  • Bouton Annuler qui annulera le téléchargement
  • Barre de progression avec une largeur de départ de 0. Pendant le processus de téléchargement, nous changerons sa largeur

À la toute fin de la balise body, j'ai ajouté un lien vers le script index.js, que nous devrons également créer:

//   ,     
const input = document.querySelector('#file');
const uploadBtn = document.querySelector('#upload');
const progressBar = document.querySelector('#progress-bar');
const cancelBtn = document.querySelector('#cancel');

Ça devrait ressembler a quelque chose comme ca:



Tout est un flux


Pour indiquer au navigateur le fichier à sélectionner, l'utilisateur doit cliquer sur le bouton «Choisir un fichier». Après cela, la boîte de dialogue de votre système d'exploitation s'ouvrira, où l'arborescence des dossiers sera affichée. Après avoir sélectionné un fichier, le navigateur télécharge toutes les informations nécessaires à son sujet.

Comment comprenons-nous qu'un utilisateur a sélectionné un fichier? Il y a un événement «changement» pour cela. Une fois l'événement déclenché, nous pouvons nous tourner vers le tableau des fichiers en entrée, où les données du fichier seront écrites.

Comment écoutons-nous l'événement de changement? Vous pouvez utiliser la méthode addEventListener et travailler avec elle. Mais nous travaillons avec RxJS, où tout événement peut être représenté comme un flux:

fromEvent(input, 'change').pipe(
    //    
    map(() => input.files[0])
).subscribe({
    next: data => console.log(file)
});

Ajoutez la fonction de téléchargement, qui téléchargera le fichier sur le serveur. Pour l'instant, laissez son corps vide:

function upload(file) {
  console.log(file);
}

La fonction de téléchargement doit être appelée après avoir cliqué sur le bouton uploadBtn:

fromEvent(uploadBtn, 'click').subscribe({
  next: () => upload(input.files[0])
});

Fusionner des flux


Maintenant, notre code n'est pas différent de ce que nous écririons en utilisant addEventListener. Oui, cela fonctionne, mais si nous le laissons comme ça, alors nous perdrons les avantages que RxJS nous offre.

Ce que nous pouvons faire? Nous décrirons la séquence des étapes de téléchargement d'un fichier:

  • Sélection de fichiers
  • Cliquez sur uploadBtn
  • Extraire le fichier de input.files
  • Téléchargement de fichiers

Maintenant, nous transférons cette séquence dans le code. Mais comment combiner les flux d'entrée et uploadBtn? L'opérateur switchMap nous y aidera, ce qui nous permet de projeter un thread sur un autre:

fromEvent(input, 'change').pipe(
    switchMap(() => fromEvent(uploadBtn, 'click')),
    map(() => input.files[0])
).subscribe({
    next: file => upload(file)
});

Ce code est très similaire à la séquence d'instructions que nous avons décrite ci-dessus. L'utilisateur sélectionne un fichier, switchMap est déclenché, et nous nous abonnons à uploadBtn. Mais alors rien ne se passera.

switchMap autorise uniquement le transfert des valeurs générées par fromEvent (uploadBtn, 'click') vers le flux externe . Pour commencer le téléchargement des fichiers, vous devez exécuter la deuxième instruction, à savoir cliquer sur uploadBtn. Ensuite, il élaborera la méthode map, qui extraira le fichier du tableau, et la méthode de téléchargement sera appelée déjà dans subscribe.

La chose la plus intéressante ici est que la séquence d'instructions n'est pas interrompue. Pour que la fonction de téléchargement fonctionne, l'événement «change» doit se déclencher avant.

Mais encore, un problème restait. L'utilisateur peut sélectionner un fichier puis le désélectionner. Et puis lorsque nous essayons de télécharger le fichier, nous passons la fonction de téléchargement - non définie. Pour éviter cette situation, nous devons ajouter une vérification:

fromEvent(input, 'change').pipe(
    switchMap(() => fromEvent(uploadBtn, 'click')),
    map(() => input.files[0]),
    filter(file => !!file)
).subscribe({
    next: file => upload(file)
});

Travailler avec xhr


Il est temps de mettre en œuvre le plus difficile - le processus de déchargement. Je vais le montrer sur un exemple de travail avec xhr, car fetch, au moment de la rédaction, ne sait pas comment suivre la progression du téléchargement .
Vous pouvez implémenter le déchargement à l'aide de n'importe quelle autre bibliothèque, par exemple axios ou jQuery.ajax.


Puisque j'utilise multer côté serveur, je devrai transférer le fichier à l'intérieur du formulaire (multer accepte les données uniquement dans ce format). Pour cela, j'ai écrit la fonction createFormData:

function createFormData(file) {
    const form = new FormData();
    //       file
    form.append('file', file);
    return form;
}

fromEvent(input, 'change').pipe(
    switchMap(() => fromEvent(uploadBtn, 'click')),
    map(() => input.files[0]),
    filter(file => !!file),
    map(file => createFormData(file))
).subscribe({
    next: data => upload(data)
});

Nous allons décharger le formulaire via XMLHttpRequest. Nous devons créer une instance de cet objet et y définir des méthodes de déchargement et d'erreurs. Le premier se déclenche lorsque le téléchargement est terminé, le second lorsque l'erreur se produit.

function upload(data) {
    const xhr = new XMLHttpRequest();

    //        
    xhr.onload = () => console.log('success');

    //    
    xhr.onerror = e => console.error(e);

    //  
    xhr.open('POST', '/upload', true);

    //  
    xhr.send(data);
}

Maintenant, nous avons un exemple de travail. Mais il contient quelques inconvénients:

  • Il n'y a aucun moyen d'annuler le téléchargement
  • Si vous cliquez sur le bouton uploadBtn n fois, alors nous aurons n connexions parallèles pour télécharger un fichier

En effet, la fonction de téléchargement fonctionne hors flux. Elle vit seule. Nous devons le réparer. Faisons en sorte que la fonction nous renvoie l'Observable. Ensuite, nous pouvons contrôler le téléchargement des fichiers:

function upload(data) {
    return new Observable(observer => {
        const xhr = new XMLHttpRequest();

        //    ,      
        //   
        xhr.onload = () => {
            observer.next();
            observer.complete();
        };
        
        xhr.onerror = e => observer.error(e);
        
        xhr.open('POST', '/upload', true);
        xhr.send(data);

        //   -  
        return () => xhr.abort();
    });
}

Notez la fonction flèche renvoyée à l'intérieur de l'Observable. Cette méthode sera appelée au moment de la désinscription et annulera le téléchargement.

Mettez l'appel de téléchargement dans switchMap:

fromEvent(input, 'change').pipe(
    switchMap(() => fromEvent(uploadBtn, 'click')),
    map(() => input.files[0]),
    filter(file => !!file),
    map(file => createFormData(file)),
    switchMap(data => upload(data))
).subscribe({
    next: () => console.log('File uploaded')
});

Maintenant, si l'utilisateur clique à nouveau sur le bouton de téléchargement, la demande précédente sera annulée, mais une nouvelle sera créée.

Annuler une demande de clic


Nous avons toujours le bouton calcelBtn. Nous devons mettre en œuvre l'annulation de la demande. L'opérateur takeUntil vous aidera ici.

takeUntil se traduit par «au revoir». Cet opérateur prend les valeurs du flux externe et les donne plus bas dans la chaîne. Tant que le thread interne existe et ne génère rien. Dès que le thread interne génère une valeur, takeUntil appellera la méthode unsubscribe et se désinscrira du thread externe.

Avant d'ajouter un opérateur, nous devons déterminer de quel flux nous voulons nous désabonner. Nous sommes intéressés par le téléchargement, car il suffit de terminer le téléchargement du fichier, c'est-à-dire Se désinscrire du fil interne:

fromEvent(input, 'change').pipe(
    switchMap(() => fromEvent(uploadBtn, 'click')),
    map(() => input.files[0]),
    filter(file => !!file),
    map(file => createFormData(file)),
    switchMap(data => upload(data).pipe(
        //    upload
        takeUntil(fromEvent(cancelBtn, 'click'))
    ))
).subscribe({
    next: () => console.log('File uploaded')
});

Barre de progression


Il reste à ajouter la barre de progression. Pour suivre les progrès, nous devons définir la méthode xhr.upload.onprogress. Cette méthode est appelée lorsque l'événement ProgressEvent se produit. L'objet événement contient plusieurs propriétés qui nous sont utiles:

  • lengthComputable - si vrai, alors nous connaissons la taille complète du fichier (dans notre cas, toujours vrai)
  • total - nombre total d'octets
  • chargé - le nombre d'octets envoyés

Apportez des modifications à la fonction de téléchargement:

function upload(data) {
    return new Observable(observer => {
        const xhr = new XMLHttpRequest();

        xhr.upload.onprogress = e => {
            //  
            const progress = e.loaded / e.total * 100;
            observer.next(progress);
        };

        xhr.onerror = e => observer.error(e);
        xhr.onload = () => observer.complete();

        xhr.open('POST', '/upload', true);
        xhr.send(data);

        return () => xhr.abort();
    });
}

Maintenant, le téléchargement crache le statut du téléchargement dans le flux. Il ne reste plus qu'à écrire une fonction qui va changer les propriétés de style de l'élément progressBar:

function setProgressBarWidth(width) {
    progressBar.style.width = `${width}%`;
}

fromEvent(input, 'change').pipe(
   /* ..
   
   */
).subscribe({
    next: width => setProgressBarWidth(width)
});


Un petit conseil: pour que vos fichiers ne soient pas téléchargés localement si rapidement, activez le paramètre "Fast 3G" ou "Slow 3G" dans l'onglet "Performances" de Chrome devtools.

Rappelez-vous


Nous avons obtenu une application de travail complète. Il reste à ajouter quelques coups. Maintenant, lorsque vous cliquez sur le bouton uploadBtn, nous annulons le téléchargement précédent et en commençons un nouveau. Mais nous avons déjà un bouton d'annulation.

Je souhaite que le bouton uploadBtn ne réponde pas aux clics suivants tant que nous n'avons pas téléchargé le fichier (ou jusqu'à ce que nous annulions le téléchargement). Ce qui peut être fait?

Vous pouvez suspendre l'attribut disable jusqu'à la fin du processus de téléchargement. Mais il existe une autre option - l'opérateur exhaustMap. Cette instruction ignorera les nouvelles valeurs du thread externe jusqu'à ce que le thread interne soit terminé. Remplacez switchMap par exhaustMap:

exhaustMap(data => upload(data).pipe(
  takeUntil(fromEvent(cancelBtn, 'click'))
))

Et maintenant, nous pouvons considérer notre candidature comme complète. Un peu de refactoring et obtenez la version finale:

import { fromEvent, Observable } from "rxjs";
import { map, switchMap, filter, takeUntil, exhaustMap } from "rxjs/operators";

const input = document.querySelector('#file');
const uploadBtn = document.querySelector('#upload');
const progressBar = document.querySelector('#progress-bar');
const cancelBtn = document.querySelector('#cancel');

const fromUploadBtn = fromEvent(uploadBtn, 'click');
const fromCancelBtn = fromEvent(cancelBtn, 'click');

fromEvent(input, 'change').pipe(
    switchMap(() => fromUploadBtn),
    map(() => input.files[0]),
    filter(file => !!file),
    map(file => createFormData(file)),
    exhaustMap(data => upload(data).pipe(
        takeUntil(fromCancelBtn)
    ))
).subscribe({
    next: width => setProgressBarWidth(width)
});

function setProgressBarWidth(width) {
    progressBar.style.width = `${width}%`;
}

function createFormData(file) {
    const form = new FormData();
    form.append('file', file);
    return form;
}

function upload(data) {
    return new Observable(observer => {
        const xhr = new XMLHttpRequest();

        xhr.upload.onprogress = e => {
            const progress = e.loaded / e.total * 100;
            observer.next(progress);
        };

        xhr.onerror = e => observer.error(e);
        xhr.onload = () => observer.complete();

        xhr.open('POST', '/upload', true);
        xhr.send(data);

        return () => xhr.abort();
    });
}

J'ai posté ma version ici .

Angular et HttpClient


Si vous travaillez avec Angular, vous n'avez pas besoin d'utiliser directement xhr. Angular dispose d'un service HttpClient. Ce service peut suivre la progression du chargement / déchargement, pour cela il suffit de passer les paramètres suivants à la méthode post:

  • reportProgress: true - obtenir des informations de téléchargement / téléchargement
  • observe: "événements" - indique que nous voulons recevoir des HttpEvents du flux

Voici à quoi ressemblera la méthode de téléchargement dans Angular:

export class UploaderService {
  constructor(private http: HttpClient) { }

  public upload(data: FormData): Observable<number> {
    return this.http.post('/upload', data, { reportProgress: true, observe: 'events' })
      .pipe(
        filter(event => event.type === HttpEventType.UploadProgress),
        map(event => event as HttpProgressEvent),
        map(event => event.loaded / event.total * 100)
      );
  }
}

L'instruction filter filtre uniquement les événements de téléchargement. D'autres événements ne nous intéressent pas. De plus, nous apportons l'événement à HttpProgressEvent afin d'accéder aux propriétés chargées et totales. Nous considérons le pourcentage.

HttpClient est juste un wrapper sur xhr qui nous sauve d'un passe-partout et facilite le travail avec HTTP.

Un exemple d'application sur Angular peut être trouvé ici .

Conclusion


RxJS est un outil très puissant entre les mains du développeur. Dans son arsenal, il y a un grand nombre d'opérateurs pour toutes les occasions. Malheureusement, pour cette raison, le seuil d'entrée dans cette technologie est assez élevé. Et souvent, les gens commencent sans le savoir à écrire leurs "vélos", ce qui rend le code difficile à maintenir.

Par conséquent, je souhaite à tous les lecteurs de ne pas rester immobiles et de ne pas avoir peur d'expérimenter. Apprenez RxJS. Soudain, vous tombez sur un opérateur qui peut transformer 10 lignes de code en une seule. Ou cela aide à rendre le code un peu plus clair.

Bonne chance

Source: https://habr.com/ru/post/undefined/


All Articles