Carga interactiva de archivos al servidor usando RxJS



Ha pasado mucho tiempo desde que escribí mi último artículo sobre los conceptos básicos de RxJS. En los comentarios, me pidieron que mostrara ejemplos más complejos que pueden ser útiles en la práctica. Así que decidí diluir un poco la teoría y hoy hablaremos sobre subir archivos.

qué hacemos?

  • Escribiremos una página pequeña donde el usuario puede seleccionar un archivo para cargarlo en el servidor
  • Agregue una barra de progreso para que se muestre el progreso de carga del archivo.
  • Agregue la posibilidad de cancelar la descarga haciendo clic en el botón Cancelar

Para comprender este artículo, necesitará conocimientos básicos de RxJS. ¿Qué son los operadores observables , operadores y operadores HOO?

¡No tiraremos del gato por la cola e inmediatamente nos pondremos manos a la obra!

Formación


Primero, necesitamos un servidor que pueda aceptar solicitudes de descarga de archivos. Cualquier servidor puede ser adecuado para esto, usaré node.js junto con express y multer para el artículo :

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

Ahora cree una página html en la que colocaremos todos los elementos necesarios:

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

Ahora en la página tenemos 4 elementos con los que el usuario interactuará:

  • Entrada de archivo de tipo para que el usuario pueda seleccionar el archivo a cargar
  • Botón de carga, cuando se hace clic, comenzaremos a cargar
  • Botón de cancelar que cancelará la descarga
  • Barra de progreso con un ancho inicial de 0. Durante el proceso de carga, cambiaremos su ancho

Al final de la etiqueta del cuerpo, agregué un enlace al script index.js, que también tendremos que crear:

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

Debería verse más o menos así:



Todo es un flujo


Para indicarle al navegador qué archivo seleccionar, el usuario debe hacer clic en el botón "Elegir archivo". Después de eso, se abrirá el cuadro de diálogo de su sistema operativo, donde se mostrará el árbol de carpetas. Después de seleccionar un archivo, el navegador descargará toda la información necesaria al respecto.

¿Cómo entendemos que un usuario ha seleccionado un archivo? Hay un evento de "cambio" para esto. Después de que se desencadena el evento, podemos recurrir a la matriz de archivos en la entrada, donde se escribirán los datos del archivo.

¿Cómo escuchamos el evento de cambio? Puede usar el método addEventListener y trabajar con él. Pero trabajamos con RxJS, donde cualquier evento se puede representar como una secuencia:

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

Agregue la función de carga, que cargará el archivo al servidor. Por ahora, deja su cuerpo vacío:

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

Se debe llamar a la función de carga después de hacer clic en el botón uploadBtn:

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

Combinar secuencias


Ahora nuestro código no es diferente de lo que escribiríamos usando addEventListener. Sí, funciona, pero si lo dejamos así, perderemos las ventajas que nos ofrece RxJS.

¿Lo que podemos hacer? Describiremos la secuencia de pasos para cargar un archivo:

  • Selección de archivo
  • Haz clic en uploadBtn
  • Extraer archivo de input.files
  • Subir archivo

Ahora transferimos esta secuencia al código. Pero, ¿cómo combinar las transmisiones input y uploadBtn? El operador switchMap nos ayudará con esto, lo que nos permite proyectar un hilo sobre otro:

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

Este código es muy similar a la secuencia de instrucciones que describimos anteriormente. El usuario selecciona un archivo, se activa switchMap y nos suscribimos a uploadBtn. Pero entonces no pasará nada.

switchMap solo permite pasar los valores generados por fromEvent (uploadBtn, 'click') a la transmisión externa . Para cargar archivos, debe ejecutar la segunda instrucción, es decir, hacer clic en uploadBtn. Luego, resolverá el método de mapa, que extraerá el archivo de la matriz, y el método de carga ya se llamará en suscripción.

Lo más interesante aquí es que la secuencia de instrucciones no está rota. Para que la función de carga funcione, el evento 'cambio' debe activarse antes.

Pero aún así, quedaba un problema. El usuario puede seleccionar un archivo y luego deseleccionarlo. Y luego, cuando intentamos cargar el archivo, pasamos la función de carga, indefinida. Para evitar esta situación, debemos agregar un cheque:

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

Trabajando con xhr


Es hora de implementar lo más difícil: el proceso de descarga. Lo mostraré en un ejemplo de trabajo con xhr, ya que fetch, al momento de escribir, no sabe cómo rastrear el progreso de carga .
Puede implementar la descarga utilizando cualquier otra biblioteca, por ejemplo axios o jQuery.ajax.


Como uso multer en el lado del servidor, tendré que transferir el archivo dentro del formulario (multer acepta datos solo en este formato). Para esto, escribí la función 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)
});

Descargaremos el formulario a través de XMLHttpRequest. Necesitamos crear una instancia de este objeto y definir métodos de descarga y onerror en él. El primero se disparará cuando se complete la carga, el segundo cuando se produzca el error.

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

Ahora tenemos un ejemplo de trabajo. Pero contiene un par de desventajas:

  • No hay forma de cancelar la descarga.
  • Si hace clic en el botón uploadBtn n veces, tendremos n conexiones paralelas para cargar un archivo

Esto se debe a que la función de carga no funciona. Ella vive sola. Necesitamos arreglarlo. Hagamos que la función nos devuelva el Observable. Entonces podemos controlar la carga de archivos:

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

Tenga en cuenta la función de flecha devuelta dentro del Observable. Se llamará a este método al momento de cancelar la suscripción y cancelar la carga.

Ponga la llamada de carga en 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')
});

Ahora, si el usuario vuelve a hacer clic en el botón de carga, se cancelará la solicitud anterior, pero se creará una nueva.

Cancelar una solicitud de clic


Todavía tenemos el botón calcelBtn. Debemos implementar la cancelación de la solicitud. El operador takeUntil ayudará aquí.

takeUntil se traduce como "take bye". Este operador toma los valores del flujo externo y los proporciona más abajo en la cadena. Mientras exista el hilo interno y no genere nada. Tan pronto como el hilo interno genere un valor, takeUntil llamará al método de cancelación de suscripción y se dará de baja del hilo externo.

Antes de agregar un operador, debemos determinar de qué transmisión queremos cancelar la suscripción. Estamos interesados ​​en cargar, ya que solo es necesario completar la carga del archivo, es decir Darse de baja del hilo interno:

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

Barra de progreso


Queda por agregar la barra de progreso. Para rastrear el progreso, necesitamos definir el método xhr.upload.onprogress. Este método se llama cuando se produce el evento ProgressEvent. El objeto de evento contiene varias propiedades que nos son útiles:

  • lengthComputable: si es verdadero, entonces conocemos el tamaño completo del archivo (en nuestro caso, siempre verdadero)
  • total - número total de bytes
  • cargado - el número de bytes enviados

Realice cambios en la función de carga:

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

Ahora cargar escupe el estado de carga en la transmisión. Solo queda escribir una función que cambie las propiedades de estilo del elemento progressBar:

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

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


Un consejo rápido: para que sus archivos no se carguen localmente tan rápido, active la configuración "3G rápido" o "3G lento" en la pestaña "Rendimiento" en las herramientas de Chrome.

Recordar


Tenemos una aplicación de trabajo completa. Queda por agregar un par de trazos. Ahora, cuando hace clic en el botón uploadBtn, cancelamos la carga anterior y comenzamos una nueva. Pero ya tenemos un botón de cancelar.

Quiero que el botón uploadBtn no responda a los clics posteriores hasta que carguemos el archivo (o hasta que cancelemos la carga). ¿Qué se puede hacer?

Puede colgar el atributo de deshabilitación hasta que se complete el proceso de carga. Pero hay otra opción: el operador exhaustMap. Esta declaración ignorará los nuevos valores del hilo externo hasta que se complete el hilo interno. Reemplace switchMap con exhaustMap:

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

Y ahora podemos considerar nuestra solicitud completa. Un poco de refactorización y obtenga la versión final:

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

Publiqué mi versión aquí .

Angular y HttpClient


Si trabaja con Angular, entonces no necesita usar xhr directamente. Angular tiene un servicio HttpClient. Este servicio puede rastrear el progreso de la carga / descarga, para esto es suficiente pasar los siguientes parámetros al método de publicación:

  • reportProgress: verdadero: obtenga información de carga / descarga
  • observe: "eventos" - indica que queremos recibir HttpEvents de la transmisión

Así se verá el método de carga en 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)
      );
  }
}

La declaración de filtro solo filtra eventos de carga. Otros eventos no nos interesan. Además, llevamos el evento al HttpProgressEvent para acceder a las propiedades cargadas y totales. Consideramos el porcentaje.

HttpClient es solo un contenedor sobre xhr que nos salva de una repetitiva y facilita el trabajo con HTTP.

Puede encontrar una aplicación de ejemplo en Angular aquí .

Conclusión


RxJS es una herramienta muy poderosa en manos del desarrollador. En su arsenal hay un gran conjunto de operadores para todas las ocasiones. Desafortunadamente, debido a esto, el umbral para ingresar a esta tecnología es bastante alto. Y a menudo, las personas sin saberlo comienzan a escribir sus "bicicletas", lo que hace que el código sea difícil de mantener.

Por lo tanto, me gustaría desear que todos los lectores no se queden quietos y no tengan miedo de experimentar. Aprende RxJS. De repente, te encuentras con un operador que puede convertir 10 líneas de código en una. O ayuda a que el código sea un poco más claro.

Buena suerte

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


All Articles