Upload interativo de arquivos para o servidor usando RxJS



Faz muito tempo desde que escrevi meu último artigo sobre o básico do RxJS. Nos comentários, me pediram para mostrar exemplos mais complexos que podem ser úteis na prática. Decidi diluir um pouco a teoria e hoje falaremos sobre o upload de arquivos.

O que nós fazemos?

  • Escreveremos uma pequena página na qual o usuário poderá selecionar um arquivo para enviá-lo ao servidor
  • Adicione uma barra de progresso para que o progresso do upload do arquivo seja exibido.
  • Adicione a capacidade de cancelar o download clicando no botão Cancelar

Para entender este artigo, você precisará de conhecimentos básicos sobre RxJS. O que são observáveis , operadores e operadores HOO

Não puxaremos o gato pela cauda e começaremos imediatamente aos negócios!

Treinamento


Primeiro, precisamos de um servidor que possa aceitar solicitações de download de arquivos. Qualquer servidor pode ser adequado para isso, usarei o node.js em conjunto com express e multer para o artigo :

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

Agora crie uma página html na qual colocaremos todos os elementos necessários:

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

Agora, na página, temos 4 elementos com os quais o usuário irá interagir:

  • Entrada do tipo arquivo para que o usuário possa selecionar o arquivo a ser carregado
  • Carregar botão, quando clicado, começaremos a carregar
  • Botão Cancelar que cancelará o download
  • Barra de progresso com uma largura inicial de 0. Durante o processo de upload, alteraremos sua largura

No final da tag body, adicionei um link ao script index.js, que também será necessário criar:

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

Deve ser algo como isto:



Tudo é um fluxo


Para informar ao navegador qual arquivo selecionar, o usuário deve clicar no botão "Escolher arquivo". Depois disso, a caixa de diálogo do seu sistema operacional será aberta, onde a árvore de pastas será exibida. Após selecionar um arquivo, o navegador fará o download de todas as informações necessárias.

Como entendemos que um usuário selecionou um arquivo? Há um evento de "mudança" para isso. Depois que o evento é disparado, podemos voltar para a matriz de arquivos na entrada, onde os dados do arquivo serão gravados.

Como ouvimos o evento de mudança? Você pode usar o método addEventListener e trabalhar com ele. Mas trabalhamos com o RxJS, onde qualquer evento pode ser representado como um fluxo:

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

Adicione a função de upload, que fará o upload do arquivo no servidor. Por enquanto, deixe seu corpo vazio:

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

A função de upload deve ser chamada após clicar no botão uploadBtn:

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

Mesclar fluxos


Agora, nosso código não é diferente do que escreveríamos usando addEventListener. Sim, funciona, mas se deixarmos assim, perderemos as vantagens que o RxJS nos oferece.

O que podemos fazer? Vamos descrever a sequência de etapas para o upload de um arquivo:

  • Seleção de arquivo
  • Clique em uploadBtn
  • Extrair arquivo de input.files
  • Upload de arquivo

Agora transferimos essa sequência para o código. Mas como combinar fluxos de entrada e uploadBtn? O operador switchMap nos ajudará com isso, o que nos permite projetar um encadeamento em outro:

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

Este código é muito semelhante à sequência de instruções que descrevemos acima. O usuário seleciona um arquivo, o switchMap é acionado e nós assinamos o uploadBtn. Mas então nada vai acontecer.

O switchMap permite apenas que os valores gerados pelo fromEvent (uploadBtn, 'clique') sejam transmitidos para o fluxo externo.Para iniciar o upload de arquivos, é necessário executar a segunda instrução, a saber, clicar em uploadBtn. Em seguida, elaborará o método de mapa, que extrairá o arquivo da matriz, e o método de upload já será chamado na assinatura.

A coisa mais interessante aqui é que a sequência de instruções não está quebrada. Para que a função de upload funcione, o evento 'change' deve ser acionado antes.

Mas, ainda assim, um problema permaneceu. O usuário pode selecionar um arquivo e desmarcá-lo. E então, quando tentamos fazer upload do arquivo, passamos a função de upload - indefinida. Para evitar essa situação, devemos adicionar uma verificação:

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

Trabalhando com xhr


É hora de implementar o mais difícil - o processo de descarregamento. Vou mostrá-lo em um exemplo de trabalho com xhr, já que o fetch, no momento da redação, não sabe como acompanhar o progresso do upload .
Você pode implementar o descarregamento usando qualquer outra biblioteca, por exemplo, axios ou jQuery.ajax.


Como uso a multer no lado do servidor, terei que transferir o arquivo dentro do formulário (a multer aceita dados apenas neste formato). Para isso, escrevi a função 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)
});

Vamos descarregar o formulário através do XMLHttpRequest. Precisamos criar uma instância desse objeto e definir métodos de descarga e onerror nele. O primeiro será acionado quando o upload for concluído, o segundo quando o erro ocorrer.

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

Agora temos um exemplo de trabalho. Mas contém algumas desvantagens:

  • Não há como cancelar o download
  • Se você clicar no botão uploadBtn n vezes, teremos n conexões paralelas para enviar um arquivo

Isso ocorre porque a função de upload funciona fora do fluxo. Ela mora sozinha. Precisamos corrigi-lo. Vamos fazer com que a função retorne o Observável para nós. Então podemos controlar o upload de arquivos:

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

Observe a função de seta retornada dentro do observável. Este método será chamado no momento de cancelar a inscrição e cancelar o upload.

Coloque a chamada de upload no 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')
});

Agora, se o usuário clicar no botão de upload novamente, a solicitação anterior será cancelada, mas uma nova será criada.

Cancelar uma solicitação de clique


Ainda temos o botão calcelBtn. Devemos implementar o cancelamento da solicitação. O operador takeUntil ajudará aqui.

takeUntil é traduzido como "tchau". Esse operador pega os valores do fluxo externo e os fornece mais adiante na cadeia. Enquanto o encadeamento interno existir e não gerar nada. Assim que o encadeamento interno gerar um valor, o takeUntil chamará o método de cancelamento de assinatura e cancelará a assinatura do thread externo.

Antes de adicionar um operador, precisamos determinar de qual fluxo queremos cancelar a inscrição. Estamos interessados ​​no upload, pois é necessário apenas concluir o upload do arquivo, ou seja, Cancelar inscrição do thread 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 progresso


Resta adicionar a barra de progresso. Para acompanhar o progresso, precisamos definir o método xhr.upload.onprogress. Este método é chamado quando o evento ProgressEvent ocorre. O objeto de evento contém várias propriedades que são úteis para nós:

  • lengthComputable - se true, sabemos o tamanho total do arquivo (no nosso caso, sempre true)
  • total - número total de bytes
  • carregado - o número de bytes enviados

Faça alterações na função de upload:

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

Agora, o upload cospe o status do upload no fluxo. Resta apenas escrever uma função que altere as propriedades de estilo do elemento progressBar:

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

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


Uma dica rápida: para que seus arquivos não sejam carregados localmente com tanta rapidez, ative a configuração "3G Rápido" ou "3G lento" na guia "Desempenho" nas ferramentas do Chrome.

Trazer à mente


Temos um aplicativo de trabalho completo. Resta adicionar alguns traços. Agora, quando você clica no botão uploadBtn, cancelamos o upload anterior e iniciamos um novo. Mas já temos um botão de cancelamento.

Desejo que o botão uploadBtn não responda aos cliques subseqüentes até o upload do arquivo (ou até o cancelamento do upload). O que pode ser feito?

Você pode travar o atributo desativar até que o processo de upload seja concluído. Mas há outra opção - o operador escapeMap. Esta instrução ignorará novos valores do encadeamento externo até que o encadeamento interno seja concluído. Substitua switchMap por escapeMap:

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

E agora podemos considerar nossa inscrição completa. Um pouco de refatoração e obtenha a versão 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();
    });
}

Eu postei minha versão aqui .

Angular e HttpClient


Se você trabalha com Angular, não precisa usar o xhr diretamente. Angular tem um serviço HttpClient. Este serviço pode acompanhar o progresso do carregamento / descarregamento, para isso basta passar os seguintes parâmetros para o método post:

  • reportProgress: true - obtenha informações de upload / download
  • observe: "eventos" - indicam que queremos receber HttpEvents do fluxo

Veja como será o método de upload no 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)
      );
  }
}

A instrução de filtro filtra apenas os eventos de upload. Outros eventos não nos interessam. Além disso, trazemos o evento para o HttpProgressEvent para acessar as propriedades carregadas e totais. Consideramos a porcentagem.

O HttpClient é apenas um invólucro no xhr que nos salva de um clichê e facilita o trabalho com o HTTP.

Um exemplo de aplicativo no Angular pode ser encontrado aqui .

Conclusão


O RxJS é uma ferramenta muito poderosa nas mãos do desenvolvedor. Em seu arsenal, há um enorme conjunto de operadores para todas as ocasiões. Infelizmente, por causa disso, o limiar de entrada nessa tecnologia é bastante alto. E, muitas vezes, as pessoas, sem saber, começam a escrever suas "bicicletas", o que dificulta a manutenção do código.

Portanto, gostaria de desejar a todos os leitores que não fiquem parados e não tenham medo de experimentar. Aprenda RxJS. De repente, você encontra um operador que pode transformar 10 linhas de código em uma. Ou isso ajuda a tornar o código um pouco mais claro.

Boa sorte

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


All Articles