谈谈关于文件上传下载那些事

8,144 阅读6分钟

前端开发中总免不了关于文件的上传、下载需求。下面来总结一下常用的方法,欢迎讨论和吐槽。

form 表单提交

最传统的文件上传方法是使用form表单上传文件的,只需要把enctype设置为 multipart/form-data。这种方式上传文件不需要 js ,而且没有兼容问题,所有浏览器都支持,就是体验很差,导致页面刷新,页面其他数据丢失。

<form method="post" action="xxxxx" enctype="multipart/form-data">
  选择文件:<input type="file" name="file" />
  <br />
  标题:<input type="text" name="title" />
  <br />
  <button type="submit">提交</button>
</form>

注意:input 必须设置 name 属性,否则数据无法发送

文件接口上传

这种方法由服务端提供接口,设置相应的请求头,前端提交 formData 形式的文件数据。

<input id="uploadFile" type="file" name="file" accept="image/png,image/gif" />
  • accept:表示可以选择的文件 MIME 类型,多个 MIME 类型用英文逗号分开
  • multiple:是否可以选择多个文件
$('#uploadFile').on('change', function (e) {
  var file = this.files[0]

  var formData = new FormData()
  formData.append('file', file)

  $.ajax({
    url: 'xxxx',
    type: 'post',
    data: formData,
    cache: false,
    contentType: false,
    processData: false,
    success: function (res) {
      //
    },
  })
})
  • processData 设置为 false。因为 data 值是 FormData 对象,不需要对数据做处理。
  • cache 设置为 false,上传文件不需要缓存。
  • contentType 设置为 false。

分片上传

有时候我们上传的文件可能很大,比如视频等可能达到 2 个 G,这样会造成上传速度太慢,甚至有时候会出现链接超时的情况。而且有时候服务端会设置文件允许上传的大小,太大的文件就不允许上传了。为解决这个问题,我们可以将文件进行分片上传,每次只上传很小的一部分 比如 1M。

思路

  1. 将文件按一定大小(比如 1M)截取成一小份,并将切片带上 hash 值,用于作为标识。
  2. 将每个切片文件并发提交到服务端,服务端保存每个切片文件的信息。
  3. 切片上传完成后,服务端根据文件标识进行合并,合并完后删除切片文件。

这样因为每个切片是并发上传的,所以可以有效地降低上传时间。下面说一下具体的实现步骤。(PS:这是我司的实现方式,并不是唯一方法,且涉及到具体接口的代码就不贴在这里了)

生成 hash 值

无论上传文件信息还是上传切片文件,都必须要生成文件和切片的 hash。最简单粗暴的 hash 值可以用文件名字+下标来标识,但是这样文件名一旦修改就失去了效果,而事实上只要文件内容不变,hash 就不应该变化,所以正确的做法是根据文件内容生成 hash。我司用的是 spark-md5 库,在这里就不一一细说了。

文件信息上传

在文件分片上传之前需要把整个文件的信息如该文件的总的文件大小、文件名、哈希值等等,主要目的是初始化一个文件分片上传事件,返回文件 id,用于每个分片的提交。

getFileId (file) {
  let vm = this
  let formData = new FormData()
  formData.append('file', file)
  axios({
    timeout: 5 * 60 * 1000,
    headers: {
      'Content-Type': 'application/json-',
      'x-data': JSON.stringify({
        fileName: file.fileName,
        size: file.size,
        hash: 'hashxxx',
      }),
    },
    url: 'xxxxxx',
    method: 'POST',
  })
  .then((res) => {
    if (res.code === '200') {
      return res.data.fileId
    })
  .catch((err) => {
    console.log(err)
  })
}

文件切片分割

当前端获取到本地图片后,利用 Blob.prototype.slice 方法(和数组的 slice 方法相似),将大文件按照没小片 1M 进行切割,返回原文件的某个切片,再并发将各个分片上传到服务端。

getCkunk (file, fileId) {
  let vm = this
  let chunkSize = 1024 * 1024
  let totalSize = file.size
  let count = Math.ceil(totalSize / chunkSize)
  let chunkArr = []
  for (let i = 0; i < count; i++) {
    if (i === count.length - 1) {
      chunkArr.push(file.slice(i * chunkSize, totalSize))
    } else {
      chunkArr.push(file.slice(i * chunkSize, (i + 1) * chunkSize))
    }

  for (let index = 0; index < count; index++) {
    let item = chunkArr[index]
    this.uploadChunk(item, index, fileId)
  }
}

各个分片上传到服务端的方法。此处省略 hash 值得获取方式。

 ploadChunk(item, index, fileId) {
   let formData = new FormData()
   formData.append('file', item)
   request({
     headers: {
       'Content-Type': 'application/octet-stream;',
       'x-data': JSON.stringify({
         fileId: fileId,
         partId: index + 1,
         hash: res,
       })
     },
     url: 'xxxxx',
     method: 'POST',
     data: formData,
   })
   .then((res) => {
     return res.data.path
   })
   .catch((err) => {
     console.log(err)
   })
 }

显示上传进度条

由于文件比较大,即使是采用分片上传的方式也是需要一定的时间的,为了更好的用户体验,前端最好是提示上传的进度。这时候就需要后端在每个分片的放回结果加上上传的 100%字段。前端获取到返回值就改变当前进度。

当最后一个分片上传完成后,服务端返回文件的 url,前端获取 url,同时将进度条状态改变为 100%。

断点续传

上面说到的分片上传,解决了大文件上传超时和服务器的限制。但是对于更大的文件,上传并不是短时间内就上传完成,甚至有时候会面临断网或者手动暂停,难道就要重新将整个文件上传了,我们当然不希望。这时候断点续传就派上用场了。

下面说一下实现思路。
首先断点续传必须是基于分片上传的基础上的

  1. 每个分片上传的时候,服务端记录上传好的文件 hash 值,上传成功后返回 hash 值给前端,前端记录 hash 值
  2. 重新上传时,将每个文件的 hash 值与记录的 hash 值做比对,如果相同的话则跳过,继续下一个分段的上传。
  3. 全部分片上传完成后,服务端根据文件标识进行合并,合并完后删除小文件。

文件下载

文件下载有以下几种方法

form 表单提交

这是最原始的方法,为一个下载按钮添加 click 事件,点击时动态生成一个表单,利用表单提交的功能来实现文件的下载(实际上表单的提交就是发送一个请求)。

function downloadFile(downloadUrl, fileName) {
  // 创建表单
  let form = document.createElement('form')
  form.method = 'get'
  form.action = downloadUrl
  //form.target = '_blank';	// form新开页面
  document.body.appendChild(form)
  form.submit()
  document.body.removeChild(form)
}
  • 优点:兼容性好,不会出现 URL 长度限制问题。
  • 缺点:无法知道下载的进度,无法直接下载浏览器可直接预览的文件类型(如 txt/png 等)

window.open 或 window.location.href

最简单最直接的方式,实际上跟 a 标签访问下载链接一样

window.open('downloadFile.zip')
location.href = 'downloadFile.zip'

缺点

  • 会出现 URL 长度限制问题
  • 需要注意 url 编码问题
  • 浏览器可直接浏览的文件类型是不提供下载的,如 txt、png、jpg、gif 等
  • 不能添加 header,也就不能进行鉴权
  • 无法知道下载的进度

a 标签 download 属性

download 属性是 HTML5 新增的属性,兼容性可以了解下 can i use download

<a href="xxxx" download>点击下载</a>
<!-- 重命名下载文件 -->
<a href="xxxx" download="test">点击下载</a>

优点:能解决不能直接下载浏览器可浏览的文件。

缺点

  • 得已知下载文件地址
  • 不能下载跨域下的浏览器可浏览的文件
  • 有兼容性问题,特别是 IE
  • 不能进行鉴权

利用 Blob 对象

此方法除了能利用已知文件地址路径进行下载外,还能通过发送 ajax 请求 api 获取文件流进行下载。利用 Blob 对象可以将文件流转化成 Blob 二进制对象。

进行下载的思路很简单:发请求获取二进制数据,转化为 Blob 对象,利用 URL.createObjectUrl 生成 url 地址,赋值在 a 标签的 href 属性上,结合 download 进行下载。

downdFile (path, name) {
  const xhr = new XMLHttpRequest();
  xhr.open('get', path);
  xhr.responseType = 'blob';
  xhr.send();
  xhr.onload = function () {
    if (this.status === 200 || this.status === 304) {
      // const blob = new Blob([this.response], { type: xhr.getResponseHeader('Content-Type') });
      // const url = URL.createObjectURL(blob);
      const url = URL.createObjectURL(this.response);
      const a = document.createElement('a');
      a.style.display = 'none';
      a.href = url;
      a.download = name;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);
    }
  }
}

推荐文章