Interaktives Hochladen von Dateien auf den Server mit RxJS



Es ist lange her, dass ich meinen letzten Artikel über die Grundlagen von RxJS geschrieben habe. In den Kommentaren wurde ich gebeten, komplexere Beispiele zu zeigen, die in der Praxis nützlich sein können. Deshalb habe ich beschlossen, die Theorie etwas zu verwässern, und heute werden wir über das Hochladen von Dateien sprechen.

Was werden wir machen?

  • Wir werden eine kleine Seite schreiben, auf der der Benutzer eine Datei auswählen kann, um sie auf den Server hochzuladen
  • Fügen Sie einen Fortschrittsbalken hinzu, damit der Fortschritt des Datei-Uploads angezeigt wird.
  • Fügen Sie die Möglichkeit hinzu, den Download abzubrechen, indem Sie auf die Schaltfläche Abbrechen klicken

Um diesen Artikel zu verstehen, benötigen Sie grundlegende RxJS-Kenntnisse. Was sind Observable , Operatoren sowie HOO-Operatoren

Wir werden die Katze nicht am Schwanz ziehen und sofort zur Sache kommen!

Ausbildung


Erstens benötigen wir einen Server, der Dateidownload-Anforderungen akzeptieren kann. Jeder Server kann dafür geeignet sein, ich werde node.js in Verbindung mit Express und Multer für den Artikel verwenden :

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);

Erstellen Sie nun eine HTML-Seite, auf der wir alle erforderlichen Elemente platzieren:

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

Jetzt haben wir auf der Seite 4 Elemente, mit denen der Benutzer interagieren wird:

  • Eingabe der Typdatei, damit der Benutzer die hochzuladende Datei auswählen kann
  • Wenn Sie auf die Schaltfläche Hochladen klicken, wird der Upload gestartet
  • Schaltfläche Abbrechen, mit der der Download abgebrochen wird
  • Fortschrittsbalken mit einer Startbreite von 0. Während des Upload-Vorgangs ändern wir seine Breite

Ganz am Ende des Body-Tags habe ich einen Link zum Skript index.js hinzugefügt, den wir auch erstellen müssen:

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

Es sollte ungefähr so ​​aussehen:



Alles ist ein Fluss


Um dem Browser mitzuteilen, welche Datei ausgewählt werden soll, muss der Benutzer auf die Schaltfläche „Datei auswählen“ klicken. Danach öffnet sich das Dialogfeld Ihres Betriebssystems, in dem der Ordnerbaum angezeigt wird. Nach Auswahl einer Datei lädt der Browser alle erforderlichen Informationen herunter.

Wie verstehen wir, dass ein Benutzer eine Datei ausgewählt hat? Hierfür gibt es ein Änderungsereignis. Nachdem das Ereignis ausgelöst wurde, können wir uns dem Dateiarray in der Eingabe zuwenden, in das die Dateidaten geschrieben werden.

Wie hören wir uns das Veränderungsereignis an? Sie können die Methode addEventListener verwenden und damit arbeiten. Wir arbeiten jedoch mit RxJS, bei dem jedes Ereignis als Stream dargestellt werden kann:

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

Fügen Sie die Upload-Funktion hinzu, mit der die Datei auf den Server hochgeladen wird. Lass ihren Körper vorerst leer:

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

Die Upload-Funktion sollte nach dem Klicken auf die Schaltfläche uploadBtn aufgerufen werden:

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

Streams zusammenführen


Jetzt unterscheidet sich unser Code nicht von dem, was wir mit addEventListener schreiben würden. Ja, es funktioniert, aber wenn wir es so lassen, verlieren wir die Vorteile, die RxJS uns bietet.

Was wir tun können? Wir werden die Abfolge der Schritte zum Hochladen einer Datei beschreiben:

  • Dateiauswahl
  • Klicken Sie auf uploadBtn
  • Datei aus input.files extrahieren
  • Datei-Upload

Jetzt übertragen wir diese Sequenz auf den Code. Aber wie kombiniert man Input- und UploadBtn-Streams? Der switchMap-Operator hilft uns dabei, sodass wir einen Thread auf einen anderen projizieren können:

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

Dieser Code ist der oben beschriebenen Befehlsfolge sehr ähnlich. Der Benutzer wählt eine Datei aus, switchMap wird ausgelöst und wir abonnieren uploadBtn. Aber dann wird nichts passieren.

Mit switchMap können nur die von fromEvent generierten Werte (uploadBtn, 'click') an den externen Stream übergeben werden . Um mit dem Hochladen von Dateien zu beginnen , müssen Sie die zweite Anweisung ausführen, nämlich auf uploadBtn klicken. Dann wird die Map-Methode ausgearbeitet, die die Datei aus dem Array extrahiert, und die Upload-Methode wird bereits im Abonnement aufgerufen.

Das Interessanteste dabei ist, dass die Reihenfolge der Anweisungen nicht unterbrochen wird. Damit die Upload-Funktion funktioniert, muss das Ereignis 'change' vorher ausgelöst werden.

Dennoch blieb ein Problem bestehen. Der Benutzer kann eine Datei auswählen und dann die Auswahl aufheben. Und wenn wir dann versuchen, die Datei hochzuladen, übergeben wir die Upload-Funktion - undefiniert. Um diese Situation zu vermeiden, sollten wir einen Check hinzufügen:

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

Arbeiten mit xhr


Es ist Zeit, das Schwierigste zu implementieren - den Entladevorgang. Ich werde es an einem Beispiel für die Arbeit mit xhr zeigen, da fetch zum Zeitpunkt des Schreibens nicht weiß, wie der Upload-Fortschritt verfolgt werden soll .
Sie können das Entladen mit jeder anderen Bibliothek implementieren, z. B. axios oder jQuery.ajax.


Da ich multer auf der Serverseite verwende, muss ich die Datei innerhalb des Formulars übertragen (multer akzeptiert Daten nur in diesem Format). Dafür habe ich die Funktion createFormData geschrieben:

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)
});

Wir werden das Formular über XMLHttpRequest entladen. Wir müssen eine Instanz dieses Objekts erstellen und Entlade- und Fehlermethoden dafür definieren. Der erste wird ausgelöst, wenn der Upload abgeschlossen ist, der zweite, wenn der Fehler auftritt.

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);
}

Jetzt haben wir ein funktionierendes Beispiel. Aber es enthält ein paar Nachteile:

  • Es gibt keine Möglichkeit, den Download abzubrechen
  • Wenn Sie n-mal auf die Schaltfläche uploadBtn klicken, haben wir n parallele Verbindungen, um eine Datei hochzuladen

Dies liegt daran, dass die Upload-Funktion außerhalb des Streams funktioniert. Sie lebt alleine. Wir müssen es beheben. Lassen Sie die Funktion das Observable an uns zurückgeben. Dann können wir den Upload von Dateien steuern:

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();
    });
}

Beachten Sie die Pfeilfunktion, die im Observable zurückgegeben wird. Diese Methode wird zum Zeitpunkt des Abbestellens aufgerufen und der Upload abgebrochen.

Setzen Sie den Upload-Aufruf in 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')
});

Wenn der Benutzer nun erneut auf die Schaltfläche zum Hochladen klickt, wird die vorherige Anforderung abgebrochen, aber eine neue erstellt.

Abbrechen einer Klickanforderung


Wir haben noch die Schaltfläche calcelBtn. Wir müssen die Stornierung der Anfrage durchführen. Der takeUntil-Operator hilft hier.

takeUntil bedeutet "Tschüss". Dieser Operator nimmt die Werte aus dem externen Stream und gibt sie weiter unten in der Kette an. Solange der interne Thread existiert und nichts generiert. Sobald der interne Thread einen Wert generiert, ruft takeUntil die Methode zum Abbestellen auf und meldet sich vom externen Thread ab.

Bevor wir einen Operator hinzufügen, müssen wir bestimmen, von welchem ​​Stream wir uns abmelden möchten. Wir sind am Hochladen interessiert, da nur der Upload der Datei abgeschlossen werden muss, d. H. Vom internen Thread abbestellen:

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')
});

Fortschrittsanzeige


Der Fortschrittsbalken muss noch hinzugefügt werden. Um den Fortschritt zu verfolgen, müssen wir die Methode xhr.upload.onprogress definieren. Diese Methode wird aufgerufen, wenn das ProgressEvent-Ereignis auftritt. Das Ereignisobjekt enthält mehrere Eigenschaften, die für uns nützlich sind:

  • lengthComputable - wenn true, kennen wir die volle Dateigröße (in unserem Fall immer true)
  • total - Gesamtzahl der Bytes
  • geladen - die Anzahl der gesendeten Bytes

Nehmen Sie Änderungen an der Upload-Funktion vor:

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();
    });
}

Jetzt spuckt Upload den Upload-Status in den Stream aus. Es bleibt nur eine Funktion zu schreiben, die die Stileigenschaften des progressBar-Elements ändert:

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

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


Ein kurzer Tipp: Damit Ihre Dateien nicht so schnell lokal hochgeladen werden, aktivieren Sie die Einstellung "Fast 3G" oder "Slow 3G" auf der Registerkarte "Leistung" von Chrome devtools.

Ins Gedächtnis rufen


Wir haben eine voll funktionsfähige Bewerbung. Es bleibt noch ein paar Striche hinzuzufügen. Wenn Sie jetzt auf die Schaltfläche uploadBtn klicken, brechen wir den vorherigen Upload ab und starten einen neuen. Wir haben aber bereits einen Abbruchknopf.

Ich möchte, dass die Schaltfläche uploadBtn nicht auf nachfolgende Klicks reagiert, bis wir die Datei hochladen (oder bis wir den Upload abbrechen). Was kann getan werden?

Sie können das Deaktivierungsattribut aufhängen, bis der Upload-Vorgang abgeschlossen ist. Es gibt aber noch eine andere Option - den ExtentMap-Operator. Diese Anweisung ignoriert neue Werte aus dem externen Thread, bis der interne Thread abgeschlossen ist. Ersetzen Sie switchMap durch extractMap:

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

Und jetzt können wir unsere Bewerbung als vollständig betrachten. Ein wenig umgestalten und die endgültige Version erhalten:

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();
    });
}

Ich habe meine Version hier gepostet .

Angular und HttpClient


Wenn Sie mit Angular arbeiten, müssen Sie xhr nicht direkt verwenden. Angular hat einen HttpClient-Dienst. Dieser Dienst kann den Fortschritt des Ladens / Entladens verfolgen. Dazu reicht es aus, die folgenden Parameter an die Post-Methode zu übergeben:

  • reportProgress: true - Upload- / Download-Informationen abrufen
  • Beachten Sie: "Ereignisse" - Geben Sie an, dass wir HttpEvents vom Stream empfangen möchten

So sieht die Upload-Methode in Angular aus:

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)
      );
  }
}

Die Filteranweisung filtert nur Upload-Ereignisse heraus. Andere Veranstaltungen interessieren uns nicht. Außerdem bringen wir das Ereignis in das HttpProgressEvent, um auf die geladenen und die gesamten Eigenschaften zuzugreifen. Wir betrachten den Prozentsatz.

HttpClient ist nur ein Wrapper über xhr, der uns vor einem Boilerplate bewahrt und die Arbeit mit HTTP erleichtert.

Eine Beispielanwendung für Angular finden Sie hier .

Fazit


RxJS ist ein sehr leistungsfähiges Tool in den Händen des Entwicklers. In seinem Arsenal gibt es eine große Anzahl von Betreibern für alle Gelegenheiten. Aus diesem Grund ist die Schwelle für den Einstieg in diese Technologie leider ziemlich hoch. Und oft fangen Leute unwissentlich an, ihre "Fahrräder" zu schreiben, was es schwierig macht, den Code zu pflegen.

Deshalb möchte ich allen Lesern wünschen, nicht still zu stehen und keine Angst vor Experimenten zu haben. Lerne RxJS. Plötzlich stoßen Sie auf einen Operator, der 10 Codezeilen in eine verwandeln kann. Oder es hilft, den Code ein wenig klarer zu machen.

Viel Glück

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


All Articles