Angular 文件下载进度

2025-06-04

Angular 文件下载进度

下载文件是 Web 应用的常见任务。这些文件可能是 PDF、ZIP 文件,也可能是其他任何您希望用户访问的二进制或文本文件。以下是 Angular 中下载文件的方法:使用简单的链接下载,或使用 JavaScript 下载(提供更多控制和进度指示)。

download本文开发的操作符可以在 ngx-operators 库 📚 中找到是一个经过实践检验的 Angular RxJS 操作符集合。如果您能在 GitHub 上给它打个星⭐️,我将不胜感激,这有助于让更多人了解它。

角度下载链接

在 Angular 中,使用纯 HTML 代码即可轻松实现一个简单的下载链接。您将使用一个带有属性的锚标记指向文件href。该download属性告知浏览器不应继续访问链接,而是下载 URL 目标。您还可以指定其值来设置正在下载的文件的名称。

<a href="/downloads/archive.zip" 
  download="archive.zip">
  archive.zip
</a>
Enter fullscreen mode Exit fullscreen mode

您可以将以下任何属性与 Angular 绑定,以便动态设置 URL 和文件名:

<a [href]="download.url" [download]="download.filename">
  {{ download.filename }}
</a>
Enter fullscreen mode Exit fullscreen mode

较旧的浏览器(例如 Internet Explorer)可能无法识别该download属性。在这种情况下,您可以在新的浏览器选项卡中打开下载文件,并将target属性设置为_blank。不过,请确保rel="noopener noreferrer"在使用时始终包含target="_blank",以免暴露安全漏洞

<a [href]="download.url" target="_blank" rel="noopener noreferrer">
  {{ download.filename }}
</a>
Enter fullscreen mode Exit fullscreen mode

如果没有该download属性,则下载的文件名将完全取决于提供文件的服务器发送的 HTTP 标头Content-Dispositiondownload 。即使存在该属性,此标头中的信息也可能具有优先权。

推荐阅读:

构建一个好的下载...按钮? - Eric Bailey

基于链接的解决方案非常符合 HTML 标准,并让浏览器完成大部分工作。但是,如果您希望更好地控制下载过程,并希望显示一些自定义进度指示器,您也可以通过 Angular 的HttpClient下载文件。

使用 HttpClient 下载文件

在浏览器中,文件最好以Blob的形式表示:

Blob 对象表示一个 blob,它是一个类似文件的对象,包含不可变的原始数据
- MDN web 文档

通过指定responseType选项,我们可以执行 GET 请求,返回表示下载文件的 blob。假设我们有一个指定对象DownloadService正在执行此操作:

@Injectable({providedIn: 'root'})
export class DownloadService {

  constructor(private http: HttpClient) {}

  download(url: string): Observable<Blob> {
    return this.http.get(url, {
      responseType: 'blob'
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

然后,组件将能够调用此服务,订阅相应的可观察对象并最终保存文件,如下所示:

@Component({...})
export class MyComponent  {

  constructor(private downloads: DownloadService) {}

  download(): void {
    this.downloads
      .download('/downloads/archive.zip')
      .subscribe(blob => {
        const a = document.createElement('a')
        const objectUrl = URL.createObjectURL(blob)
        a.href = objectUrl
        a.download = 'archive.zip';
        a.click();
        URL.revokeObjectURL(objectUrl);
      })
  }
}
Enter fullscreen mode Exit fullscreen mode

这里,我们在 Blob 到达时以编程方式创建一个锚标记。使用URL.createObjectURL我们可以生成指向 Blob 的下载链接。最后,我们click()像用户使用常规浏览器下载链接一样使用该链接。文件下载完成后,我们将通过撤销创建的对象 URL 来丢弃该 Blob。

不过这种方法比较繁琐,而且可能无法在所有浏览器上流畅运行。因此,我建议你在保存 Blob 对象时使用流行的FileSaver.js库。保存过程只需一行代码:

import { saveAs } from 'file-saver';

download() {
    this.downloads
      .download('/downloads/archive.zip')
      .subscribe(blob => saveAs(blob, 'archive.zip'))
}
Enter fullscreen mode Exit fullscreen mode

如果您不想为此添加依赖项,而更愿意使用之前展示的手动方法,那么您不妨将用于保存 Blob 的代码重构到一个单独的服务中。在那里,您可能希望document使用 Angular 的内置注入令牌DOCUMENT进行注入。您也可以为FileSaver.js创建自定义注入令牌URL——另请参阅下文我们将如何为 FileSaver.js 执行此操作。

计算下载进度

通过在发出 HTTP 请求时将选项设置为observeevents我们不仅可以收到请求的最终响应主体,还可以访问中间的 HTTP 事件。Angular 中有多种类型的 HTTP 事件,全部合并在HttpEvent类型下。我们还需要显式传递该选项reportProgress才能接收HttpProgressEvents。我们的 HTTP 请求最终将如下所示:

this.http.get(url, {
  reportProgress: true,
  observe: 'events',
  responseType: 'blob'
})
Enter fullscreen mode Exit fullscreen mode

由于我们不想将这些事件转发给每个组件,我们的服务必须做更多工作。否则,我们的组件就必须处理 HTTP 协议的细节——这就是服务的作用!因此,我们引入一个表示下载进度的数据结构:

export interface Download {
  state: 'PENDING' | 'IN_PROGRESS' | 'DONE'
  progress: number
  content: Blob | null
}
Enter fullscreen mode Exit fullscreen mode

ADownload可以处于三种状态之一。要么它尚未开始,因此处于待处理状态。要么它已完成或仍在进行中。我们使用TypeScript 的联合类型来定义不同的下载状态。此外,下载有一个数字,表示下载进度,从 1 到 100。下载完成后,它将包含一个 Blob 作为其content- 在此之前,此属性不可用,因此null

现在,我们希望将特定的 HTTP 事件抽象到我们新定义的数据结构中。这样,我们的组件就可以与底层网络协议解耦。由于我们要处理随时间推移而来的多个事件,因此 RxJS 操作符非常适合——那就让我们创建一个吧!

第一步是创建类型保护,帮助我们区分不同的 HTTP 事件。这样,我们就可以以类型安全的方式访问特定于事件的字段。

我们将重点关注事件HttpResponseHttpProgressEvents。它们都包含 discriminator 字段,type使我们能够轻松地在保护器中为类型断言返回布尔值。

import {HttpEvent, HttpEventType, HttpResponse, HttpProgressEvent} from '@angular/common/http'

function isHttpResponse<T>(event: HttpEvent<T>): event is HttpResponse<T> {
  return event.type === HttpEventType.Response
}

function isHttpProgressEvent(event: HttpEvent<unknown>): event is HttpProgressEvent {
  return event.type === HttpEventType.DownloadProgress 
      || event.type === HttpEventType.UploadProgress
}
Enter fullscreen mode Exit fullscreen mode

保护可以与简单的 if 语句一起使用,但是,TypeScript 会为我们缩小语句块内的事件类型:

const event: HttpEvent<Blob> = ...
console.log(event.loaded) // not accessible, possible compilation error
if (isHttpProgressEvent(event)) {
  console.log(event.loaded) // type is narrowed, property is accessible
}
Enter fullscreen mode Exit fullscreen mode

基于这些保护,我们现在可以创建自定义操作符了。它将利用scan操作符,该操作符允许我们累积来自可观察对象的连续值的状态。它最多需要两个参数:首先,我们提供一个函数,该函数将根据前一个状态和当前状态accumulator计算下一个状态。其次,我们将传递一个表示初始状态的 。这将表示我们的下载处于待处理状态,没有任何进度或内容:DownloadHttpEventseedscanDownloadseed

{state: 'PENDING', progress: 0, content: null}
Enter fullscreen mode Exit fullscreen mode

我们accumulator将使用先前定义的保护来Download通过来自 HTTP 事件的信息随时更新状态:

(previous: Download, event: HttpEvent<Blob>): Download => {
  if (isHttpProgressEvent(event)) {
    return {
      progress: event.total
        ? Math.round((100 * event.loaded) / event.total)
        : previous.progress,
      state: 'IN_PROGRESS',
      content: null
    }
  }
  if (isHttpResponse(event)) {
    return {
      progress: 100,
      state: 'DONE',
      content: event.body
    }
  }
  return previous
}
Enter fullscreen mode Exit fullscreen mode

当遇到 时HttpProgressEvent,我们会根据已加载的字节数和总字节数计算进度。当我们收到 ,HttpResponse其主体中包含文件内容时,下载即完成。当收到除HttpProgressEvent或 之外的任何其他事件时HttpResponse,我们不会更改下载状态并按原样返回。这样,例如,我们可以将信息保留在progress属性中,而其他不允许我们计算进度的事件可以暂时忽略。

scan让我们最终定义与accumulatorand一起使用的自定义运算符seed

export function download(
  saver?: (b: Blob) => void
): (source: Observable<HttpEvent<Blob>>) => Observable<Download> {
  return (source: Observable<HttpEvent<Blob>>) =>
    source.pipe(
      scan((previous: Download, event: HttpEvent<Blob>): Download => {
          if (isHttpProgressEvent(event)) {
            return {
              progress: event.total
                ? Math.round((100 * event.loaded) / event.total)
                : previous.progress,
              state: 'IN_PROGRESS',
              content: null
            }
          }
          if (isHttpResponse(event)) {
            if (saver && event.body) {
              saver(event.body)
            }
            return {
              progress: 100,
              state: 'DONE',
              content: event.body
            }
          }
          return previous
        },
        {state: 'PENDING', progress: 0, content: null}
      )
    )
}
Enter fullscreen mode Exit fullscreen mode

请注意,此download运算符接受一个可选参数saver。一旦收到 HTTP 响应,就会使用 内部的下载内容调用此函数accumulator。这使我们能够传递将下载内容持久化到文件的策略,而无需将运算符直接耦合到 FileSaver.js。

使用自定义运算符时,服务代码现在如下所示:

import { saveAs } from 'file-saver';
...
download(url: string, filename?: string): Observable<Download> {
    return this.http.get(url, {
      reportProgress: true,
      observe: 'events',
      responseType: 'blob'
    }).pipe(download(blob => saveAs(blob, filename)))
}
Enter fullscreen mode Exit fullscreen mode

解耦 FileSaver.js

通过将 FileSaver.js 排除在我们的自定义操作符之外,最终的代码更易于维护。download无需模拟导入即可测试该saveAs操作符(相关测试参见此处)。如果我们将相同的模式应用于服务,测试起来也同样简单。因此,让我们saveAs在名为 的文件中创建一个自定义注入令牌来实现这一点saver.provider.ts

import { InjectionToken } from '@angular/core'
import { saveAs } from 'file-saver';

export type Saver = (blob: Blob, filename?: string) => void

export const SAVER = new InjectionToken<Saver>('saver')

export function getSaver(): Saver {
  return saveAs;
}
Enter fullscreen mode Exit fullscreen mode

然后使用令牌在 Angular 模块中注册提供程序:

import {SAVER, getSaver} from './saver.provider'

@NgModule({
  ...
  providers: [
    {provide: SAVER, useFactory: getSaver}
  ]
})
export class AppModule { }
Enter fullscreen mode Exit fullscreen mode

然后我们的服务可以注入保存方法,从而实现更加松散的耦合:

@Injectable({providedIn: 'root'})
export class DownloadService {

  constructor(
    private http: HttpClient,
    @Inject(SAVER) private save: Saver
  ) {}

  download(url: string, filename?: string): Observable<Download> {
    return this.http.get(url, {
      reportProgress: true,
      observe: 'events',
      responseType: 'blob'
    }).pipe(download(blob => this.save(blob, filename)))
  }
}
Enter fullscreen mode Exit fullscreen mode

显示进度条

让我们使用Angular Material 中的进度条来显示下载进度。我们将创建一个名为 的组件属性来绑定下载download$。现在,组件只需将可观察的 download 赋给该属性即可:

@Component({...})
export class MyComponent  {

  download$: Observable<Download>

  constructor(private downloads: DownloadService) {}

  download(): void {
    this.download$ = this.downloads.download(
        '/downloads/archive.zip', 
        'archive.zip'
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

然后,我们可以通过AsyncPipe 结合 NgIf来订阅这个可观察对象。下载过程中,我们将以“缓冲”模式显示进度条(您也可以使用“查询”模式),否则进度条是确定的。然后可以轻松地从 中获取进度条的值Download

<mat-progress-bar *ngIf="download$ | async as download"
        [mode]="download.state == 'PENDING' ? 'buffer' : 'determinate'" 
        [value]="download.progress">
</mat-progress-bar>
Enter fullscreen mode Exit fullscreen mode

专业提示:如果您需要在模板中将某个对象映射到两个以上的值,或者三元语句无法满足您的需求:请将可观察对象映射到您需要的类型,或者使用自定义管道,而不是从模板中调用组件函数。这两种方法都很容易编写,更具声明性,并且性能更佳。

与往常一样,如果您有任何疑问,请随时在下方留言或在 Twitter 上关注我@n_mehlhorn。您也可以在 Twitter 上关注我,并加入我的邮件列表,以便及时了解新文章的发布,并获取有关 Angular 和 Web 开发的小技巧。

这是 StackBlitz 的运行演示。下载的文件只有 3MB,因此您可能需要启用节流功能才能看到更多进度条。

文章来源:https://dev.to/angular/angular-file-download-with-progress-985
PREV
Angular v9 和 Universal:开箱即用的 SSR 和预渲染!
NEXT
让我们实现一个像 Angular Material Site 一样的主题切换🎨欢迎来到 Dev.to nanba!