Upload файла в Angular по клику кнопки

Подготовка

В качестве бекенда принимающего файлы будет создано nestjs приложение.

$ npm i -g @nestjs/cli
$ nest new server

Приложение по-умолчанию работает на 3000 порту, чтобы разрешить запросы с 4200 необходимо включить CORS, для этого достаточно добавить строчку app.enableCors(); в основной файл приложения.

main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableCors(); // <===== 2 часа искал это решение
  await app.listen(3000);
}
bootstrap();

В контреллер добавляется хендлер upload, который ожидает файл в свойстве upload_file.

  @Post('upload')
  @UseInterceptors(FileInterceptor('upload_file'))
  uploadFile(@UploadedFile() file) {
    console.log(file);
    return { id: '1', name: file.originalname };
  }

С бекендом завершено, можно запускать:

npm start

Выбор и отправка файла по клику

Выбор и отправка файла по клику подразумевает, что на форме отсутствует input для файла.

  • Следует динамически создать input
  • Установить тип file
  • Повешать обработчик на событие change
    • В свойстве события event.target.files[0] будут содержаться выбранный пользователем файл.
    • Подготовить multipart
    • Отправить POST запрос на сервер.
    • Убрать ссылку на созданный input, чтобы сборщик мусора его удалил
  • Кликнуть на созданный input

Получается примерно такой обработчик:

uploadFile(): void {
    let fileInput = this.document.createElement('input');
    fileInput.type = 'file';
    fileInput.addEventListener('change', event => {
        const target = event.target as HTMLInputElement;
        const selectedFile = target.files[0];
        const uploadData = new FormData();
        uploadData.append('upload_file', selectedFile, selectedFile.name);
        // непосредственно отправить файл (post запрос)
        fileInput = null;
    });
    fileInput.click();
}

Отправка запроса с отслеживанием прогресса

Чтобы получить прогресс, нужно установить 2 свойства в опциях post запроса http клиента:

this.http.post('http://localhost:3000/upload', uploadData, {
    reportProgress: true, // Без observe: 'events' не работает
    observe: 'events', // без reportProgress: true только HttpEventType.Sent и HttpEventType.Response
})

Результат

Написано в реактивном стиле. Полностью можно посмотреть в репозитарии.

import { Component, Inject, OnDestroy, ChangeDetectionStrategy } from '@angular/core';
import { HttpClient, HttpEventType } from '@angular/common/http';
import { DOCUMENT } from '@angular/common';

import { fromEvent, Subject } from 'rxjs';
import { mergeMap, finalize, takeUntil, first } from 'rxjs/operators';

@Component({
  selector: 'app-root',
  template: `
    <h1>Welcome to File upload!</h1>
    <button (click)="chooseAndUploadFile()">Upload!</button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent implements OnDestroy {
  private destroy$ = new Subject<void>();

  constructor(
    @Inject(DOCUMENT) private document: Document,
    private http: HttpClient
  ) {}

  /**
   * Open file dialog and upload file to server
   */
  public chooseAndUploadFile(): void {
    let fileInput = this.document.createElement('input');
    fileInput.type = 'file';
    fromEvent(fileInput, 'change')
      .pipe(
        first(),
        mergeMap(event => {
          const target = event.target as HTMLInputElement;
          const selectedFile = target.files[0];
          // formData обязательно в 2 строчки
          const uploadData = new FormData();
          uploadData.append('upload_file', selectedFile, selectedFile.name);
          return this.http.post('http://localhost:3000/upload', uploadData, {
            reportProgress: true, // Без observe: 'events' не работает
            observe: 'events', // без reportProgress: true только HttpEventType.Sent и HttpEventType.Response
          });
        }),
        finalize(() => {
          // должен быть удален, т.к. счетчик ссылок обнулится
          fileInput = null;
          console.log('fileInput = null');
        }),
        takeUntil(this.destroy$)
      )
      .subscribe(
        event => {
          // console.log(event);
          switch (event.type) {
            case HttpEventType.Sent:
              console.log('Request sent!');
              break;
            case HttpEventType.ResponseHeader:
              console.log('Response header received!');
              break;
            case HttpEventType.UploadProgress:
              const kbLoaded = Math.round(event.loaded / 1024 / 1024);
              const percent = Math.round((event.loaded * 100) / event.total);
              console.log(
                `Upload in progress! ${kbLoaded}Mb loaded (${percent}%)`
              );
              break;
            case HttpEventType.Response:
              console.log('Done!', event.body);
          }
        },
        () => console.log('Upload error'),
        () => console.log('Upload complete')
      );
    fileInput.click();
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Единственный момент требующий пояснения это first(). Несмотря на то, что в реальности получается только 1 change событие из input'a и затем он удаляется, подписка на его событие change будет жить в ожидании следующих событий. Поэтому необходимо добавить оператор, который завершит поток. Подойдет first() или take(1).


Похожие записи

Angular environment service

Использование сервиса для окружения вместо прямой ссылки на environment.ts

26 января 2020 г. в Angular

Angular Let Directive

*ngIf не отображает содержимое в falsy случаях (0, null, undefined) на async pipe, в пакете @rx-angular/template предлагается решение

27 сентября 2020 г. в Angular