文件下载

文件下载

服务器只要在响应头中加入Content-Disposition: attachment; filename="filename.jpg"即可触发浏览器的下载功能

Content-Disposition响应标头是用于告诉客户端该如何处理接收到的文件。

其中:

  • inline 指示浏览器在新的窗口中打开文件
  • attachment 表示附件,指示浏览器触发下载行为(不同的浏览器下载行为有所区别)下载行为需要a标签触发
  • filename="filename.jpg",保存文件时使用的默认文件名

这部分操作是由服务器完成的,和前端开发无关

const express = require('express')
const fs = require('fs');
const path = require('path');

const app = express()

app.get('/download', (req, res) => {
  const fileName = req?.body?.fileName || 'ES6.pdf'
  const filePath = path.resolve(__dirname, fileName)
  const fileContent = fs.readFileSync(filePath)
  res.setHeader('Content-Type', 'application/octet-stream')
  res.setHeader('Content-Disposition', `attachment;filename=${fileName}`)
  res.setHeader('"Access-Control-Expose-Headers"', "Content-Disposition")
  res.send(fileContent)
})

const port = 8001

app.listen(port, () => {
  console.log(`server is listen to ${port}`)
})
<a href="http://localhost:8001/download">普通下载</a>

需要额外的操作

绝大多数情况下不能直接使用a标签下载,需要进行一些其他的前置操作后才能进行下载。例如权限校验,项目一般会使用axios统一在每个请求的请求头添加token。由于a标签的网络请求类型为document而不是ajax,其并没有为开发人员开放可操作的api。因此一般情况使用以下方式进行文件下载。

  1. Ajax请求获取响应Response对象
  2. Response对象使用Response.prototype.blob方法转化为Blob对象
  3. Blob对象通过URL.createObjectURL转URL的字符串
  4. 创建a标签并使用url触发下载(a.click())
  5. 清除a标签并使用URL.revokeObjectURL(objectURL)清除URL字符串(对象)
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <!-- <a href="http://localhost:8001/download">普通下载</a> -->
  <button id="btn">下载</button>
</body>

<script>
  const btn = document.querySelector('#btn')
  btn.addEventListener('click', (e) => {
    download('http://localhost:8001/download')
  })

  function download(path, name = "es.pdf") {
    fetch(path).then(res => res.blob()).then(blob => {
      const url = URL.createObjectURL(blob)
      let a = document.createElement('a')
      a.href = url
      a.download = name
      a.click()
      a = null
      URL.revokeObjectURL(url)
    })
  }
</script>

</html>

如果使用axios的话设置responseType: 'blob',响应的数据类型就是Blob对象
这种方式因为需要先用ajax下载到内存,在写入磁盘,会受到文件大小,网络波动影响。当文件太大会有长时间的空白后才会提示文件下载情况

大文件分片下载

服务器端

const express = require('express')
const cors = require('cors')
const fs = require('fs');
const path = require('path');
const cookieParser = require('cookie-parser');
const app = express()

app.use(cors())
app.use(cookieParser())
app.use(express.json())
app.use(express.static(path.resolve(__dirname, './static')))

app.get('/streamDownload', (req, res) => {
  const fileName = '2023-10-11-19-27-34.mp4'
  const filePath = path.join(__dirname, fileName);
  const stat = fs.statSync(filePath);
  const fileSize = stat.size;
  const range = req.headers.range;
  if (!range) {
    res.status = 200
    res.setHeader('Content-Length', fileSize)
    res.setHeader('Content-Type', 'application/octet-stream')
    fs.createReadStream(filePath).pipe(res);
    return
  }

  const parts = range.replace(/bytes=/, '').split('-');
  const start = parseInt(parts[0], 10);
  const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;

  const chunksize = (end - start) + 1;
  const file = fs.createReadStream(filePath, { start, end });
  res.status = 206
  res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`) // 文件信息
  res.setHeader('Accept-Ranges', 'bytes') // 支持传输部分数据
  res.setHeader('Content-Length', chunksize)
  res.setHeader('Content-Type', 'application/octet-stream')
  res.setHeader('Access-Control-Expose-Headers', "Content-Range") // 暴露Content-Range响应头给js获取
  file.pipe(res);

})

const port = 8001

app.listen(port, () => {
  console.log(`server is listen to ${port}`)
})

首先,服务器在响应时,要在头中加入字段Accept-Ranges: bytes 。这个字段是向客户端表明:我这个文件可以支持传输部分数据,你只需要告诉我你需要的是哪一部分的数据即可,单位是字节

Content-Range: <unit> <range-start>-<range-end>/<size> 响应的 HTTP 报头指示其中一个完整的身体信息的部分消息所属。

前端

import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'
import request from './require'

export interface FileDownloadLoaderConfig {
  url: string,
  fileName: string,
  chunkSize?: number,
  callback?: Function
}

export class FileDownloadLoader {
  url: string
  fileName: string
  fileSize: number = 0
  finishedSize: number = 0
  chunkSize: number = 1024 * 1024
  chunkIndex: number = 0
  chunkTotal: number = 0
  isPause: boolean = false
  chunkList: Blob[] = []
  retryCount: number = 0
  maxRetryCount: number = 5
  cancelSource = axios.CancelToken.source()
  finalCallback?: Function
  constructor(config: FileDownloadLoaderConfig) {
    this.url = config.url
    this.fileName = config.fileName
    this.finalCallback = config.callback
    if (config.chunkSize) this.chunkSize = config.chunkSize
  }

  private get isOver() {
    if (this.fileSize === 0) return false
    return this.chunkIndex * this.chunkSize >= this.fileSize
  }

  private get isRetry() {
    return this.retryCount < this.maxRetryCount
  }

  get progress() {
    if (this.fileSize === 0) return 0
    return parseFloat((this.finishedSize / this.fileSize).toFixed(2))
  }

  async startDownload() {
    if (this.isPause || this.isOver) return
    let response = await this.chunkDownload()
    if (this.chunkIndex === 0) this.getStat(response)
    this.chunkIndex++
    this.finishedSize += response.data.size
    // todo chunk存储
    this.chunkList.push(response.data)
    this.isOver ? this.mergeChunk() : this.startDownload()
  }

  private async chunkDownload() {
    let start = this.chunkIndex * this.chunkSize
    let end = start + this.chunkSize - 1
    if (this.fileSize > 0 && end >= this.fileSize - 1) end = this.fileSize - 1
    const config: AxiosRequestConfig = {
      responseType: 'blob',
      headers: {
        range: `bytes=${start}-${end}`
      },
      cancelToken: this.cancelSource.token
    }
    let response
    try {
      response = await request.get<Blob>(this.url, config)
    } catch (err) {
      if (!this.isRetry) throw new Error(`多次重试失败:${err}`)
      this.retryCount++
      response = await request.get<Blob>(this.url, config)
    }
    return response
  }

  private getStat(response: AxiosResponse) {
    const headers = response.headers
    const fizeSizeParm = 'content-range'
    if (!headers[fizeSizeParm]) throw new Error(`headers[${fizeSizeParm}]: ${headers[fizeSizeParm]}`)
    const str = headers[fizeSizeParm] as string
    this.fileSize = parseInt(str.match(/(?<=\/)(\d+)/)![0])
    this.chunkTotal = Math.ceil(this.fileSize / this.chunkSize)
  }

  private mergeChunk() {
    if (!this.isOver) return
    const blob = new Blob(this.chunkList, {
      type: "application/octet-stream"
    })
    this.download(blob, this.fileName)
  }

  private download(data: Blob, name: string = '下载的文件') {
    const url = URL.createObjectURL(data)
    let a: HTMLAnchorElement | null = document.createElement('a')
    a.href = url
    a.download = name
    a.click()
    a = null
    URL.revokeObjectURL(url)
    this.finalCallback && this.finalCallback()
    this.reset()
  }

  private reset() {
    this.fileSize = 0
    this.chunkIndex = 0
    this.chunkTotal = 0
    this.chunkList = []
  }

  pause() {
    this.isPause = true
  }

  continue() {
    if (!this.isPause) return
    this.isPause = false
    this.startDownload()
  }

  cancel(msg = '请求取消') {
    this.cancelSource.cancel(msg)
    this.reset()
  }
}

posted @ 2024-07-23 18:21  冰凉小手  阅读(146)  评论(0)    收藏  举报