0


前端必学 - 大文件上传如何实现

前端必学 - 大文件上传如何实现

写在前面

1、正常的向后端发送请求,常见的

get

post

大家都很熟悉,是没有任何问题的;我们也可以用

post

或者表单请求发送

file

文件 到后端。 但是大文件的上传是一个特殊的情况: 大文件上传最主要的问题就在于:在一个请求中,要上传大量的数据,导致整个过程会比较漫长,且失败后需要重头开始上传。

  • 首先是上传过程时间比较久(要传输更多的报文,丢包重传的概率也更大),在这个过程中不能做其他操作,用户不能刷新页面,只能耐心等待请求完成。
  • 常见的软件应用中,前端/后端都会对一个请求的时间进行限制,那么大文件的上传就会很容易超时,导致上传失败。
  • 上传失败只能从头再来,你能接受吗?

2、面试/实际工作中,这也是一个常见的问题;所以,我们今天来彻底搞懂它。

源代码:https://github.com/Neveryu/bigfile-upload

问题分析

如果我们将这个文件拆分,将一次性上传大文件拆分成多个上传小文件的请求,因为请求是可以并发的,每个请求的时间就会缩短,且如果某个请求失败,只需要重新发送这一次请求即可,无需从头开始,这样不就可以解决大文件上传的问题了!

【明确目标】大文件上传需要实现下面几个需求:

  • 支持拆分上传请求(即文件切片)
  • 支持断点续传
  • 支持显示上传进度和暂停上传

开始操作

一、文件如何切片

用户选择了一个大文件后,我们该如何处理它?

JavaScript

中,文件

File

对象是

Blob

对象的子类,

Blob

对象包含一个重要的方法

slice

,通过这个方法,我们就可以对二进制文件进行拆分。

// 生成文件切片functioncreateFileChunk(file, size =SIZE){const fileChunkList =[]let cur =0while(cur < file.size){
    fileChunkList.push({file: file.slice(cur, cur + size),})
    cur += size
  }return fileChunkList
}

将文件拆分成

size

大小(可以是100k、500k、1M…)的分块,得到一个

file

的数组

fileChunkList

,然后每次请求只需要上传这一个部分的分块即可。服务器接收到这些切片后,再将他们拼接起来就可以了。

二、得到原文件的hash值

拿到原文件的

hash

值是关键的一步,同一个文件就算改文件名,

hash

值也不会变,就可以避免文件改名后重复上传的问题。

这里,我们使用 spark-md5.min.js 来根据文件的二进制内容计算文件的

hash

说明:考虑到如果上传一个超大文件,读取文件内容计算 hash 是非常耗费时间的,并且会引起 UI 的阻塞,导致页面假死状态,所以我们使用 web-worker 在 worker 线程计算 hash,这样用户仍可以在主界面正常的交互。

由于实例化 web-worker 时,参数是一个 js 文件路径且不能跨域,所以我们单独创建一个 hash.js 文件放在 public 目录下,另外在 worker 中也是不允许访问 dom 的,但它提供了importScripts 函数用于导入外部脚本,通过它导入 spark-md5。

计算

hash

代码如下:

// public/hash.js
self.onmessage=e=>{const{ fileChunkList }= e.data
    const spark =newself.SparkMD5.ArrayBuffer()let percentage =0let count =0constloadNext=index=>{const reader =newFileReader()
        reader.readAsArrayBuffer(fileChunkList[index].file)
        reader.onload=e=>{
            count++
            spark.append(e.target.result)if(count === fileChunkList.length){
                self.postMessage({percentage:100,hash: spark.end()})
                self.close()}else{
                percentage +=100/ fileChunkList.length
                self.postMessage({
                    percentage
                })loadNext(count)}}}loadNext(count)}

我们传入切片后的

fileChunkList

,利用

FileReader

读取每个切片的

ArrayBuffer

并不断传入

spark-md5

中,每计算完一个切片通过

postMessage

向主线程发送一个进度事件,全部完成后将最终的

hash

发送给主线程。

【重要说明】

spark-md5

需要根据所有切片才能算出一个

hash

值,不能直接将整个文件放入计算,否则即使不同文件也会有相同的

hash

,具体可以看官方文档 spark-md5。

三、文件上传

1)验证文件是否已经在服务端存在,如果存在,那就不用上传了,相当于秒传成功。

/**
 * 返回值说明
 * shouldUpload:标识这个文件是否还需要上传
 * uploadedList: 服务端存在该文件的切片List
 */const{ shouldUpload, uploadedList }=awaitverifyUpload(
  container.file.name,
  container.hash
)

如果

shouldUpload

false

,则表明这个文件不需要上传,提示:秒传成功。

2)然后上传除了

uploadedList

之外的文件切片。

/**
* 上传切片,同时过滤已上传的切片
* uploadedList:已经上传了的切片,这次不用上传了
*/asyncfunctionuploadChunks(uploadedList =[]){
  console.log(uploadedList,'uploadedList')const requestList = data.value
    .filter(({ hash })=>!uploadedList.includes(hash)).map(({ chunk, hash, index })=>{const formData =newFormData()// 切片文件
      formData.append('chunk', chunk)// 切片文件hash
      formData.append('hash', hash)// 大文件的文件名
      formData.append('filename', container.file.name)// 大文件hash
      formData.append('fileHash', container.hash)return{ formData, index }}).map(async({ formData, index })=>request({url:'http://localhost:9999',data: formData,onProgress:createProgressHandler(index, data.value[index]),requestList: requestListArr.value,}))// 并发切片await Promise.all(requestList)// 之前上传的切片数量 + 本次上传的切片数量 = 所有切片数量时// 切片并发上传完以后,发个请求告诉后端:合并切片if(uploadedList.length + requestList.length === data.value.length){// ok,都上传完了,请求合并文件mergeRequest()}}

四、文件合并

文件合并方案有这么几种。

1、前端发送切片完成后,发送一个合并请求,后端收到请求后,将之前上传的切片文件合并。
2、后台记录切片文件上传数据,当后台检测到切片上传完成后,自动完成合并。
3、创建一个和源文件大小相同的文件,根据切片文件的起止位置直接将切片写入对应位置。

我们这里采用的是第一种方案。

下面以用

node.js

的实现为例:

/**
 * 合并文件夹中的切片,生成一个完整的文件
 * @Author   Author
 * @DateTime 2021-12-30T17:41:19+0800
 * @param    {[string]}                 filePath [完整的文件路径(最终文件切片合并为一个完整的文件)]
 * @param    {[type]}                 fileHash [大文件的文件名]
 * @param    {[type]}                 size     [单个切片的大小]
 * @return   {[type]}                          [description]
 */constmergeFileChunk=async(filePath, fileHash, size)=>{// 所有的文件切片放在以“大文件的文件hash命名文件夹”中const chunkDir = path.resolve(UPLOAD_DIR, fileHash)const chunkPaths =await fse.readdir(chunkDir)// 根据切片下标进行排序// 否则直接读取目录的获得的顺序可能会错乱
  chunkPaths.sort((a, b)=>{return a.split('-')[1]- b.split('-')[1]})await Promise.all(
    chunkPaths.map((chunkPath, index)=>{returnpipeStream(
        path.resolve(chunkDir, chunkPath),/**
         * 创建写入的目标文件的流,并指定位置,
         * 目的是能够并发合并多个可读流到可写流中,这样即使流的顺序不同也能传输到正确的位置,
         * 所以这里还需要让前端在请求的时候多提供一个 size 参数。
         * 其实也可以等上一个切片合并完后再合并下个切片,这样就不需要指定位置,
         * 但传输速度会降低,所以使用了并发合并的手段,
         */
        fse.createWriteStream(filePath,{start: index * size,end:(index +1)* size
        }))}))// 文件合并后删除保存切片的目录
  fse.rmdirSync(chunkDir)}

服务端根据文件标识,分片顺序进行合并,合并完以后删除分片文件。

技术点总结【重要】

一、上传文件?

我们都知道如果要上传一个文件,需要把

form

标签的

enctype

设置为

multipart/form-data

,同时

method

必须为

post

方法。(这是最原始的方式)

那么

multipart/form-data

表示什么呢?

multipart 互联网上的混合资源,就是资源由多种元素组成,form-data 表示可以使用 HTML Forms 和 POST 方法上传文件,具体的定义可以参考 RFC 7578。

但是现在,我们很少使用这种

form

的方式了,我们都是直接使用

XMLHttpRequest

来发送

Ajax

请求。

最开始

XMLHttpRequest

是不支持传输二进制文件的。文件只能使用表单的方式上传,我们需要写一个

Form

,然后将

enctype

设置为

multipart/form-data

后来

XMLHttpRequest

升级为

Level 2

之后,新增了

FormData

对象,用于模拟表单数据,并且支持发送和接收二进制数据。我们目前使用的文件上传基本都是基于

XMLHttpRequest Level 2

xhr.send(data)

data

参数的数据类型会影响请求头部

content-type

的值。我们上传文件,

data

的类型是

FormData

,此时

content-type

默认值为

multipart/form-data

在上传文件场景下,不必设置 content-type 的值,浏览器会根据文件类型自动配置

二、显示进度

我们可以通过

onprogress

事件来实时显示进度,默认情况下这个事件每

50ms

触发一次。需要注意的是,上传过程和下载过程触发的是不同对象的

onprogress

事件:上传触发的是

xhr.upload

对象的

onprogress

事件,下载触发的是

xhr

对象的

onprogress

事件。

xhr.onprogress = updateProgress;
xhr.upload.onprogress = updateProgress;functionupdateProgress(event){if(event.lengthComputable){var completedPercent = event.loaded / event.total;}}

PS 特别提醒:

xhr.upload.onprogress

要写在

xhr.send

方法前面。

三、暂停上传

一个请求能被取消的前提是,我们需要将未收到响应的请求保存在一个列表中,然后依次调用每个

xhr

对象的

abort

方法。调用这个方法后,

xhr

对象会停止触发事件,将请求的

status

置为

0

,并且无法访问任何与响应有关的属性。

/**
 * 暂停
 */functionhandlePause(){
  requestListArr.value.forEach((xhr)=> xhr?.abort())
  requestListArr.value =[]}

从后端的角度看,一个上传请求被取消,意味着当前浏览器不会再向后端传输数据流,后端此时会报错,如下,错误信息也很清楚,就是文件还没到末尾就被客户端中断。当前文件切片写入失败。

四、Hash有优化空间吗?

计算

hash

耗时的问题,不仅可以通过

web-workder

,还可以参考

React

FFiber

架构,通过

requestIdleCallback

来利用浏览器的空闲时间计算,也不会卡死主线程;

如果觉得文件计算全量 Hash 比较慢的话,还有一种方式就是计算抽样 Hash,减少计算的字节数可以大幅度减少耗时;

在前文的代码中,我们是将大文件切片后,全量传入 spark-md5.min.js 中来根据文件的二进制内容计算文件的

hash

的。

那么,举个例子,我们可以这样优化: 文件切片以后,取第一个和最后一个切片全部内容,其他切片的取 首中尾 三个地方各2各字节来计算

hash

。这样来计算文件

hash

会快很多。

五、限制请求个数

解决了大文件计算

hash

的时间优化问题;下一个问题是:如果一个大文件切了成百上千来个切片,一次发几百个

http

请求,容易把浏览器搞崩溃。那么就需要控制并发,也就是限制请求个数

思路就是我们把异步请求放在一个队列里,比如并发数是4,就先同时发起4个请求,然后有请求结束了,再发起下一个请求即可。

我们通过并发数

max

来管理并发数,发起一个请求

max--

,结束一个请求

max++

即可。

【预留】

六、拥塞控制,动态计算文件切片大小

【预留】

演示&源码

源代码:https://github.com/Neveryu/bigfile-upload

在这里插入图片描述

源代码:https://github.com/Neveryu/bigfile-upload
——————————
【正文完】——————————

前端学习交流群,想进来面基的,可以加群: 685486827,832485817;

写在最后:约定优于配置 —— 软件开发的简约原则

——————————【完】——————————

我的:
个人网站: https://neveryu.github.io/neveryu/
Github: https://github.com/Neveryu
新浪微博: https://weibo.com/Neveryu
微信: miracle421354532

更多学习资源请关注我的新浪微博…好吗

标签: vue.js vue3 upload

本文转载自: https://blog.csdn.net/csdn_yudong/article/details/123720232
版权归原作者 Never Yu 所有, 如有侵权,请联系我们删除。

“前端必学 - 大文件上传如何实现”的评论:

还没有评论